From 61c78d0588d73cb1b22ef7adfd0f10658caae5f6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 6 Feb 2026 23:13:09 +0000 Subject: [PATCH] feat: table delete button, SSE status indicator, table editor UI polish Five improvements: 1. Delete table button: A trash icon appears to the left of the edit pencil on rendered tables (on hover). Clicking it opens a confirmation dialog before removing the entire table from the memo content. 2. SSE connection status indicator: A small colored dot in the sidebar navigation (above the user menu) shows the live-refresh connection status: - Green = connected, live updates active - Yellow (pulsing) = connecting - Red = disconnected, updates not live Hover tooltip explains the current state. Uses useSyncExternalStore for efficient re-renders from a singleton status store. 3. Always-visible action buttons: Sort and delete buttons in the table editor are now always visible at 40% opacity (previously hidden until hover). They become fully opaque on hover for better discoverability. 4. Larger table editor dialog: Fixed size of 56rem x 44rem (capped to viewport) so the dialog is spacious regardless of table dimensions. The table area scrolls within the fixed frame. 5. Monospace font in table editor: All cell inputs use Fira Code with fallbacks to Fira Mono, JetBrains Mono, Cascadia Code, Consolas, and system monospace for better alignment when editing tabular data. Co-authored-by: milvasic --- web/src/components/MemoContent/Table.tsx | 112 ++++++++++++++++------ web/src/components/Navigation.tsx | 6 +- web/src/components/SSEStatusIndicator.tsx | 36 +++++++ web/src/components/TableEditorDialog.tsx | 93 +++++++++--------- web/src/hooks/useLiveMemoRefresh.ts | 73 +++++++++++--- 5 files changed, 230 insertions(+), 90 deletions(-) create mode 100644 web/src/components/SSEStatusIndicator.tsx diff --git a/web/src/components/MemoContent/Table.tsx b/web/src/components/MemoContent/Table.tsx index 0580fb7f6..4c2bbb6eb 100644 --- a/web/src/components/MemoContent/Table.tsx +++ b/web/src/components/MemoContent/Table.tsx @@ -1,6 +1,8 @@ -import { PencilIcon } from "lucide-react"; +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"; @@ -9,7 +11,7 @@ import { useMemoViewContext, useMemoViewDerived } from "../MemoView/MemoViewCont import type { ReactMarkdownProps } from "./markdown/types"; // --------------------------------------------------------------------------- -// Table (root wrapper with edit button) +// Table (root wrapper with edit + delete buttons) // --------------------------------------------------------------------------- interface TableProps extends React.HTMLAttributes, ReactMarkdownProps { @@ -19,6 +21,7 @@ interface TableProps extends React.HTMLAttributes, ReactMarkdo export const Table = ({ children, className, node: _node, ...props }: TableProps) => { const tableRef = useRef(null); const [dialogOpen, setDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [tableData, setTableData] = useState(null); const [tableIndex, setTableIndex] = useState(-1); @@ -26,27 +29,26 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps 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(); - // Determine which table this is in the memo content by walking the DOM. - const container = tableRef.current?.closest('[class*="wrap-break-word"]'); - if (!container) return; - - const allTables = container.querySelectorAll("table"); - let idx = 0; - for (let i = 0; i < allTables.length; i++) { - if (tableRef.current?.contains(allTables[i])) { - idx = i; - break; - } - } - - // Find and parse the corresponding markdown table. + const idx = resolveTableIndex(); const tables = findAllTables(memo.content); - if (idx >= tables.length) return; + if (idx < 0 || idx >= tables.length) return; const parsed = parseMarkdownTable(tables[idx].text); if (!parsed) return; @@ -55,10 +57,24 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps setTableIndex(idx); setDialogOpen(true); }, - [memo.content], + [memo.content, resolveTableIndex], ); - const handleConfirm = useCallback( + 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); @@ -70,6 +86,17 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps [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 ( <>
@@ -77,17 +104,46 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps {children} {!readonly && ( - +
+ + +
)}
- + + + + {/* Delete confirmation dialog */} + + + + Delete table + Are you sure you want to delete this table? This action cannot be undone. + + + + + + + + + ); }; diff --git a/web/src/components/Navigation.tsx b/web/src/components/Navigation.tsx index 484f0837b..faf07c55b 100644 --- a/web/src/components/Navigation.tsx +++ b/web/src/components/Navigation.tsx @@ -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) => { ))} {currentUser && ( -
+
+
+ +
)} diff --git a/web/src/components/SSEStatusIndicator.tsx b/web/src/components/SSEStatusIndicator.tsx new file mode 100644 index 000000000..ca4df407d --- /dev/null +++ b/web/src/components/SSEStatusIndicator.tsx @@ -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 ( + + + + + + + {label} + + ); +}; + +export default SSEStatusIndicator; diff --git a/web/src/components/TableEditorDialog.tsx b/web/src/components/TableEditorDialog.tsx index 54a52c383..31af74205 100644 --- a/web/src/components/TableEditorDialog.tsx +++ b/web/src/components/TableEditorDialog.tsx @@ -8,6 +8,13 @@ import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } fr import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; import { VisuallyHidden } from "./ui/visually-hidden"; +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Monospace font stack for the cell inputs. */ +const MONO_FONT = "'Fira Code', 'Fira Mono', 'JetBrains Mono', 'Cascadia Code', 'Consolas', ui-monospace, monospace"; + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -121,7 +128,6 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table const sorted = [...prev].sort((a, b) => { const va = (a[col] || "").toLowerCase(); const vb = (b[col] || "").toLowerCase(); - // Try numeric comparison first. const na = Number(va); const nb = Number(vb); if (!Number.isNaN(na) && !Number.isNaN(nb)) { @@ -143,23 +149,18 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table let nextRow = row; if (nextCol >= colCount) { - // Move to first cell of next row. if (row < rowCount - 1) { nextRow = row + 1; focusCell(nextRow, 0); } else { - // At last cell – add a new row and focus it. addRow(); - // Need to wait for state update; use setTimeout. setTimeout(() => focusCell(rowCount, 0), 0); } } else if (nextCol < 0) { - // Move to last cell of previous row. if (row > 0) { nextRow = row - 1; focusCell(nextRow, colCount - 1); } else { - // Move to header row. focusCell(-1, colCount - 1); } } else { @@ -189,12 +190,12 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table if (sortState?.col === col) { return sortState.dir === "asc" ? : ; } - return ; + return ; }; return ( - + @@ -204,54 +205,53 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table Edit table headers, rows, columns and sort data -
- {/* Scrollable table area */} -
+
+ {/* Scrollable table area — grows to fill */} +
{/* Header row */} {/* Row number column */} - ))} @@ -277,15 +277,15 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table {rows.map((row, rowIdx) => ( {/* Row number + remove */} -
+ {headers.map((header, col) => ( - -
-
- setInputRef(`-1:${col}`, el)} - className="flex-1 min-w-0 px-2 py-1.5 font-semibold text-xs uppercase tracking-wide bg-accent/50 border border-border rounded-tl-md 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}`} - /> +
+
+ 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-accent/50 border border-border rounded-tl-md 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}`} + /> + + + + + Sort column + + {colCount > 1 && ( - Sort column + Remove column - {colCount > 1 && ( - - - - - Remove column - - )} -
+ )}
-
- {rowIdx + 1} +
+
+ {rowIdx + 1} {rowCount > 1 && (
setInputRef(`${rowIdx}:${col}`, el)} + style={{ fontFamily: MONO_FONT }} className={cn( "w-full px-2 py-1.5 text-sm bg-transparent border border-border focus:outline-none focus:ring-1 focus:ring-primary/40", rowIdx === rowCount - 1 && "rounded-bl-md", diff --git a/web/src/hooks/useLiveMemoRefresh.ts b/web/src/hooks/useLiveMemoRefresh.ts index 8dc9c40c1..3f0b98049 100644 --- a/web/src/hooks/useLiveMemoRefresh.ts +++ b/web/src/hooks/useLiveMemoRefresh.ts @@ -1,5 +1,5 @@ import { useQueryClient } from "@tanstack/react-query"; -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useSyncExternalStore } from "react"; import { getAccessToken } from "@/auth-state"; import { memoKeys } from "@/hooks/useMemoQueries"; import { userKeys } from "@/hooks/useUserQueries"; @@ -11,10 +11,49 @@ 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(); + +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 memo change events - * (created, updated, deleted) are received. + * invalidates relevant React Query caches when change events + * (memos, reactions) are received. * * This enables real-time updates across all open instances of the app. */ @@ -23,6 +62,8 @@ export function useLiveMemoRefresh() { const retryDelayRef = useRef(INITIAL_RETRY_DELAY_MS); const abortControllerRef = useRef(null); + const handleEvent = useCallback((event: SSEChangeEvent) => handleSSEEvent(event, queryClient), [queryClient]); + useEffect(() => { let mounted = true; let retryTimeout: ReturnType | null = null; @@ -32,11 +73,13 @@ export function useLiveMemoRefresh() { 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; @@ -55,6 +98,7 @@ export function useLiveMemoRefresh() { // Successfully connected - reset retry delay. retryDelayRef.current = INITIAL_RETRY_DELAY_MS; + setSSEStatus("connected"); const reader = response.body.getReader(); const decoder = new TextDecoder(); @@ -80,8 +124,8 @@ export function useLiveMemoRefresh() { if (line.startsWith("data: ")) { const jsonStr = line.slice(6); try { - const event = JSON.parse(jsonStr) as { type: string; name: string }; - handleSSEEvent(event, queryClient); + const event = JSON.parse(jsonStr) as SSEChangeEvent; + handleEvent(event); } catch { // Ignore malformed JSON. } @@ -92,11 +136,14 @@ export function useLiveMemoRefresh() { } 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; @@ -109,6 +156,7 @@ export function useLiveMemoRefresh() { return () => { mounted = false; + setSSEStatus("disconnected"); if (retryTimeout) { clearTimeout(retryTimeout); } @@ -116,9 +164,13 @@ export function useLiveMemoRefresh() { abortControllerRef.current.abort(); } }; - }, [queryClient]); + }, [handleEvent]); } +// --------------------------------------------------------------------------- +// Event handling +// --------------------------------------------------------------------------- + interface SSEChangeEvent { type: string; name: string; @@ -127,32 +179,23 @@ interface SSEChangeEvent { function handleSSEEvent(event: SSEChangeEvent, queryClient: ReturnType) { switch (event.type) { case "memo.created": - // Invalidate memo lists so new memos appear. queryClient.invalidateQueries({ queryKey: memoKeys.lists() }); - // Invalidate user stats (memo count changed). queryClient.invalidateQueries({ queryKey: userKeys.stats() }); break; case "memo.updated": - // Invalidate the specific memo detail cache. queryClient.invalidateQueries({ queryKey: memoKeys.detail(event.name) }); - // Invalidate memo lists to reflect updated content/ordering. queryClient.invalidateQueries({ queryKey: memoKeys.lists() }); break; case "memo.deleted": - // Remove the specific memo from cache. queryClient.removeQueries({ queryKey: memoKeys.detail(event.name) }); - // Invalidate memo lists. queryClient.invalidateQueries({ queryKey: memoKeys.lists() }); - // Invalidate user stats (memo count changed). queryClient.invalidateQueries({ queryKey: userKeys.stats() }); break; case "reaction.upserted": case "reaction.deleted": - // Reactions are embedded in the memo object, so invalidate the memo detail - // and lists to reflect the updated reaction state. queryClient.invalidateQueries({ queryKey: memoKeys.detail(event.name) }); queryClient.invalidateQueries({ queryKey: memoKeys.lists() }); break;