mirror of https://github.com/usememos/memos.git
Merge 6c6e1462bc into b623162d37
This commit is contained in:
commit
fd2a2e03cb
|
|
@ -141,6 +141,12 @@ func (s *APIV1Service) CreateMemo(ctx context.Context, request *v1pb.CreateMemoR
|
|||
slog.Warn("Failed to dispatch memo created webhook", slog.Any("err", err))
|
||||
}
|
||||
|
||||
// Broadcast live refresh event.
|
||||
s.SSEHub.Broadcast(&SSEEvent{
|
||||
Type: SSEEventMemoCreated,
|
||||
Name: memoMessage.Name,
|
||||
})
|
||||
|
||||
return memoMessage, nil
|
||||
}
|
||||
|
||||
|
|
@ -471,6 +477,12 @@ func (s *APIV1Service) UpdateMemo(ctx context.Context, request *v1pb.UpdateMemoR
|
|||
slog.Warn("Failed to dispatch memo updated webhook", slog.Any("err", err))
|
||||
}
|
||||
|
||||
// Broadcast live refresh event.
|
||||
s.SSEHub.Broadcast(&SSEEvent{
|
||||
Type: SSEEventMemoUpdated,
|
||||
Name: memoMessage.Name,
|
||||
})
|
||||
|
||||
return memoMessage, nil
|
||||
}
|
||||
|
||||
|
|
@ -539,6 +551,12 @@ func (s *APIV1Service) DeleteMemo(ctx context.Context, request *v1pb.DeleteMemoR
|
|||
return nil, status.Errorf(codes.Internal, "failed to delete memo")
|
||||
}
|
||||
|
||||
// Broadcast live refresh event.
|
||||
s.SSEHub.Broadcast(&SSEEvent{
|
||||
Type: SSEEventMemoDeleted,
|
||||
Name: request.Name,
|
||||
})
|
||||
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -97,6 +97,12 @@ func (s *APIV1Service) UpsertMemoReaction(ctx context.Context, request *v1pb.Ups
|
|||
|
||||
reactionMessage := convertReactionFromStore(reaction)
|
||||
|
||||
// Broadcast live refresh event (reaction belongs to a memo).
|
||||
s.SSEHub.Broadcast(&SSEEvent{
|
||||
Type: SSEEventReactionUpserted,
|
||||
Name: request.Reaction.ContentId,
|
||||
})
|
||||
|
||||
return reactionMessage, nil
|
||||
}
|
||||
|
||||
|
|
@ -136,6 +142,12 @@ func (s *APIV1Service) DeleteMemoReaction(ctx context.Context, request *v1pb.Del
|
|||
return nil, status.Errorf(codes.Internal, "failed to delete reaction")
|
||||
}
|
||||
|
||||
// Broadcast live refresh event (reaction belongs to a memo).
|
||||
s.SSEHub.Broadcast(&SSEEvent{
|
||||
Type: SSEEventReactionDeleted,
|
||||
Name: reaction.ContentID,
|
||||
})
|
||||
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
package v1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"github.com/usememos/memos/server/auth"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// sseHeartbeatInterval is the interval between heartbeat pings to keep the connection alive.
|
||||
sseHeartbeatInterval = 30 * time.Second
|
||||
)
|
||||
|
||||
// RegisterSSERoutes registers the SSE endpoint on the given Echo instance.
|
||||
func RegisterSSERoutes(echoServer *echo.Echo, hub *SSEHub, storeInstance *store.Store, secret string) {
|
||||
authenticator := auth.NewAuthenticator(storeInstance, secret)
|
||||
echoServer.GET("/api/v1/sse", func(c echo.Context) error {
|
||||
return handleSSE(c, hub, authenticator)
|
||||
})
|
||||
}
|
||||
|
||||
// handleSSE handles the SSE connection for live memo refresh.
|
||||
// Authentication is done via Bearer token in the Authorization header,
|
||||
// or via the "token" query parameter (for EventSource which cannot set headers).
|
||||
func handleSSE(c echo.Context, hub *SSEHub, authenticator *auth.Authenticator) error {
|
||||
// Authenticate the request.
|
||||
authHeader := c.Request().Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
// Fall back to query parameter for native EventSource support.
|
||||
if token := c.QueryParam("token"); token != "" {
|
||||
authHeader = "Bearer " + token
|
||||
}
|
||||
}
|
||||
|
||||
result := authenticator.Authenticate(c.Request().Context(), authHeader)
|
||||
if result == nil {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||
}
|
||||
|
||||
// Set SSE headers.
|
||||
w := c.Response()
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no") // Disable nginx buffering
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// Flush headers immediately.
|
||||
if f, ok := w.Writer.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
|
||||
// Subscribe to the hub.
|
||||
client := hub.Subscribe()
|
||||
defer hub.Unsubscribe(client)
|
||||
|
||||
// Create a ticker for heartbeat pings.
|
||||
heartbeat := time.NewTicker(sseHeartbeatInterval)
|
||||
defer heartbeat.Stop()
|
||||
|
||||
ctx := c.Request().Context()
|
||||
|
||||
slog.Debug("SSE client connected")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Client disconnected.
|
||||
slog.Debug("SSE client disconnected")
|
||||
return nil
|
||||
|
||||
case data, ok := <-client.events:
|
||||
if !ok {
|
||||
// Channel closed, client was unsubscribed.
|
||||
return nil
|
||||
}
|
||||
// Write SSE event.
|
||||
if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil {
|
||||
return nil
|
||||
}
|
||||
if f, ok := w.Writer.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
|
||||
case <-heartbeat.C:
|
||||
// Send a heartbeat comment to keep the connection alive.
|
||||
if _, err := fmt.Fprint(w, ": heartbeat\n\n"); err != nil {
|
||||
return nil
|
||||
}
|
||||
if f, ok := w.Writer.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// SSEEventType represents the type of change event.
|
||||
type SSEEventType string
|
||||
|
||||
const (
|
||||
SSEEventMemoCreated SSEEventType = "memo.created"
|
||||
SSEEventMemoUpdated SSEEventType = "memo.updated"
|
||||
SSEEventMemoDeleted SSEEventType = "memo.deleted"
|
||||
SSEEventReactionUpserted SSEEventType = "reaction.upserted"
|
||||
SSEEventReactionDeleted SSEEventType = "reaction.deleted"
|
||||
)
|
||||
|
||||
// SSEEvent represents a change event sent to SSE clients.
|
||||
type SSEEvent struct {
|
||||
Type SSEEventType `json:"type"`
|
||||
// Name is the affected resource name (e.g., "memos/xxxx").
|
||||
// For reaction events, this is the memo resource name that the reaction belongs to.
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// JSON returns the JSON representation of the event.
|
||||
func (e *SSEEvent) JSON() []byte {
|
||||
data, _ := json.Marshal(e)
|
||||
return data
|
||||
}
|
||||
|
||||
// sseClient represents a single SSE connection.
|
||||
type sseClient struct {
|
||||
events chan []byte
|
||||
}
|
||||
|
||||
// SSEHub manages SSE client connections and broadcasts events.
|
||||
// It is safe for concurrent use.
|
||||
type SSEHub struct {
|
||||
mu sync.RWMutex
|
||||
clients map[*sseClient]struct{}
|
||||
}
|
||||
|
||||
// NewSSEHub creates a new SSE hub.
|
||||
func NewSSEHub() *SSEHub {
|
||||
return &SSEHub{
|
||||
clients: make(map[*sseClient]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe registers a new client and returns it.
|
||||
// The caller must call Unsubscribe when done.
|
||||
func (h *SSEHub) Subscribe() *sseClient {
|
||||
c := &sseClient{
|
||||
// Buffer a few events so a slow client doesn't block broadcasting.
|
||||
events: make(chan []byte, 32),
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.clients[c] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
return c
|
||||
}
|
||||
|
||||
// Unsubscribe removes a client and closes its channel.
|
||||
func (h *SSEHub) Unsubscribe(c *sseClient) {
|
||||
h.mu.Lock()
|
||||
if _, ok := h.clients[c]; ok {
|
||||
delete(h.clients, c)
|
||||
close(c.events)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// Broadcast sends an event to all connected clients.
|
||||
// Slow clients that have a full buffer will have the event dropped
|
||||
// to avoid blocking the broadcaster.
|
||||
func (h *SSEHub) Broadcast(event *SSEEvent) {
|
||||
data := event.JSON()
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for c := range h.clients {
|
||||
select {
|
||||
case c.events <- data:
|
||||
default:
|
||||
// Drop event for slow client to avoid blocking.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -46,6 +46,7 @@ func NewTestService(t *testing.T) *TestService {
|
|||
Profile: testProfile,
|
||||
Store: testStore,
|
||||
MarkdownService: markdownService,
|
||||
SSEHub: apiv1.NewSSEHub(),
|
||||
}
|
||||
|
||||
return &TestService{
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ type APIV1Service struct {
|
|||
Profile *profile.Profile
|
||||
Store *store.Store
|
||||
MarkdownService markdown.Service
|
||||
SSEHub *SSEHub
|
||||
|
||||
// thumbnailSemaphore limits concurrent thumbnail generation to prevent memory exhaustion
|
||||
thumbnailSemaphore *semaphore.Weighted
|
||||
|
|
@ -45,6 +46,7 @@ func NewAPIV1Service(secret string, profile *profile.Profile, store *store.Store
|
|||
Profile: profile,
|
||||
Store: store,
|
||||
MarkdownService: markdownService,
|
||||
SSEHub: NewSSEHub(),
|
||||
thumbnailSemaphore: semaphore.NewWeighted(3), // Limit to 3 concurrent thumbnail generations
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,10 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store
|
|||
|
||||
// Create and register RSS routes (needs markdown service from apiV1Service).
|
||||
rss.NewRSSService(s.Profile, s.Store, apiV1Service.MarkdownService).RegisterRoutes(rootGroup)
|
||||
|
||||
// Register SSE endpoint for live memo refresh.
|
||||
apiv1.RegisterSSERoutes(echoServer, apiV1Service.SSEHub, s.Store, s.Secret)
|
||||
|
||||
// Register gRPC gateway as api v1.
|
||||
if err := apiV1Service.RegisterGateway(ctx, echoServer); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to register gRPC gateway")
|
||||
|
|
|
|||
|
|
@ -1,20 +1,157 @@
|
|||
import { PencilIcon, TrashIcon } from "lucide-react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import TableEditorDialog from "@/components/TableEditorDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useUpdateMemo } from "@/hooks/useMemoQueries";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TableData } from "@/utils/markdown-table";
|
||||
import { findAllTables, parseMarkdownTable, replaceNthTable } from "@/utils/markdown-table";
|
||||
import { useMemoViewContext, useMemoViewDerived } from "../MemoView/MemoViewContext";
|
||||
import type { ReactMarkdownProps } from "./markdown/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Table (root wrapper with edit + delete buttons)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TableProps extends React.HTMLAttributes<HTMLTableElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Table = ({ children, className, node: _node, ...props }: TableProps) => {
|
||||
const tableRef = useRef<HTMLDivElement>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [tableData, setTableData] = useState<TableData | null>(null);
|
||||
const [tableIndex, setTableIndex] = useState(-1);
|
||||
|
||||
const { memo } = useMemoViewContext();
|
||||
const { readonly } = useMemoViewDerived();
|
||||
const { mutate: updateMemo } = useUpdateMemo();
|
||||
|
||||
/** Resolve which markdown table index this rendered table corresponds to. */
|
||||
const resolveTableIndex = useCallback(() => {
|
||||
const container = tableRef.current?.closest('[class*="wrap-break-word"]');
|
||||
if (!container) return -1;
|
||||
|
||||
const allTables = container.querySelectorAll("table");
|
||||
for (let i = 0; i < allTables.length; i++) {
|
||||
if (tableRef.current?.contains(allTables[i])) return i;
|
||||
}
|
||||
return -1;
|
||||
}, []);
|
||||
|
||||
const handleEditClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const idx = resolveTableIndex();
|
||||
const tables = findAllTables(memo.content);
|
||||
if (idx < 0 || idx >= tables.length) return;
|
||||
|
||||
const parsed = parseMarkdownTable(tables[idx].text);
|
||||
if (!parsed) return;
|
||||
|
||||
setTableData(parsed);
|
||||
setTableIndex(idx);
|
||||
setDialogOpen(true);
|
||||
},
|
||||
[memo.content, resolveTableIndex],
|
||||
);
|
||||
|
||||
const handleDeleteClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const idx = resolveTableIndex();
|
||||
if (idx < 0) return;
|
||||
|
||||
setTableIndex(idx);
|
||||
setDeleteDialogOpen(true);
|
||||
},
|
||||
[resolveTableIndex],
|
||||
);
|
||||
|
||||
const handleConfirmEdit = useCallback(
|
||||
(markdown: string) => {
|
||||
if (tableIndex < 0) return;
|
||||
const newContent = replaceNthTable(memo.content, tableIndex, markdown);
|
||||
updateMemo({
|
||||
update: { name: memo.name, content: newContent },
|
||||
updateMask: ["content"],
|
||||
});
|
||||
},
|
||||
[memo.content, memo.name, tableIndex, updateMemo],
|
||||
);
|
||||
|
||||
const handleConfirmDelete = useCallback(() => {
|
||||
if (tableIndex < 0) return;
|
||||
// Replace the table with an empty string to delete it.
|
||||
const newContent = replaceNthTable(memo.content, tableIndex, "");
|
||||
updateMemo({
|
||||
update: { name: memo.name, content: newContent },
|
||||
updateMask: ["content"],
|
||||
});
|
||||
setDeleteDialogOpen(false);
|
||||
}, [memo.content, memo.name, tableIndex, updateMemo]);
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-x-auto rounded-lg border border-border my-2">
|
||||
<table className={cn("w-full border-collapse text-sm", className)} {...props}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
<>
|
||||
<div ref={tableRef} className="group/table relative w-full overflow-x-auto rounded-lg border border-border my-2">
|
||||
<table className={cn("w-full border-collapse text-sm", className)} {...props}>
|
||||
{children}
|
||||
</table>
|
||||
{!readonly && (
|
||||
<div className="absolute top-1.5 right-1.5 flex items-center gap-1 opacity-0 group-hover/table:opacity-100 transition-all">
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded bg-accent/80 text-muted-foreground hover:bg-destructive/20 hover:text-destructive transition-colors"
|
||||
onClick={handleDeleteClick}
|
||||
title="Delete table"
|
||||
>
|
||||
<TrashIcon className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded bg-accent/80 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
onClick={handleEditClick}
|
||||
title="Edit table"
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TableEditorDialog open={dialogOpen} onOpenChange={setDialogOpen} initialData={tableData} onConfirm={handleConfirmEdit} />
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent size="sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete table</DialogTitle>
|
||||
<DialogDescription>Are you sure you want to delete this table? This action cannot be undone.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button variant="destructive" onClick={handleConfirmDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components (unchanged)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TableHeadProps extends React.HTMLAttributes<HTMLTableSectionElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,18 @@ import { useSuggestions } from "./useSuggestions";
|
|||
|
||||
const SlashCommands = ({ editorRef, editorActions, commands }: SlashCommandsProps) => {
|
||||
const handleCommandAutocomplete = (cmd: (typeof commands)[0], word: string, index: number, actions: EditorRefActions) => {
|
||||
// Remove trigger char + word, then insert command output
|
||||
// Remove trigger char + word.
|
||||
actions.removeText(index, word.length);
|
||||
|
||||
// If the command has a dialog action, invoke it instead of inserting text.
|
||||
if (cmd.action) {
|
||||
cmd.action();
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise insert the command output text.
|
||||
actions.insertText(cmd.run());
|
||||
// Position cursor relative to insertion point, if specified
|
||||
// Position cursor relative to insertion point, if specified.
|
||||
if (cmd.cursorOffset) {
|
||||
actions.setCursorPosition(index + cmd.cursorOffset);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
export interface Command {
|
||||
name: string;
|
||||
/** Returns text to insert. Ignored if `action` is set. */
|
||||
run: () => string;
|
||||
cursorOffset?: number;
|
||||
/** If set, called instead of inserting run() text. Used for dialog-based commands. */
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
export const editorCommands: Command[] = [
|
||||
|
|
@ -26,3 +29,18 @@ export const editorCommands: Command[] = [
|
|||
cursorOffset: 1,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Create the full editor commands list, with the table command
|
||||
* wired to open the table editor dialog instead of inserting raw markdown.
|
||||
*/
|
||||
export function createEditorCommands(onOpenTableEditor?: () => void): Command[] {
|
||||
if (!onOpenTableEditor) return editorCommands;
|
||||
|
||||
return editorCommands.map((cmd) => {
|
||||
if (cmd.name === "table") {
|
||||
return { ...cmd, action: onOpenTableEditor };
|
||||
}
|
||||
return cmd;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward
|
|||
isInIME = false,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
commands: customCommands,
|
||||
} = props;
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
|
|
@ -205,7 +206,7 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward
|
|||
onCompositionEnd={onCompositionEnd}
|
||||
></textarea>
|
||||
<TagSuggestions editorRef={editorRef} editorActions={ref} />
|
||||
<SlashCommands editorRef={editorRef} editorActions={ref} commands={editorCommands} />
|
||||
<SlashCommands editorRef={editorRef} editorActions={ref} commands={customCommands || editorCommands} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,20 @@
|
|||
import { LatLng } from "leaflet";
|
||||
import { uniqBy } from "lodash-es";
|
||||
import { FileIcon, LinkIcon, LoaderIcon, type LucideIcon, MapPinIcon, Maximize2Icon, MoreHorizontalIcon, PlusIcon } from "lucide-react";
|
||||
import {
|
||||
FileIcon,
|
||||
LinkIcon,
|
||||
LoaderIcon,
|
||||
type LucideIcon,
|
||||
MapPinIcon,
|
||||
Maximize2Icon,
|
||||
MoreHorizontalIcon,
|
||||
PlusIcon,
|
||||
TableIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useDebounce } from "react-use";
|
||||
import { useReverseGeocoding } from "@/components/map";
|
||||
import TableEditorDialog from "@/components/TableEditorDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -30,6 +41,7 @@ const InsertMenu = (props: InsertMenuProps) => {
|
|||
|
||||
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
|
||||
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
|
||||
const [tableDialogOpen, setTableDialogOpen] = useState(false);
|
||||
const [moreSubmenuOpen, setMoreSubmenuOpen] = useState(false);
|
||||
|
||||
const { handleTriggerEnter, handleTriggerLeave, handleContentEnter, handleContentLeave } = useDropdownMenuSubHoverDelay(
|
||||
|
|
@ -77,6 +89,17 @@ const InsertMenu = (props: InsertMenuProps) => {
|
|||
setLinkDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleOpenTableDialog = useCallback(() => {
|
||||
setTableDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleTableConfirm = useCallback(
|
||||
(markdown: string) => {
|
||||
props.onInsertText?.(markdown);
|
||||
},
|
||||
[props],
|
||||
);
|
||||
|
||||
const handleLocationClick = useCallback(() => {
|
||||
setLocationDialogOpen(true);
|
||||
if (!initialLocation && !location.locationInitialized) {
|
||||
|
|
@ -127,6 +150,12 @@ const InsertMenu = (props: InsertMenuProps) => {
|
|||
icon: FileIcon,
|
||||
onClick: handleUploadClick,
|
||||
},
|
||||
{
|
||||
key: "table",
|
||||
label: "Table",
|
||||
icon: TableIcon,
|
||||
onClick: handleOpenTableDialog,
|
||||
},
|
||||
{
|
||||
key: "link",
|
||||
label: t("tooltip.link-memo"),
|
||||
|
|
@ -140,7 +169,7 @@ const InsertMenu = (props: InsertMenuProps) => {
|
|||
onClick: handleLocationClick,
|
||||
},
|
||||
] satisfies Array<{ key: string; label: string; icon: LucideIcon; onClick: () => void }>,
|
||||
[handleLocationClick, handleOpenLinkDialog, handleUploadClick, t],
|
||||
[handleLocationClick, handleOpenLinkDialog, handleOpenTableDialog, handleUploadClick, t],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -207,6 +236,8 @@ const InsertMenu = (props: InsertMenuProps) => {
|
|||
onCancel={handleLocationCancel}
|
||||
onConfirm={handleLocationConfirm}
|
||||
/>
|
||||
|
||||
<TableEditorDialog open={tableDialogOpen} onOpenChange={setTableDialogOpen} onConfirm={handleTableConfirm} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { forwardRef } from "react";
|
||||
import { forwardRef, useMemo } from "react";
|
||||
import Editor, { type EditorRefActions } from "../Editor";
|
||||
import { createEditorCommands } from "../Editor/commands";
|
||||
import { useBlobUrls, useDragAndDrop } from "../hooks";
|
||||
import { useEditorContext } from "../state";
|
||||
import type { EditorContentProps } from "../types";
|
||||
import type { LocalFile } from "../types/attachment";
|
||||
|
||||
export const EditorContent = forwardRef<EditorRefActions, EditorContentProps>(({ placeholder }, ref) => {
|
||||
export const EditorContent = forwardRef<EditorRefActions, EditorContentProps>(({ placeholder, onOpenTableEditor }, ref) => {
|
||||
const { state, actions, dispatch } = useEditorContext();
|
||||
const { createBlobUrl } = useBlobUrls();
|
||||
|
||||
|
|
@ -54,6 +55,9 @@ export const EditorContent = forwardRef<EditorRefActions, EditorContentProps>(({
|
|||
event.preventDefault();
|
||||
};
|
||||
|
||||
// Build commands with the table editor action wired in.
|
||||
const commands = useMemo(() => createEditorCommands(onOpenTableEditor), [onOpenTableEditor]);
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col flex-1" {...dragHandlers}>
|
||||
<Editor
|
||||
|
|
@ -67,6 +71,7 @@ export const EditorContent = forwardRef<EditorRefActions, EditorContentProps>(({
|
|||
onPaste={handlePaste}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
commands={commands}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import InsertMenu from "../Toolbar/InsertMenu";
|
|||
import VisibilitySelector from "../Toolbar/VisibilitySelector";
|
||||
import type { EditorToolbarProps } from "../types";
|
||||
|
||||
export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoName }) => {
|
||||
export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoName, onInsertText }) => {
|
||||
const t = useTranslate();
|
||||
const { state, actions, dispatch } = useEditorContext();
|
||||
const { valid } = validationService.canSave(state);
|
||||
|
|
@ -34,6 +34,7 @@ export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoNa
|
|||
location={state.metadata.location}
|
||||
onLocationChange={handleLocationChange}
|
||||
onToggleFocusMode={handleToggleFocusMode}
|
||||
onInsertText={onInsertText}
|
||||
memoName={memoName}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useRef } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import TableEditorDialog from "@/components/TableEditorDialog";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { memoKeys } from "@/hooks/useMemoQueries";
|
||||
|
|
@ -68,6 +69,17 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
|||
dispatch(actions.toggleFocusMode());
|
||||
};
|
||||
|
||||
// Table editor dialog (shared by slash command and toolbar).
|
||||
const [tableDialogOpen, setTableDialogOpen] = useState(false);
|
||||
|
||||
const handleOpenTableEditor = useCallback(() => {
|
||||
setTableDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleTableConfirm = useCallback((markdown: string) => {
|
||||
editorRef.current?.insertText(markdown);
|
||||
}, []);
|
||||
|
||||
useKeyboard(editorRef, { onSave: handleSave });
|
||||
|
||||
async function handleSave() {
|
||||
|
|
@ -142,14 +154,21 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
|||
<FocusModeExitButton isActive={state.ui.isFocusMode} onToggle={handleToggleFocusMode} title={t("editor.exit-focus-mode")} />
|
||||
|
||||
{/* Editor content grows to fill available space in focus mode */}
|
||||
<EditorContent ref={editorRef} placeholder={placeholder} autoFocus={autoFocus} />
|
||||
<EditorContent ref={editorRef} placeholder={placeholder} autoFocus={autoFocus} onOpenTableEditor={handleOpenTableEditor} />
|
||||
|
||||
{/* Metadata and toolbar grouped together at bottom */}
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<EditorMetadata memoName={memoName} />
|
||||
<EditorToolbar onSave={handleSave} onCancel={onCancel} memoName={memoName} />
|
||||
<EditorToolbar
|
||||
onSave={handleSave}
|
||||
onCancel={onCancel}
|
||||
memoName={memoName}
|
||||
onInsertText={(text) => editorRef.current?.insertText(text)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TableEditorDialog open={tableDialogOpen} onOpenChange={setTableDialogOpen} onConfirm={handleTableConfirm} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,12 +18,14 @@ export interface MemoEditorProps {
|
|||
export interface EditorContentProps {
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
onOpenTableEditor?: () => void;
|
||||
}
|
||||
|
||||
export interface EditorToolbarProps {
|
||||
onSave: () => void;
|
||||
onCancel?: () => void;
|
||||
memoName?: string;
|
||||
onInsertText?: (text: string) => void;
|
||||
}
|
||||
|
||||
export interface EditorMetadataProps {
|
||||
|
|
@ -68,6 +70,7 @@ export interface InsertMenuProps {
|
|||
location?: Location;
|
||||
onLocationChange: (location?: Location) => void;
|
||||
onToggleFocusMode?: () => void;
|
||||
onInsertText?: (text: string) => void;
|
||||
memoName?: string;
|
||||
}
|
||||
|
||||
|
|
@ -92,6 +95,8 @@ export interface EditorProps {
|
|||
isInIME?: boolean;
|
||||
onCompositionStart?: () => void;
|
||||
onCompositionEnd?: () => void;
|
||||
/** Custom commands for slash menu. If not provided, defaults are used. */
|
||||
commands?: import("../Editor/commands").Command[];
|
||||
}
|
||||
|
||||
export interface VisibilitySelectorProps {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { Routes } from "@/router";
|
|||
import { UserNotification_Status } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import MemosLogo from "./MemosLogo";
|
||||
import SSEStatusIndicator from "./SSEStatusIndicator";
|
||||
import UserMenu from "./UserMenu";
|
||||
|
||||
interface NavLinkItem {
|
||||
|
|
@ -114,7 +115,10 @@ const Navigation = (props: Props) => {
|
|||
))}
|
||||
</div>
|
||||
{currentUser && (
|
||||
<div className={cn("w-full flex flex-col justify-end", props.collapsed ? "items-center" : "items-start pl-3")}>
|
||||
<div className={cn("w-full flex flex-col justify-end gap-1", props.collapsed ? "items-center" : "items-start pl-3")}>
|
||||
<div className={cn("flex items-center", props.collapsed ? "justify-center" : "pl-1")}>
|
||||
<SSEStatusIndicator />
|
||||
</div>
|
||||
<UserMenu collapsed={collapsed} />
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
import { useSSEConnectionStatus } from "@/hooks/useLiveMemoRefresh";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
/**
|
||||
* A small colored dot that indicates the SSE live-refresh connection status.
|
||||
* - Green = connected (live updates active)
|
||||
* - Yellow/pulsing = connecting
|
||||
* - Red = disconnected (updates not live)
|
||||
*/
|
||||
const SSEStatusIndicator = () => {
|
||||
const status = useSSEConnectionStatus();
|
||||
|
||||
const label =
|
||||
status === "connected" ? "Live updates active" : status === "connecting" ? "Connecting to live updates..." : "Live updates unavailable";
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex items-center justify-center size-5 cursor-default" aria-label={label}>
|
||||
<span
|
||||
className={cn(
|
||||
"block size-2 rounded-full transition-colors",
|
||||
status === "connected" && "bg-green-500",
|
||||
status === "connecting" && "bg-yellow-500 animate-pulse",
|
||||
status === "disconnected" && "bg-red-500",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default SSEStatusIndicator;
|
||||
|
|
@ -0,0 +1,415 @@
|
|||
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, PlusIcon, TrashIcon } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { ColumnAlignment, TableData } from "@/utils/markdown-table";
|
||||
import { createEmptyTable, serializeMarkdownTable } from "@/utils/markdown-table";
|
||||
import { Button } from "./ui/button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } from "./ui/dialog";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { VisuallyHidden } from "./ui/visually-hidden";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MONO_FONT = "'Fira Code', 'Fira Mono', 'JetBrains Mono', 'Cascadia Code', 'Consolas', ui-monospace, monospace";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TableEditorDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
initialData?: TableData | null;
|
||||
onConfirm: (markdown: string) => void;
|
||||
}
|
||||
|
||||
type SortState = { col: number; dir: "asc" | "desc" } | null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: TableEditorDialogProps) => {
|
||||
const [headers, setHeaders] = useState<string[]>([]);
|
||||
const [rows, setRows] = useState<string[][]>([]);
|
||||
const [alignments, setAlignments] = useState<ColumnAlignment[]>([]);
|
||||
const [sortState, setSortState] = useState<SortState>(null);
|
||||
|
||||
const inputRefs = useRef<Map<string, HTMLInputElement>>(new Map());
|
||||
|
||||
const setInputRef = useCallback((key: string, el: HTMLInputElement | null) => {
|
||||
if (el) inputRefs.current.set(key, el);
|
||||
else inputRefs.current.delete(key);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (initialData) {
|
||||
setHeaders([...initialData.headers]);
|
||||
setRows(initialData.rows.map((r) => [...r]));
|
||||
setAlignments([...initialData.alignments]);
|
||||
} else {
|
||||
const empty = createEmptyTable(3, 2);
|
||||
setHeaders(empty.headers);
|
||||
setRows(empty.rows);
|
||||
setAlignments(empty.alignments);
|
||||
}
|
||||
setSortState(null);
|
||||
}
|
||||
}, [open, initialData]);
|
||||
|
||||
const colCount = headers.length;
|
||||
const rowCount = rows.length;
|
||||
|
||||
// ---- Cell editing ----
|
||||
|
||||
const updateHeader = (col: number, value: string) => {
|
||||
setHeaders((prev) => {
|
||||
const next = [...prev];
|
||||
next[col] = value;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateCell = (row: number, col: number, value: string) => {
|
||||
setRows((prev) => {
|
||||
const next = prev.map((r) => [...r]);
|
||||
next[row][col] = value;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// ---- Add / Remove / Insert ----
|
||||
|
||||
const addColumn = () => {
|
||||
setHeaders((prev) => [...prev, ""]);
|
||||
setRows((prev) => prev.map((r) => [...r, ""]));
|
||||
setAlignments((prev) => [...prev, "none"]);
|
||||
setSortState(null);
|
||||
};
|
||||
|
||||
const insertColumnAt = (index: number) => {
|
||||
setHeaders((prev) => [...prev.slice(0, index), "", ...prev.slice(index)]);
|
||||
setRows((prev) => prev.map((r) => [...r.slice(0, index), "", ...r.slice(index)]));
|
||||
setAlignments((prev) => [...prev.slice(0, index), "none" as ColumnAlignment, ...prev.slice(index)]);
|
||||
setSortState(null);
|
||||
};
|
||||
|
||||
const removeColumn = (col: number) => {
|
||||
if (colCount <= 1) return;
|
||||
setHeaders((prev) => prev.filter((_, i) => i !== col));
|
||||
setRows((prev) => prev.map((r) => r.filter((_, i) => i !== col)));
|
||||
setAlignments((prev) => prev.filter((_, i) => i !== col));
|
||||
setSortState(null);
|
||||
};
|
||||
|
||||
const addRow = () => {
|
||||
setRows((prev) => [...prev, Array.from({ length: colCount }, () => "")]);
|
||||
};
|
||||
|
||||
const insertRowAt = (index: number) => {
|
||||
setRows((prev) => [...prev.slice(0, index), Array.from({ length: colCount }, () => ""), ...prev.slice(index)]);
|
||||
};
|
||||
|
||||
const removeRow = (row: number) => {
|
||||
if (rowCount <= 1) return;
|
||||
setRows((prev) => prev.filter((_, i) => i !== row));
|
||||
};
|
||||
|
||||
// ---- Sorting ----
|
||||
|
||||
const sortByColumn = (col: number) => {
|
||||
let newDir: "asc" | "desc" = "asc";
|
||||
if (sortState && sortState.col === col && sortState.dir === "asc") newDir = "desc";
|
||||
setSortState({ col, dir: newDir });
|
||||
setRows((prev) => {
|
||||
const sorted = [...prev].sort((a, b) => {
|
||||
const va = (a[col] || "").toLowerCase();
|
||||
const vb = (b[col] || "").toLowerCase();
|
||||
const na = Number(va);
|
||||
const nb = Number(vb);
|
||||
if (!Number.isNaN(na) && !Number.isNaN(nb)) return newDir === "asc" ? na - nb : nb - na;
|
||||
const cmp = va.localeCompare(vb);
|
||||
return newDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
return sorted;
|
||||
});
|
||||
};
|
||||
|
||||
// ---- Tab / keyboard navigation ----
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>, row: number, col: number) => {
|
||||
if (e.key !== "Tab") return;
|
||||
e.preventDefault();
|
||||
const nextCol = e.shiftKey ? col - 1 : col + 1;
|
||||
let nextRow = row;
|
||||
if (nextCol >= colCount) {
|
||||
if (row < rowCount - 1) {
|
||||
nextRow = row + 1;
|
||||
focusCell(nextRow, 0);
|
||||
} else {
|
||||
addRow();
|
||||
setTimeout(() => focusCell(rowCount, 0), 0);
|
||||
}
|
||||
} else if (nextCol < 0) {
|
||||
if (row > 0) {
|
||||
nextRow = row - 1;
|
||||
focusCell(nextRow, colCount - 1);
|
||||
} else {
|
||||
focusCell(-1, colCount - 1);
|
||||
}
|
||||
} else {
|
||||
focusCell(nextRow, nextCol);
|
||||
}
|
||||
};
|
||||
|
||||
const focusCell = (row: number, col: number) => {
|
||||
inputRefs.current.get(`${row}:${col}`)?.focus();
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
const md = serializeMarkdownTable({ headers, rows, alignments });
|
||||
onConfirm(md);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const SortIndicator = ({ col }: { col: number }) => {
|
||||
if (sortState?.col === col) {
|
||||
return sortState.dir === "asc" ? <ArrowUpIcon className="size-3 text-primary" /> : <ArrowDownIcon className="size-3 text-primary" />;
|
||||
}
|
||||
return <ArrowUpDownIcon className="size-3 opacity-40" />;
|
||||
};
|
||||
|
||||
const totalColSpan = colCount + 2;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent size="full" className="p-0! w-[min(56rem,calc(100vw-2rem))] h-[min(44rem,calc(100vh-4rem))]" showCloseButton={false}>
|
||||
<VisuallyHidden>
|
||||
<DialogClose />
|
||||
</VisuallyHidden>
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>Table Editor</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
<VisuallyHidden>
|
||||
<DialogDescription>Edit table headers, rows, columns and sort data</DialogDescription>
|
||||
</VisuallyHidden>
|
||||
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Scrollable table area */}
|
||||
<div className="flex-1 overflow-auto px-4 pb-2">
|
||||
{/* Clip wrapper: ensures blue highlight lines don't extend beyond the table */}
|
||||
<div className="relative overflow-hidden">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
{/* ============ STICKY HEADER ============ */}
|
||||
<thead className="sticky top-0 z-20">
|
||||
{/* Mask row: solid background that hides content scrolling behind the header */}
|
||||
<tr>
|
||||
<th colSpan={totalColSpan} className="h-4 bg-background p-0 border-0" />
|
||||
</tr>
|
||||
|
||||
{/* Header row */}
|
||||
<tr>
|
||||
{/* Row-number spacer */}
|
||||
<th className="w-7 min-w-7 bg-background" />
|
||||
|
||||
{headers.map((header, col) => (
|
||||
<th key={col} className="p-0 min-w-[140px] relative bg-background">
|
||||
{/* ---- Insert-column zone (between col-1 and col) ---- */}
|
||||
{col > 0 && (
|
||||
<div
|
||||
className="group/cins absolute -left-4 top-0 bottom-0 w-8 z-30 cursor-pointer"
|
||||
onClick={() => insertColumnAt(col)}
|
||||
>
|
||||
{/* Blue vertical line through the entire table */}
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2 top-0 w-0 group-hover/cins:w-[3px] bg-blue-500/70 transition-all pointer-events-none"
|
||||
style={{ bottom: "-200rem" }}
|
||||
/>
|
||||
{/* + button — absolutely centered on the column border */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-10 flex items-center justify-center size-5 rounded-full bg-background border border-border text-muted-foreground cursor-pointer opacity-0 group-hover/cins:opacity-100 hover:text-primary hover:border-primary transition-all shadow-sm"
|
||||
>
|
||||
<PlusIcon className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Insert column</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header cell — bg covers input + sort + delete */}
|
||||
<div className="flex items-center bg-accent/50 border border-border">
|
||||
<input
|
||||
ref={(el) => setInputRef(`-1:${col}`, el)}
|
||||
style={{ fontFamily: MONO_FONT }}
|
||||
className="flex-1 min-w-0 px-2 py-1.5 font-semibold text-xs uppercase tracking-wide bg-transparent focus:outline-none focus:ring-1 focus:ring-primary/40"
|
||||
value={header}
|
||||
onChange={(e) => updateHeader(col, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, -1, col)}
|
||||
placeholder={`Col ${col + 1}`}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center size-7 rounded cursor-pointer hover:bg-accent transition-colors"
|
||||
onClick={() => sortByColumn(col)}
|
||||
>
|
||||
<SortIndicator col={col} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Sort column</TooltipContent>
|
||||
</Tooltip>
|
||||
{colCount > 1 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center size-7 ml-1 rounded cursor-pointer opacity-40 hover:opacity-100 hover:bg-destructive/10 hover:text-destructive transition-all"
|
||||
onClick={() => removeColumn(col)}
|
||||
>
|
||||
<TrashIcon className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Remove column</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
|
||||
{/* Add column at end */}
|
||||
<th className="w-8 min-w-8 align-middle bg-background">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center size-7 rounded cursor-pointer hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={addColumn}
|
||||
>
|
||||
<PlusIcon className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add column</TooltipContent>
|
||||
</Tooltip>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
{/* ============ DATA ROWS ============ */}
|
||||
<tbody>
|
||||
{rows.map((row, rowIdx) => (
|
||||
<React.Fragment key={rowIdx}>
|
||||
<tr>
|
||||
{/* Row number — with insert-row zone on top border */}
|
||||
<td className="w-7 min-w-7 text-center align-middle relative">
|
||||
{rowIdx > 0 && (
|
||||
<div
|
||||
className="group/rins absolute -top-[10px] -left-1 right-0 h-5 z-10 cursor-pointer"
|
||||
onClick={() => insertRowAt(rowIdx)}
|
||||
>
|
||||
{/* Blue horizontal line extending across the table */}
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 left-0 h-0 group-hover/rins:h-[3px] bg-blue-500/70 transition-all pointer-events-none"
|
||||
style={{ width: "200rem" }}
|
||||
/>
|
||||
{/* + button at intersection of row border and first-column border */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 z-10 flex items-center justify-center size-5 rounded-full bg-background border border-border text-muted-foreground cursor-pointer opacity-0 group-hover/rins:opacity-100 hover:text-primary hover:border-primary transition-all shadow-sm"
|
||||
>
|
||||
<PlusIcon className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Insert row</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">{rowIdx + 1}</span>
|
||||
</td>
|
||||
|
||||
{/* Data cells */}
|
||||
{row.map((cell, col) => (
|
||||
<td key={col} className="p-0">
|
||||
<input
|
||||
ref={(el) => setInputRef(`${rowIdx}:${col}`, el)}
|
||||
style={{ fontFamily: MONO_FONT }}
|
||||
className="w-full px-2 py-1.5 text-sm bg-transparent border border-border focus:outline-none focus:ring-1 focus:ring-primary/40"
|
||||
value={cell}
|
||||
onChange={(e) => updateCell(rowIdx, col, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, rowIdx, col)}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
|
||||
{/* Row delete button */}
|
||||
<td className="w-8 min-w-8 align-middle">
|
||||
{rowCount > 1 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center size-7 rounded cursor-pointer opacity-40 hover:opacity-100 hover:bg-destructive/10 hover:text-destructive text-muted-foreground transition-all"
|
||||
onClick={() => removeRow(rowIdx)}
|
||||
>
|
||||
<TrashIcon className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Remove row</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Add row button below the table */}
|
||||
<div className="flex justify-center mt-2">
|
||||
<Button variant="ghost" size="sm" className="text-xs text-muted-foreground cursor-pointer" onClick={addRow}>
|
||||
<PlusIcon className="size-3.5" />
|
||||
Add row
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ============ FOOTER ============ */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{colCount} {colCount === 1 ? "column" : "columns"} · {rowCount} {rowCount === 1 ? "row" : "rows"}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" className="text-xs text-muted-foreground cursor-pointer" onClick={addRow}>
|
||||
<PlusIcon className="size-3.5" />
|
||||
Add row
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="text-xs text-muted-foreground cursor-pointer" onClick={addColumn}>
|
||||
<PlusIcon className="size-3.5" />
|
||||
Add column
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" className="cursor-pointer" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="cursor-pointer" onClick={handleConfirm}>
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableEditorDialog;
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
|
||||
import { getAccessToken } from "@/auth-state";
|
||||
import { memoKeys } from "@/hooks/useMemoQueries";
|
||||
import { userKeys } from "@/hooks/useUserQueries";
|
||||
|
||||
/**
|
||||
* Reconnection parameters for SSE connection.
|
||||
*/
|
||||
const INITIAL_RETRY_DELAY_MS = 1000;
|
||||
const MAX_RETRY_DELAY_MS = 30000;
|
||||
const RETRY_BACKOFF_MULTIPLIER = 2;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared connection status store (singleton)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SSEConnectionStatus = "connected" | "disconnected" | "connecting";
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
let _status: SSEConnectionStatus = "disconnected";
|
||||
const _listeners = new Set<Listener>();
|
||||
|
||||
function getSSEStatus(): SSEConnectionStatus {
|
||||
return _status;
|
||||
}
|
||||
|
||||
function setSSEStatus(s: SSEConnectionStatus) {
|
||||
if (_status !== s) {
|
||||
_status = s;
|
||||
_listeners.forEach((l) => l());
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeSSEStatus(listener: Listener): () => void {
|
||||
_listeners.add(listener);
|
||||
return () => _listeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook that returns the current SSE connection status.
|
||||
* Re-renders the component whenever the status changes.
|
||||
*/
|
||||
export function useSSEConnectionStatus(): SSEConnectionStatus {
|
||||
return useSyncExternalStore(subscribeSSEStatus, getSSEStatus, getSSEStatus);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* useLiveMemoRefresh connects to the server's SSE endpoint and
|
||||
* invalidates relevant React Query caches when change events
|
||||
* (memos, reactions) are received.
|
||||
*
|
||||
* This enables real-time updates across all open instances of the app.
|
||||
*/
|
||||
export function useLiveMemoRefresh() {
|
||||
const queryClient = useQueryClient();
|
||||
const retryDelayRef = useRef(INITIAL_RETRY_DELAY_MS);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const handleEvent = useCallback((event: SSEChangeEvent) => handleSSEEvent(event, queryClient), [queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
let retryTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const connect = async () => {
|
||||
if (!mounted) return;
|
||||
|
||||
const token = getAccessToken();
|
||||
if (!token) {
|
||||
setSSEStatus("disconnected");
|
||||
// Not logged in; retry after a delay in case the user logs in.
|
||||
retryTimeout = setTimeout(connect, 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
setSSEStatus("connecting");
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/v1/sse", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
signal: abortController.signal,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`SSE connection failed: ${response.status}`);
|
||||
}
|
||||
|
||||
// Successfully connected - reset retry delay.
|
||||
retryDelayRef.current = INITIAL_RETRY_DELAY_MS;
|
||||
setSSEStatus("connected");
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (mounted) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Process complete SSE messages (separated by double newlines).
|
||||
const messages = buffer.split("\n\n");
|
||||
// Keep the last incomplete chunk in the buffer.
|
||||
buffer = messages.pop() || "";
|
||||
|
||||
for (const message of messages) {
|
||||
if (!message.trim()) continue;
|
||||
|
||||
// Parse SSE format: lines starting with "data: " contain JSON payload.
|
||||
// Lines starting with ":" are comments (heartbeats).
|
||||
for (const line of message.split("\n")) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const jsonStr = line.slice(6);
|
||||
try {
|
||||
const event = JSON.parse(jsonStr) as SSEChangeEvent;
|
||||
handleEvent(event);
|
||||
} catch {
|
||||
// Ignore malformed JSON.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
// Intentional abort, don't reconnect.
|
||||
setSSEStatus("disconnected");
|
||||
return;
|
||||
}
|
||||
// Connection lost or failed - reconnect with backoff.
|
||||
}
|
||||
|
||||
setSSEStatus("disconnected");
|
||||
|
||||
// Reconnect with exponential backoff.
|
||||
if (mounted) {
|
||||
const delay = retryDelayRef.current;
|
||||
retryDelayRef.current = Math.min(delay * RETRY_BACKOFF_MULTIPLIER, MAX_RETRY_DELAY_MS);
|
||||
retryTimeout = setTimeout(connect, delay);
|
||||
}
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
setSSEStatus("disconnected");
|
||||
if (retryTimeout) {
|
||||
clearTimeout(retryTimeout);
|
||||
}
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [handleEvent]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SSEChangeEvent {
|
||||
type: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function handleSSEEvent(event: SSEChangeEvent, queryClient: ReturnType<typeof useQueryClient>) {
|
||||
switch (event.type) {
|
||||
case "memo.created":
|
||||
queryClient.invalidateQueries({ queryKey: memoKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: userKeys.stats() });
|
||||
break;
|
||||
|
||||
case "memo.updated":
|
||||
queryClient.invalidateQueries({ queryKey: memoKeys.detail(event.name) });
|
||||
queryClient.invalidateQueries({ queryKey: memoKeys.lists() });
|
||||
break;
|
||||
|
||||
case "memo.deleted":
|
||||
queryClient.removeQueries({ queryKey: memoKeys.detail(event.name) });
|
||||
queryClient.invalidateQueries({ queryKey: memoKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: userKeys.stats() });
|
||||
break;
|
||||
|
||||
case "reaction.upserted":
|
||||
case "reaction.deleted":
|
||||
queryClient.invalidateQueries({ queryKey: memoKeys.detail(event.name) });
|
||||
queryClient.invalidateQueries({ queryKey: memoKeys.lists() });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import { refreshAccessToken } from "@/connect";
|
|||
import { AuthProvider, useAuth } from "@/contexts/AuthContext";
|
||||
import { InstanceProvider, useInstance } from "@/contexts/InstanceContext";
|
||||
import { ViewProvider } from "@/contexts/ViewContext";
|
||||
import { useLiveMemoRefresh } from "@/hooks/useLiveMemoRefresh";
|
||||
import { useTokenRefreshOnFocus } from "@/hooks/useTokenRefreshOnFocus";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import router from "./router";
|
||||
|
|
@ -46,6 +47,9 @@ function AppInitializer({ children }: { children: React.ReactNode }) {
|
|||
// Related: https://github.com/usememos/memos/issues/5589
|
||||
useTokenRefreshOnFocus(refreshAccessToken, !!currentUser);
|
||||
|
||||
// Live refresh: listen for memo changes via SSE and invalidate caches.
|
||||
useLiveMemoRefresh();
|
||||
|
||||
if (!authInitialized || !instanceInitialized) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,203 @@
|
|||
/**
|
||||
* Utilities for parsing, serializing, and manipulating markdown tables.
|
||||
*/
|
||||
|
||||
export interface TableData {
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
/** Column alignments: "left" | "center" | "right" | "none". */
|
||||
alignments: ColumnAlignment[];
|
||||
}
|
||||
|
||||
export type ColumnAlignment = "left" | "center" | "right" | "none";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a markdown table string into structured TableData.
|
||||
*
|
||||
* Expects a standard GFM table:
|
||||
* | Header1 | Header2 |
|
||||
* | ------- | ------- |
|
||||
* | cell | cell |
|
||||
*/
|
||||
export function parseMarkdownTable(md: string): TableData | null {
|
||||
const lines = md
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0);
|
||||
|
||||
if (lines.length < 2) return null;
|
||||
|
||||
const parseRow = (line: string): string[] => {
|
||||
// Strip leading/trailing pipes and split by pipe.
|
||||
let trimmed = line;
|
||||
if (trimmed.startsWith("|")) trimmed = trimmed.slice(1);
|
||||
if (trimmed.endsWith("|")) trimmed = trimmed.slice(0, -1);
|
||||
return trimmed.split("|").map((cell) => cell.trim());
|
||||
};
|
||||
|
||||
const headers = parseRow(lines[0]);
|
||||
|
||||
// Parse the separator line for alignments.
|
||||
const sepCells = parseRow(lines[1]);
|
||||
const isSeparator = sepCells.every((cell) => /^:?-+:?$/.test(cell.trim()));
|
||||
if (!isSeparator) return null;
|
||||
|
||||
const alignments: ColumnAlignment[] = sepCells.map((cell) => {
|
||||
const c = cell.trim();
|
||||
const left = c.startsWith(":");
|
||||
const right = c.endsWith(":");
|
||||
if (left && right) return "center";
|
||||
if (right) return "right";
|
||||
if (left) return "left";
|
||||
return "none";
|
||||
});
|
||||
|
||||
const rows: string[][] = [];
|
||||
for (let i = 2; i < lines.length; i++) {
|
||||
const cells = parseRow(lines[i]);
|
||||
// Pad or trim to match header count.
|
||||
while (cells.length < headers.length) cells.push("");
|
||||
if (cells.length > headers.length) cells.length = headers.length;
|
||||
rows.push(cells);
|
||||
}
|
||||
|
||||
return { headers, rows, alignments };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serialization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Serialize TableData into a properly-aligned markdown table string.
|
||||
*/
|
||||
export function serializeMarkdownTable(data: TableData): string {
|
||||
const { headers, rows, alignments } = data;
|
||||
const colCount = headers.length;
|
||||
|
||||
// Calculate maximum width per column (minimum 3 for the separator).
|
||||
const widths: number[] = [];
|
||||
for (let c = 0; c < colCount; c++) {
|
||||
let max = Math.max(3, headers[c].length);
|
||||
for (const row of rows) {
|
||||
max = Math.max(max, (row[c] || "").length);
|
||||
}
|
||||
widths.push(max);
|
||||
}
|
||||
|
||||
const padCell = (text: string, width: number, align: ColumnAlignment): string => {
|
||||
const t = text || "";
|
||||
const padding = width - t.length;
|
||||
if (padding <= 0) return t;
|
||||
if (align === "right") return " ".repeat(padding) + t;
|
||||
if (align === "center") {
|
||||
const left = Math.floor(padding / 2);
|
||||
const right = padding - left;
|
||||
return " ".repeat(left) + t + " ".repeat(right);
|
||||
}
|
||||
return t + " ".repeat(padding);
|
||||
};
|
||||
|
||||
const formatRow = (cells: string[]): string => {
|
||||
const formatted = cells.map((cell, i) => {
|
||||
const align = alignments[i] || "none";
|
||||
return padCell(cell, widths[i], align);
|
||||
});
|
||||
return "| " + formatted.join(" | ") + " |";
|
||||
};
|
||||
|
||||
const separator = widths.map((w, i) => {
|
||||
const align = alignments[i] || "none";
|
||||
const dashes = "-".repeat(w);
|
||||
if (align === "center") return ":" + dashes.slice(1, -1) + ":";
|
||||
if (align === "right") return dashes.slice(0, -1) + ":";
|
||||
if (align === "left") return ":" + dashes.slice(1);
|
||||
return dashes;
|
||||
});
|
||||
const separatorLine = "| " + separator.join(" | ") + " |";
|
||||
|
||||
const headerLine = formatRow(headers);
|
||||
const rowLines = rows.map((row) => formatRow(row));
|
||||
|
||||
return [headerLine, separatorLine, ...rowLines].join("\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Find & Replace
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Regex that matches a full markdown table block (one or more table lines). */
|
||||
const TABLE_LINE = /^\s*\|.+\|\s*$/;
|
||||
|
||||
export interface TableMatch {
|
||||
/** The raw markdown of the table. */
|
||||
text: string;
|
||||
/** Start index in the source string. */
|
||||
start: number;
|
||||
/** End index (exclusive) in the source string. */
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all markdown table blocks in a content string.
|
||||
*/
|
||||
export function findAllTables(content: string): TableMatch[] {
|
||||
const lines = content.split("\n");
|
||||
const tables: TableMatch[] = [];
|
||||
let i = 0;
|
||||
let offset = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
if (TABLE_LINE.test(lines[i])) {
|
||||
const startLine = i;
|
||||
const startOffset = offset;
|
||||
// Consume all consecutive table lines.
|
||||
while (i < lines.length && TABLE_LINE.test(lines[i])) {
|
||||
offset += lines[i].length + 1; // +1 for newline
|
||||
i++;
|
||||
}
|
||||
const endOffset = offset - 1; // exclude trailing newline
|
||||
const text = lines.slice(startLine, i).join("\n");
|
||||
// Only count if it has at least a header + separator (2 lines).
|
||||
if (i - startLine >= 2) {
|
||||
tables.push({ text, start: startOffset, end: endOffset });
|
||||
}
|
||||
} else {
|
||||
offset += lines[i].length + 1;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return tables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the nth table in the content with new markdown.
|
||||
*/
|
||||
export function replaceNthTable(content: string, tableIndex: number, newTableMarkdown: string): string {
|
||||
const tables = findAllTables(content);
|
||||
if (tableIndex < 0 || tableIndex >= tables.length) return content;
|
||||
|
||||
const table = tables[tableIndex];
|
||||
return content.slice(0, table.start) + newTableMarkdown + content.slice(table.end);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default empty table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a default empty table with the given dimensions.
|
||||
*/
|
||||
export function createEmptyTable(cols = 2, rows = 2): TableData {
|
||||
return {
|
||||
headers: Array.from({ length: cols }, (_, i) => `Header ${i + 1}`),
|
||||
rows: Array.from({ length: rows }, () => Array.from({ length: cols }, () => "")),
|
||||
alignments: Array.from({ length: cols }, () => "none" as ColumnAlignment),
|
||||
};
|
||||
}
|
||||
|
|
@ -16,6 +16,12 @@ export default defineConfig({
|
|||
host: "0.0.0.0",
|
||||
port: 3001,
|
||||
proxy: {
|
||||
"^/api/v1/sse": {
|
||||
target: devProxyServer,
|
||||
xfwd: true,
|
||||
// SSE requires no response buffering and longer timeout.
|
||||
timeout: 0,
|
||||
},
|
||||
"^/api": {
|
||||
target: devProxyServer,
|
||||
xfwd: true,
|
||||
|
|
|
|||
Loading…
Reference in New Issue