diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 767fa918a..d7d9560a3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import "./styles/cookieconsent.css"; import "./index.css"; import { RightRailProvider } from "./contexts/RightRailContext"; import { ViewerProvider } from "./contexts/ViewerContext"; +import ToastPlayground from "./components/toast/ToastPlayground"; // Import file ID debugging helpers (development only) import "./utils/fileIdSafety"; @@ -47,6 +48,7 @@ export default function App() { + {import.meta.env.DEV && } diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index f03404eac..5ba24b513 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -11,6 +11,7 @@ import FileEditorThumbnail from './FileEditorThumbnail'; import FilePickerModal from '../shared/FilePickerModal'; import SkeletonLoader from '../shared/SkeletonLoader'; import { FileId, StirlingFile } from '../../types/fileContext'; +import { alert } from '../toast'; import { downloadBlob } from '../../utils/downloadUtils'; @@ -48,6 +49,14 @@ const FileEditor = ({ const [status, setStatus] = useState(null); const [error, setError] = useState(null); + + // Toast helpers + const showStatus = useCallback((message: string, type: 'neutral' | 'success' | 'warning' | 'error' = 'neutral') => { + alert({ alertType: type, title: message, expandable: false, durationMs: 4000 }); + }, []); + const showError = useCallback((message: string) => { + alert({ alertType: 'error', title: 'Error', body: message, expandable: true }); + }, []); const [selectionMode, setSelectionMode] = useState(toolMode); // Enable selection mode automatically in tool mode @@ -157,18 +166,18 @@ const FileEditor = ({ // Show any errors if (errors.length > 0) { - setError(errors.join('\n')); + showError(errors.join('\n')); } // Process all extracted files if (allExtractedFiles.length > 0) { // Add files to context (they will be processed automatically) await addFiles(allExtractedFiles); - setStatus(`Added ${allExtractedFiles.length} files`); + showStatus(`Added ${allExtractedFiles.length} files`, 'success'); } } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to process files'; - setError(errorMessage); + showError(errorMessage); console.error('File processing error:', err); // Reset extraction progress on error @@ -206,7 +215,7 @@ const FileEditor = ({ } else { // Check if we've hit the selection limit if (maxAllowed > 1 && currentSelectedIds.length >= maxAllowed) { - setStatus(`Maximum ${maxAllowed} files can be selected`); + showStatus(`Maximum ${maxAllowed} files can be selected`, 'warning'); return; } newSelection = [...currentSelectedIds, contextFileId]; @@ -271,7 +280,7 @@ const FileEditor = ({ // Update status const moveCount = filesToMove.length; - setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`); + showStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`); }, [activeStirlingFileStubs, reorderFiles, setStatus]); @@ -314,10 +323,10 @@ const FileEditor = ({ try { // Use FileContext to handle loading stored files // The files are already in FileContext, just need to add them to active files - setStatus(`Loaded ${selectedFiles.length} files from storage`); + showStatus(`Loaded ${selectedFiles.length} files from storage`); } catch (err) { console.error('Error loading files from storage:', err); - setError('Failed to load some files from storage'); + showError('Failed to load some files from storage'); } }, []); @@ -408,7 +417,7 @@ const FileEditor = ({ onToggleFile={toggleFile} onDeleteFile={handleDeleteFile} onViewFile={handleViewFile} - onSetStatus={setStatus} + onSetStatus={showStatus} onReorderFiles={handleReorderFiles} onDownloadFile={handleDownloadFile} toolMode={toolMode} @@ -428,31 +437,7 @@ const FileEditor = ({ onSelectFiles={handleLoadFromStorage} /> - {status && ( - - setStatus(null)} - style={{ position: 'fixed', bottom: 40, right: 80, zIndex: 10001 }} - > - {status} - - - )} - - {error && ( - - setError(null)} - style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 10001 }} - > - {error} - - - )} + ); diff --git a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx index f28713c73..a933ddf1c 100644 --- a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx @@ -1,5 +1,6 @@ import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react'; import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core'; +import { alert } from '../toast'; import { useTranslation } from 'react-i18next'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined'; @@ -263,10 +264,10 @@ const FileEditorThumbnail = ({ if (actualFile) { if (isPinned) { unpinFile(actualFile); - onSetStatus?.(`Unpinned ${file.name}`); + alert({ alertType: 'neutral', title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 }); } else { pinFile(actualFile); - onSetStatus?.(`Pinned ${file.name}`); + alert({ alertType: 'success', title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 }); } } setShowActions(false); @@ -278,7 +279,7 @@ const FileEditorThumbnail = ({ + + + + + + + + + + + + + + ); +} + +function Button({ onClick, children }: { onClick: () => void; children: React.ReactNode }) { + return ( + + ); +} + +function Divider() { + return
; +} + + diff --git a/frontend/src/components/toast/ToastRenderer.tsx b/frontend/src/components/toast/ToastRenderer.tsx new file mode 100644 index 000000000..a525abcc7 --- /dev/null +++ b/frontend/src/components/toast/ToastRenderer.tsx @@ -0,0 +1,176 @@ +import React from 'react'; +import { useToast } from './ToastContext'; +import { ToastInstance, ToastLocation } from './types'; +import { LocalIcon } from '../shared/LocalIcon'; + +const locationToClass: Record = { + 'top-left': { top: 16, left: 16, flexDirection: 'column' }, + 'top-right': { top: 16, right: 16, flexDirection: 'column' }, + 'bottom-left': { bottom: 16, left: 16, flexDirection: 'column-reverse' }, + 'bottom-right': { bottom: 16, right: 16, flexDirection: 'column-reverse' }, +}; + +function getColors(t: ToastInstance) { + switch (t.alertType) { + case 'success': + return { bg: 'var(--color-green-100)', border: 'var(--color-green-400)', text: 'var(--text-primary)', bar: 'var(--color-green-500)' }; + case 'error': + return { bg: 'var(--color-red-100)', border: 'var(--color-red-400)', text: 'var(--text-primary)', bar: 'var(--color-red-500)' }; + case 'warning': + return { bg: 'var(--color-yellow-100)', border: 'var(--color-yellow-400)', text: 'var(--text-primary)', bar: 'var(--color-yellow-500)' }; + case 'neutral': + default: + return { bg: 'var(--bg-surface)', border: 'var(--border-default)', text: 'var(--text-primary)', bar: 'var(--color-gray-500)' }; + } +} + +function getDefaultIconName(t: ToastInstance): string { + switch (t.alertType) { + case 'success': + return 'check-circle-rounded'; + case 'error': + return 'close-rounded'; + case 'warning': + return 'warning-rounded'; + case 'neutral': + default: + return 'info-rounded'; + } +} + +export default function ToastRenderer() { + const { toasts, dismiss } = useToast(); + + const grouped = toasts.reduce>((acc, t) => { + const key = t.location; + if (!acc[key]) acc[key] = [] as ToastInstance[]; + acc[key].push(t); + return acc; + }, { 'top-left': [], 'top-right': [], 'bottom-left': [], 'bottom-right': [] }); + + return ( + <> + {(Object.keys(grouped) as ToastLocation[]).map((loc) => ( +
+ {grouped[loc].map(t => { + const colors = getColors(t); + return ( +
+ {/* Top row: Icon + Title + Controls */} +
+ {/* Icon */} +
+ {t.icon ?? ( + + )} +
+ + {/* Title */} +
{t.title}
+ + {/* Controls */} +
+ {t.expandable && ( + + )} + +
+
+ {(t.isExpanded || !t.expandable) && ( +
+ {t.body} + {t.buttonText && t.buttonCallback && ( + + )} + {typeof t.progress === 'number' && ( +
+
+
+ )} +
+ )} +
+ ); + })} +
+ ))} + + ); +} + + diff --git a/frontend/src/components/toast/index.ts b/frontend/src/components/toast/index.ts new file mode 100644 index 000000000..d0b1045f2 --- /dev/null +++ b/frontend/src/components/toast/index.ts @@ -0,0 +1,61 @@ +import { ToastOptions } from './types'; +import { useToast, ToastProvider } from './ToastContext'; +import ToastRenderer from './ToastRenderer'; + +export { useToast, ToastProvider, ToastRenderer }; + +// Global imperative API via module singleton +let _api: ReturnType | null = null; + +function createImperativeApi() { + const subscribers: Array<(fn: any) => void> = []; + let api: any = null; + return { + provide(instance: any) { + api = instance; + subscribers.splice(0).forEach(cb => cb(api)); + }, + get(): any | null { return api; }, + onReady(cb: (api: any) => void) { + if (api) cb(api); else subscribers.push(cb); + } + }; +} + +if (!_api) _api = createImperativeApi(); + +// Hook helper to wire context API back to singleton +export function ToastPortalBinder() { + const ctx = useToast(); + // Provide API once mounted + _api!.provide(ctx); + return null; +} + +export function alert(options: ToastOptions) { + if (_api?.get()) { + return _api.get()!.show(options); + } + // Queue until provider mounts + let id = ''; + _api?.onReady((api) => { id = api.show(options); }); + return id; +} + +export function updateToast(id: string, options: Partial) { + _api?.get()?.update(id, options); +} + +export function updateToastProgress(id: string, progress: number) { + _api?.get()?.updateProgress(id, progress); +} + +export function dismissToast(id: string) { + _api?.get()?.dismiss(id); +} + +export function dismissAllToasts() { + _api?.get()?.dismissAll(); +} + + diff --git a/frontend/src/components/toast/types.ts b/frontend/src/components/toast/types.ts new file mode 100644 index 000000000..a3071e80f --- /dev/null +++ b/frontend/src/components/toast/types.ts @@ -0,0 +1,48 @@ +import { ReactNode } from 'react'; + +export type ToastLocation = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; +export type ToastAlertType = 'success' | 'error' | 'warning' | 'neutral'; + +export interface ToastOptions { + alertType?: ToastAlertType; + title: string; + body?: ReactNode; + buttonText?: string; + buttonCallback?: () => void; + isPersistentPopup?: boolean; + location?: ToastLocation; + icon?: ReactNode; + /** number 0-1 as fraction or 0-100 as percent */ + progressBarPercentage?: number; + /** milliseconds to auto-close if not persistent */ + durationMs?: number; + /** optional id to control/update later */ + id?: string; + /** If true, show chevron and collapse/expand animation. Defaults to true. */ + expandable?: boolean; +} + +export interface ToastInstance extends Omit { + id: string; + alertType: ToastAlertType; + isPersistentPopup: boolean; + location: ToastLocation; + durationMs: number; + expandable: boolean; + isExpanded: boolean; + /** internal progress normalized 0..100 */ + progress?: number; + /** if progress completed, briefly show check icon */ + justCompleted: boolean; + createdAt: number; +} + +export interface ToastApi { + show: (options: ToastOptions) => string; + update: (id: string, options: Partial) => void; + updateProgress: (id: string, progress: number) => void; + dismiss: (id: string) => void; + dismissAll: () => void; +} + + diff --git a/frontend/src/hooks/tools/shared/useToolApiCalls.ts b/frontend/src/hooks/tools/shared/useToolApiCalls.ts index ae282ebaf..e71771573 100644 --- a/frontend/src/hooks/tools/shared/useToolApiCalls.ts +++ b/frontend/src/hooks/tools/shared/useToolApiCalls.ts @@ -1,5 +1,5 @@ import { useCallback, useRef } from 'react'; -import axios, { CancelTokenSource } from 'axios'; +import axios, { CancelTokenSource } from '../../../services/http'; import { processResponse, ResponseHandler } from '../../../utils/toolResponseProcessor'; import type { ProcessingProgress } from './useToolState'; diff --git a/frontend/src/hooks/tools/shared/useToolOperation.ts b/frontend/src/hooks/tools/shared/useToolOperation.ts index 7735dd1a4..896081fbc 100644 --- a/frontend/src/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/hooks/tools/shared/useToolOperation.ts @@ -1,5 +1,5 @@ import { useCallback, useRef, useEffect } from 'react'; -import axios from 'axios'; +import axios from '../../../services/http'; import { useTranslation } from 'react-i18next'; import { useFileContext } from '../../../contexts/FileContext'; import { useToolState, type ProcessingProgress } from './useToolState'; diff --git a/frontend/src/services/http.ts b/frontend/src/services/http.ts new file mode 100644 index 000000000..fd1812932 --- /dev/null +++ b/frontend/src/services/http.ts @@ -0,0 +1,69 @@ +import axios from 'axios'; +import { alert } from '../components/toast'; + +function extractAxiosErrorMessage(error: any): string { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + const statusText = error.response?.statusText || 'Request Error'; + const body = ((): string => { + const data = error.response?.data as any; + if (!data) return ''; + if (typeof data === 'string') return data; + if (data?.message) return data.message as string; + try { return JSON.stringify(data); } catch { return ''; } + })(); + return `${status ?? ''} ${statusText}${body ? `: ${body}` : ''}`.trim(); + } + try { + return (error?.message || String(error)) as string; + } catch { + return 'Unknown network error'; + } +} + +// Install Axios response error interceptor +axios.interceptors.response.use( + (response) => response, + (error) => { + const msg = extractAxiosErrorMessage(error); + alert({ + alertType: 'error', + title: 'Request failed', + body: msg, + expandable: true, + isPersistentPopup: false, + }); + return Promise.reject(error); + } +); + +export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const res = await fetch(input, { credentials: init?.credentials ?? 'include', ...init }); + if (!res.ok) { + let detail = ''; + try { + const ct = res.headers.get('content-type') || ''; + if (ct.includes('application/json')) { + const data = await res.json(); + detail = typeof data === 'string' ? data : (data?.message || JSON.stringify(data)); + } else { + detail = await res.text(); + } + } catch { + // ignore parse errors + } + alert({ + alertType: 'error', + title: `Request failed (${res.status})`, + body: detail || res.statusText, + expandable: true, + isPersistentPopup: false, + }); + } + return res; +} + +export default axios; +export type { CancelTokenSource } from 'axios'; + + diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index f5e5e91bf..4d416cf09 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -30,6 +30,30 @@ --color-primary-800: #1e40af; --color-primary-900: #1e3a8a; + /* Success (green) */ + --color-green-50: #f0fdf4; + --color-green-100: #dcfce7; + --color-green-200: #bbf7d0; + --color-green-300: #86efac; + --color-green-400: #4ade80; + --color-green-500: #22c55e; + --color-green-600: #16a34a; + --color-green-700: #15803d; + --color-green-800: #166534; + --color-green-900: #14532d; + + /* Warning (yellow) */ + --color-yellow-50: #fefce8; + --color-yellow-100: #fef9c3; + --color-yellow-200: #fef08a; + --color-yellow-300: #fde047; + --color-yellow-400: #facc15; + --color-yellow-500: #eab308; + --color-yellow-600: #ca8a04; + --color-yellow-700: #a16207; + --color-yellow-800: #854d0e; + --color-yellow-900: #713f12; + --color-red-50: #fef2f2; --color-red-100: #fee2e2; --color-red-200: #fecaca; @@ -241,6 +265,30 @@ --color-gray-800: #e5e7eb; --color-gray-900: #f3f4f6; + /* Success (green) - dark */ + --color-green-50: #052e16; + --color-green-100: #064e3b; + --color-green-200: #065f46; + --color-green-300: #047857; + --color-green-400: #059669; + --color-green-500: #22c55e; + --color-green-600: #16a34a; + --color-green-700: #4ade80; + --color-green-800: #86efac; + --color-green-900: #bbf7d0; + + /* Warning (yellow) - dark */ + --color-yellow-50: #451a03; + --color-yellow-100: #713f12; + --color-yellow-200: #854d0e; + --color-yellow-300: #a16207; + --color-yellow-400: #ca8a04; + --color-yellow-500: #eab308; + --color-yellow-600: #facc15; + --color-yellow-700: #fde047; + --color-yellow-800: #fef08a; + --color-yellow-900: #fef9c3; + /* Dark theme semantic colors */ --bg-surface: #2A2F36; --bg-raised: #1F2329; diff --git a/frontend/src/theme/mantineTheme.ts b/frontend/src/theme/mantineTheme.ts index b91bbe83a..0e43db9a4 100644 --- a/frontend/src/theme/mantineTheme.ts +++ b/frontend/src/theme/mantineTheme.ts @@ -14,6 +14,32 @@ const primary: MantineColorsTuple = [ 'var(--color-primary-900)', ]; +const green: MantineColorsTuple = [ + 'var(--color-green-50)', + 'var(--color-green-100)', + 'var(--color-green-200)', + 'var(--color-green-300)', + 'var(--color-green-400)', + 'var(--color-green-500)', + 'var(--color-green-600)', + 'var(--color-green-700)', + 'var(--color-green-800)', + 'var(--color-green-900)', +]; + +const yellow: MantineColorsTuple = [ + 'var(--color-yellow-50)', + 'var(--color-yellow-100)', + 'var(--color-yellow-200)', + 'var(--color-yellow-300)', + 'var(--color-yellow-400)', + 'var(--color-yellow-500)', + 'var(--color-yellow-600)', + 'var(--color-yellow-700)', + 'var(--color-yellow-800)', + 'var(--color-yellow-900)', +]; + const gray: MantineColorsTuple = [ 'var(--color-gray-50)', 'var(--color-gray-100)', @@ -34,6 +60,8 @@ export const mantineTheme = createTheme({ // Color palette colors: { primary, + green, + yellow, gray, }, diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 42d04d16d..405c2bbc1 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -22,6 +22,42 @@ module.exports = { 800: 'rgb(var(--gray-800) / )', 900: 'rgb(var(--gray-900) / )', }, + green: { + 50: 'var(--color-green-50)', + 100: 'var(--color-green-100)', + 200: 'var(--color-green-200)', + 300: 'var(--color-green-300)', + 400: 'var(--color-green-400)', + 500: 'var(--color-green-500)', + 600: 'var(--color-green-600)', + 700: 'var(--color-green-700)', + 800: 'var(--color-green-800)', + 900: 'var(--color-green-900)', + }, + yellow: { + 50: 'var(--color-yellow-50)', + 100: 'var(--color-yellow-100)', + 200: 'var(--color-yellow-200)', + 300: 'var(--color-yellow-300)', + 400: 'var(--color-yellow-400)', + 500: 'var(--color-yellow-500)', + 600: 'var(--color-yellow-600)', + 700: 'var(--color-yellow-700)', + 800: 'var(--color-yellow-800)', + 900: 'var(--color-yellow-900)', + }, + red: { + 50: 'var(--color-red-50)', + 100: 'var(--color-red-100)', + 200: 'var(--color-red-200)', + 300: 'var(--color-red-300)', + 400: 'var(--color-red-400)', + 500: 'var(--color-red-500)', + 600: 'var(--color-red-600)', + 700: 'var(--color-red-700)', + 800: 'var(--color-red-800)', + 900: 'var(--color-red-900)', + }, // Custom semantic colors for app-specific usage surface: 'rgb(var(--surface) / )', background: 'rgb(var(--background) / )',