From 186743ee370bba218b0b800470e39237342ab764 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Wed, 3 Dec 2025 15:16:26 +0100 Subject: [PATCH] feat: frontend status toast overlay, while refactoring document store usage. --- .../src/components/StatusToastOverlay.tsx | 83 +++++++++++++ frontend/src/contexts/StatusToastContext.tsx | 111 ++++++++++++++++++ frontend/src/desktop/useCardPointer.ts | 2 +- .../src/documents/DocumentSummarySection.tsx | 2 +- .../documents/store/useDocumentsStore.ts | 20 ---- .../hooks/documents/useDocumentsWorkspace.ts | 14 ++- frontend/src/index.tsx | 11 +- frontend/src/sidebar/Sidebar.tsx | 9 -- frontend/src/sidebar/useSidebarProps.ts | 7 -- frontend/src/styles/status-toast.css | 86 ++++++++++++++ 10 files changed, 301 insertions(+), 44 deletions(-) create mode 100644 frontend/src/components/StatusToastOverlay.tsx create mode 100644 frontend/src/contexts/StatusToastContext.tsx delete mode 100644 frontend/src/hooks/documents/store/useDocumentsStore.ts create mode 100644 frontend/src/styles/status-toast.css diff --git a/frontend/src/components/StatusToastOverlay.tsx b/frontend/src/components/StatusToastOverlay.tsx new file mode 100644 index 0000000..32cdaea --- /dev/null +++ b/frontend/src/components/StatusToastOverlay.tsx @@ -0,0 +1,83 @@ +import React, { useState, useCallback, useEffect, useRef } from 'react'; +import { useStatusToast } from '../contexts/StatusToastContext'; +import '../styles/status-toast.css'; + +const FADE_OUT_DURATION = 300; // Match CSS animation duration + +const StatusToastOverlay: React.FC = () => { + const { toasts, removeToast } = useStatusToast(); + const [exitingToasts, setExitingToasts] = useState>(new Set()); + const [displayToasts, setDisplayToasts] = useState(toasts); + const prevToastIdsRef = useRef>(new Set()); + + // Detect when toasts are removed from context and trigger fade + useEffect(() => { + const currentIds = new Set(toasts.map(t => t.id)); + const prevIds = prevToastIdsRef.current; + + // Find toasts that were removed + const removedIds = Array.from(prevIds).filter((id) => typeof id === 'string' && !currentIds.has(id)); + + // Trigger fade for removed toasts + if (removedIds.length > 0) { + setExitingToasts(prev => { + const next = new Set(prev); + removedIds.forEach(id => next.add(id)); + return next; + }); + + // Remove from display after fade + setTimeout(() => { + setDisplayToasts(current => current.filter(t => !removedIds.includes(t.id))); + setExitingToasts(prev => { + const next = new Set(prev); + removedIds.forEach(id => next.delete(id)); + return next; + }); + }, FADE_OUT_DURATION); + } + + // Add new toasts to display + const newToasts = toasts.filter(t => !prevIds.has(t.id)); + if (newToasts.length > 0) { + setDisplayToasts(toasts); + } + + prevToastIdsRef.current = currentIds; + }, [toasts]); + + const handleRemove = useCallback((id: string) => { + removeToast(id); + }, [removeToast]); + + if (displayToasts.length === 0) { + return null; + } + + return ( +
+ {displayToasts.map((toast) => { + const isExiting = exitingToasts.has(toast.id); + const classNames = [ + 'status-toast-pill', + `status-toast-pill--${toast.variant}`, + isExiting ? 'status-toast-pill--exiting' : '', + ].filter(Boolean).join(' '); + + return ( +
handleRemove(toast.id)} + role="status" + aria-live="polite" + > + {toast.message} +
+ ); + })} +
+ ); +}; + +export default StatusToastOverlay; diff --git a/frontend/src/contexts/StatusToastContext.tsx b/frontend/src/contexts/StatusToastContext.tsx new file mode 100644 index 0000000..541b0fc --- /dev/null +++ b/frontend/src/contexts/StatusToastContext.tsx @@ -0,0 +1,111 @@ +import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react'; + +export type ToastVariant = 'info' | 'success' | 'error'; + +export interface ToastMessage { + id: string; + message: string; + variant: ToastVariant; + timestamp: number; + duration: number; +} + +interface StatusToastContextValue { + toasts: ToastMessage[]; + showToast: (message: string, variant?: ToastVariant, duration?: number) => void; + removeToast: (id: string) => void; +} + +const StatusToastContext = createContext(null); + +const DEFAULT_DURATIONS: Record = { + success: 3000, + info: 5000, + error: 8000, +}; + +const MAX_TOASTS = 3; + +export const StatusToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [toasts, setToasts] = useState([]); + const timeoutRefs = useRef>(new Map()); + + const removeToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((toast) => toast.id !== id)); + + // Clear timeout if it exists + const timeout = timeoutRefs.current.get(id); + if (timeout) { + clearTimeout(timeout); + timeoutRefs.current.delete(id); + } + }, []); + + const showToast = useCallback((message: string, variant: ToastVariant = 'info', duration?: number) => { + const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const finalDuration = duration ?? DEFAULT_DURATIONS[variant]; + + const newToast: ToastMessage = { + id, + message, + variant, + timestamp: Date.now(), + duration: finalDuration, + }; + + setToasts((prev) => { + const updated = [...prev, newToast]; + + // If we exceed max toasts, remove the oldest ones + if (updated.length > MAX_TOASTS) { + const removed = updated.slice(0, updated.length - MAX_TOASTS); + removed.forEach((toast) => { + const timeout = timeoutRefs.current.get(toast.id); + if (timeout) { + clearTimeout(timeout); + timeoutRefs.current.delete(toast.id); + } + }); + return updated.slice(-MAX_TOASTS); + } + + return updated; + }); + + // Set auto-dismiss timeout - trigger fade then remove + const timeout = setTimeout(() => { + removeToast(id); + }, finalDuration); + + timeoutRefs.current.set(id, timeout); + }, [removeToast]); + + // Cleanup all timeouts on unmount + useEffect(() => { + const timeouts = timeoutRefs.current; + return () => { + timeouts.forEach((timeout) => clearTimeout(timeout)); + timeouts.clear(); + }; + }, []); + + const value: StatusToastContextValue = { + toasts, + showToast, + removeToast, + }; + + return ( + + {children} + + ); +}; + +export const useStatusToast = (): StatusToastContextValue => { + const context = useContext(StatusToastContext); + if (!context) { + throw new Error('useStatusToast must be used within StatusToastProvider'); + } + return context; +}; diff --git a/frontend/src/desktop/useCardPointer.ts b/frontend/src/desktop/useCardPointer.ts index 7b22e1b..9d342b8 100644 --- a/frontend/src/desktop/useCardPointer.ts +++ b/frontend/src/desktop/useCardPointer.ts @@ -108,7 +108,7 @@ export const useCardPointer = ( } }, 500); // 500ms long press } - }, [card, isSelected, selection, onSelect, addPointer, activePointersRef]); + }, [card, isSelected, selection, onSelect, addPointer, activePointersRef, requestCanvasFocus]); const onPointerMove = useCallback((e: React.PointerEvent) => { // Ignore interactions on interactive child elements diff --git a/frontend/src/documents/DocumentSummarySection.tsx b/frontend/src/documents/DocumentSummarySection.tsx index dc258f4..dbcbe3e 100644 --- a/frontend/src/documents/DocumentSummarySection.tsx +++ b/frontend/src/documents/DocumentSummarySection.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react'; import { Link } from 'react-router-dom'; -import { EditIcon, IconX, CheckIcon, PlusIcon } from '../ui/icons'; +import { EditIcon, IconX, PlusIcon } from '../ui/icons'; import InlineRenameInput from './components/InlineRenameInput'; import SelectionAssignmentMenu, { SelectionAssignmentMenuItem, diff --git a/frontend/src/hooks/documents/store/useDocumentsStore.ts b/frontend/src/hooks/documents/store/useDocumentsStore.ts deleted file mode 100644 index fd31444..0000000 --- a/frontend/src/hooks/documents/store/useDocumentsStore.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useCallback, useState } from 'react'; - -export type StatusVariant = 'info' | 'success' | 'error'; - -export interface StatusMessage { - message: string; - variant: StatusVariant; -} - -export const useDocumentsStore = () => { - const [status, setStatus] = useState(null); - - const setStatusMessage = useCallback((message?: string | null, variant: StatusVariant = 'info') => { - setStatus(message ? { message, variant } : null); - }, []); - - return { status, setStatusMessage }; -}; - -export default useDocumentsStore; diff --git a/frontend/src/hooks/documents/useDocumentsWorkspace.ts b/frontend/src/hooks/documents/useDocumentsWorkspace.ts index 46fa3cf..4d4fc02 100644 --- a/frontend/src/hooks/documents/useDocumentsWorkspace.ts +++ b/frontend/src/hooks/documents/useDocumentsWorkspace.ts @@ -36,7 +36,7 @@ import { } from '../../app/workspaceUtils'; import { createDocumentEntryKey, createFolderEntryKey } from '../../app/entryKey'; import useDocumentsSearch from '../../app/useDocumentsSearch'; -import useDocumentsStore from './store/useDocumentsStore'; +import { useStatusToast, type ToastVariant } from '../../contexts/StatusToastContext'; import useAuthManager from './useAuthManager'; import useTenantManager from './useTenantManager'; import useDocuments from './useDocuments'; @@ -153,7 +153,16 @@ const useDocumentsWorkspace = ({ const tenantOptions: TenantOption[] = Array.isArray(tenantOptionsRaw) ? (tenantOptionsRaw as TenantOption[]) : []; - const { status, setStatusMessage } = useDocumentsStore(); + const { showToast } = useStatusToast(); + const setStatusMessage = useCallback( + (message?: string | null, variant: ToastVariant = 'info') => { + if (message) { + showToast(message, variant); + } + }, + [showToast], + ); + const status = null; // No longer used, kept for backward compatibility const handleApiReport = useCallback( ({ message, variant }) => setStatusMessage(message, variant), [setStatusMessage], @@ -1216,7 +1225,6 @@ const useDocumentsWorkspace = ({ appStatus, previewActive, handleLogout, - status, tenantName, tenantOptions, currentTenantId, diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 5cc80ba..9fe1b2c 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -19,6 +19,8 @@ import { useDocumentsPreferences } from './app/useDocumentsPreferences'; import { AppShellContext } from './appShellContext'; import useDocumentsWorkspace from './hooks/documents/useDocumentsWorkspace'; import UploadQueueOverlay from './app/UploadQueueOverlay'; +import { StatusToastProvider } from './contexts/StatusToastContext'; +import StatusToastOverlay from './components/StatusToastOverlay'; const AppLayout: React.FC = () => { const documentsPreferences = useDocumentsPreferences(); @@ -67,6 +69,7 @@ const AppLayout: React.FC = () => { queue={contextValue.uploadQueue || []} onClearQueue={contextValue.clearUploadQueue} /> + {managementModals} {settingsOpen ? ( @@ -99,8 +102,10 @@ if (!container) { const root = createRoot(container); root.render( - - - + + + + + , ); diff --git a/frontend/src/sidebar/Sidebar.tsx b/frontend/src/sidebar/Sidebar.tsx index 4ebc15a..161ab03 100644 --- a/frontend/src/sidebar/Sidebar.tsx +++ b/frontend/src/sidebar/Sidebar.tsx @@ -1,7 +1,6 @@ import React, { useRef } from 'react'; import { usePanelManager, usePanelResizeBindings } from '../app/PanelManagerContext'; -import type { StatusMessage } from '../hooks/documents/store/useDocumentsStore'; import type { Identifier } from '../types/identifiers'; import SidebarFolderList from './components/SidebarFolderList'; @@ -40,7 +39,6 @@ interface SidebarProps { onCreateTag?: (label: string) => Promise | void; onCreateCorrespondent?: (name: string) => Promise | void; onLogout?: () => void; - status?: StatusMessage | null; tenantName?: string | null; tenants?: TenantOption[]; activeTenantId?: Identifier | null; @@ -72,7 +70,6 @@ const Sidebar: React.FC = ({ onCreateTag, onCreateCorrespondent, onLogout, - status = null, tenantName, tenants = [], activeTenantId = null, @@ -122,12 +119,6 @@ const Sidebar: React.FC = ({ />
- {status && ( -
-
{status.message}
-
- )} - void | Promise; - status: StatusMessage | null; tenantName: string | null; tenantOptions: TenantOption[]; currentTenantId: Identifier | null; @@ -119,10 +117,8 @@ interface SidebarHookResult { onCreateTag: (label: string) => void; correspondents: CorrespondentOption[]; onCreateCorrespondent: (name: string) => void; - appStatus: string; previewActive: boolean; onLogout: UseSidebarPropsArgs['handleLogout']; - status: StatusMessage | null; tenantName: string | null; tenants: TenantOption[]; activeTenantId: Identifier | null; @@ -150,7 +146,6 @@ const useSidebarProps = ({ appStatus, previewActive, handleLogout, - status, tenantName, tenantOptions, currentTenantId, @@ -183,7 +178,6 @@ const useSidebarProps = ({ appStatus, previewActive, onLogout: handleLogout, - status, tenantName, tenants: tenantOptions, activeTenantId: currentTenantId, @@ -227,7 +221,6 @@ const useSidebarProps = ({ previewActive, draggedFolderId, selectedFolder, - status, tags, tenantName, tenantOptions, diff --git a/frontend/src/styles/status-toast.css b/frontend/src/styles/status-toast.css new file mode 100644 index 0000000..c299c43 --- /dev/null +++ b/frontend/src/styles/status-toast.css @@ -0,0 +1,86 @@ +.status-toast-container { + position: fixed; + top: 2rem; + left: 50%; + transform: translateX(-50%); + z-index: 5000000; + display: flex; + flex-direction: column; + gap: 0.75rem; + align-items: center; + pointer-events: none; +} + +.status-toast-pill { + position: relative; + background: var(--surface); + color: var(--fg); + padding: 0.35rem 0.7rem; + border-radius: 2rem; + font-size: 0.875rem; + font-weight: 500; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + pointer-events: auto; + animation: fadeIn 0.3s ease-out; + max-width: 90vw; + word-wrap: break-word; +} + +.status-toast-pill--success { + background: linear-gradient(var(--success-subtle), var(--success-subtle)), var(--surface); + color: var(--success); +} + +.status-toast-pill--info { + background: linear-gradient(var(--surface-subtle), var(--surface-subtle)), var(--surface); + color: var(--fg); +} + +.status-toast-pill--error { + background: linear-gradient(var(--danger-soft), var(--danger-soft)), var(--surface); + color: var(--danger); +} + +.status-toast-pill--exiting { + animation: fadeOut 0.3s ease-out forwards; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-1rem); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fadeOut { + from { + opacity: 1; + transform: translateY(0); + } + + to { + opacity: 0; + transform: translateY(-1rem); + } +} + +/* Mobile responsiveness */ +@media (max-width: 640px) { + .status-toast-container { + bottom: 1rem; + width: calc(100% - 2rem); + left: 1rem; + transform: none; + } + + .status-toast-pill { + padding: 0.65rem 1rem; + font-size: 0.8125rem; + max-width: 100%; + } +} \ No newline at end of file