mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-09-26 17:52:59 +02:00
add toasts and error handling
This commit is contained in:
parent
6441dc1d6f
commit
a5777a0059
@ -14,6 +14,7 @@ import "./styles/cookieconsent.css";
|
|||||||
import "./index.css";
|
import "./index.css";
|
||||||
import { RightRailProvider } from "./contexts/RightRailContext";
|
import { RightRailProvider } from "./contexts/RightRailContext";
|
||||||
import { ViewerProvider } from "./contexts/ViewerContext";
|
import { ViewerProvider } from "./contexts/ViewerContext";
|
||||||
|
import ToastPlayground from "./components/toast/ToastPlayground";
|
||||||
|
|
||||||
// Import file ID debugging helpers (development only)
|
// Import file ID debugging helpers (development only)
|
||||||
import "./utils/fileIdSafety";
|
import "./utils/fileIdSafety";
|
||||||
@ -47,6 +48,7 @@ export default function App() {
|
|||||||
<ViewerProvider>
|
<ViewerProvider>
|
||||||
<RightRailProvider>
|
<RightRailProvider>
|
||||||
<HomePage />
|
<HomePage />
|
||||||
|
{import.meta.env.DEV && <ToastPlayground />}
|
||||||
</RightRailProvider>
|
</RightRailProvider>
|
||||||
</ViewerProvider>
|
</ViewerProvider>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
@ -11,6 +11,7 @@ import FileEditorThumbnail from './FileEditorThumbnail';
|
|||||||
import FilePickerModal from '../shared/FilePickerModal';
|
import FilePickerModal from '../shared/FilePickerModal';
|
||||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||||
import { FileId, StirlingFile } from '../../types/fileContext';
|
import { FileId, StirlingFile } from '../../types/fileContext';
|
||||||
|
import { alert } from '../toast';
|
||||||
import { downloadBlob } from '../../utils/downloadUtils';
|
import { downloadBlob } from '../../utils/downloadUtils';
|
||||||
|
|
||||||
|
|
||||||
@ -48,6 +49,14 @@ const FileEditor = ({
|
|||||||
|
|
||||||
const [status, setStatus] = useState<string | null>(null);
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(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);
|
const [selectionMode, setSelectionMode] = useState(toolMode);
|
||||||
|
|
||||||
// Enable selection mode automatically in tool mode
|
// Enable selection mode automatically in tool mode
|
||||||
@ -157,18 +166,18 @@ const FileEditor = ({
|
|||||||
|
|
||||||
// Show any errors
|
// Show any errors
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
setError(errors.join('\n'));
|
showError(errors.join('\n'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process all extracted files
|
// Process all extracted files
|
||||||
if (allExtractedFiles.length > 0) {
|
if (allExtractedFiles.length > 0) {
|
||||||
// Add files to context (they will be processed automatically)
|
// Add files to context (they will be processed automatically)
|
||||||
await addFiles(allExtractedFiles);
|
await addFiles(allExtractedFiles);
|
||||||
setStatus(`Added ${allExtractedFiles.length} files`);
|
showStatus(`Added ${allExtractedFiles.length} files`, 'success');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
|
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
|
||||||
setError(errorMessage);
|
showError(errorMessage);
|
||||||
console.error('File processing error:', err);
|
console.error('File processing error:', err);
|
||||||
|
|
||||||
// Reset extraction progress on error
|
// Reset extraction progress on error
|
||||||
@ -206,7 +215,7 @@ const FileEditor = ({
|
|||||||
} else {
|
} else {
|
||||||
// Check if we've hit the selection limit
|
// Check if we've hit the selection limit
|
||||||
if (maxAllowed > 1 && currentSelectedIds.length >= maxAllowed) {
|
if (maxAllowed > 1 && currentSelectedIds.length >= maxAllowed) {
|
||||||
setStatus(`Maximum ${maxAllowed} files can be selected`);
|
showStatus(`Maximum ${maxAllowed} files can be selected`, 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
newSelection = [...currentSelectedIds, contextFileId];
|
newSelection = [...currentSelectedIds, contextFileId];
|
||||||
@ -271,7 +280,7 @@ const FileEditor = ({
|
|||||||
|
|
||||||
// Update status
|
// Update status
|
||||||
const moveCount = filesToMove.length;
|
const moveCount = filesToMove.length;
|
||||||
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
|
showStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
|
||||||
}, [activeStirlingFileStubs, reorderFiles, setStatus]);
|
}, [activeStirlingFileStubs, reorderFiles, setStatus]);
|
||||||
|
|
||||||
|
|
||||||
@ -314,10 +323,10 @@ const FileEditor = ({
|
|||||||
try {
|
try {
|
||||||
// Use FileContext to handle loading stored files
|
// Use FileContext to handle loading stored files
|
||||||
// The files are already in FileContext, just need to add them to active 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) {
|
} catch (err) {
|
||||||
console.error('Error loading files from storage:', 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}
|
onToggleFile={toggleFile}
|
||||||
onDeleteFile={handleDeleteFile}
|
onDeleteFile={handleDeleteFile}
|
||||||
onViewFile={handleViewFile}
|
onViewFile={handleViewFile}
|
||||||
onSetStatus={setStatus}
|
onSetStatus={showStatus}
|
||||||
onReorderFiles={handleReorderFiles}
|
onReorderFiles={handleReorderFiles}
|
||||||
onDownloadFile={handleDownloadFile}
|
onDownloadFile={handleDownloadFile}
|
||||||
toolMode={toolMode}
|
toolMode={toolMode}
|
||||||
@ -428,31 +437,7 @@ const FileEditor = ({
|
|||||||
onSelectFiles={handleLoadFromStorage}
|
onSelectFiles={handleLoadFromStorage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{status && (
|
|
||||||
<Portal>
|
|
||||||
<Notification
|
|
||||||
color="blue"
|
|
||||||
mt="md"
|
|
||||||
onClose={() => setStatus(null)}
|
|
||||||
style={{ position: 'fixed', bottom: 40, right: 80, zIndex: 10001 }}
|
|
||||||
>
|
|
||||||
{status}
|
|
||||||
</Notification>
|
|
||||||
</Portal>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Portal>
|
|
||||||
<Notification
|
|
||||||
color="red"
|
|
||||||
mt="md"
|
|
||||||
onClose={() => setError(null)}
|
|
||||||
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 10001 }}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</Notification>
|
|
||||||
</Portal>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Dropzone>
|
</Dropzone>
|
||||||
);
|
);
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||||
import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core';
|
import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core';
|
||||||
|
import { alert } from '../toast';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||||
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
||||||
@ -263,10 +264,10 @@ const FileEditorThumbnail = ({
|
|||||||
if (actualFile) {
|
if (actualFile) {
|
||||||
if (isPinned) {
|
if (isPinned) {
|
||||||
unpinFile(actualFile);
|
unpinFile(actualFile);
|
||||||
onSetStatus?.(`Unpinned ${file.name}`);
|
alert({ alertType: 'neutral', title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
|
||||||
} else {
|
} else {
|
||||||
pinFile(actualFile);
|
pinFile(actualFile);
|
||||||
onSetStatus?.(`Pinned ${file.name}`);
|
alert({ alertType: 'success', title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setShowActions(false);
|
setShowActions(false);
|
||||||
@ -278,7 +279,7 @@ const FileEditorThumbnail = ({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
className={styles.actionRow}
|
className={styles.actionRow}
|
||||||
onClick={() => { onDownloadFile(file.id); setShowActions(false); }}
|
onClick={() => { onDownloadFile(file.id); alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 }); setShowActions(false); }}
|
||||||
>
|
>
|
||||||
<DownloadOutlinedIcon fontSize="small" />
|
<DownloadOutlinedIcon fontSize="small" />
|
||||||
<span>{t('download', 'Download')}</span>
|
<span>{t('download', 'Download')}</span>
|
||||||
@ -290,7 +291,7 @@ const FileEditorThumbnail = ({
|
|||||||
className={`${styles.actionRow} ${styles.actionDanger}`}
|
className={`${styles.actionRow} ${styles.actionDanger}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onDeleteFile(file.id);
|
onDeleteFile(file.id);
|
||||||
onSetStatus(`Deleted ${file.name}`);
|
alert({ alertType: 'neutral', title: `Deleted ${file.name}`, expandable: false, durationMs: 3500 });
|
||||||
setShowActions(false);
|
setShowActions(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -3,6 +3,9 @@ import { MantineProvider } from '@mantine/core';
|
|||||||
import { useRainbowTheme } from '../../hooks/useRainbowTheme';
|
import { useRainbowTheme } from '../../hooks/useRainbowTheme';
|
||||||
import { mantineTheme } from '../../theme/mantineTheme';
|
import { mantineTheme } from '../../theme/mantineTheme';
|
||||||
import rainbowStyles from '../../styles/rainbow.module.css';
|
import rainbowStyles from '../../styles/rainbow.module.css';
|
||||||
|
import { ToastProvider } from '../toast';
|
||||||
|
import ToastRenderer from '../toast/ToastRenderer';
|
||||||
|
import { ToastPortalBinder } from '../toast';
|
||||||
|
|
||||||
interface RainbowThemeContextType {
|
interface RainbowThemeContextType {
|
||||||
themeMode: 'light' | 'dark' | 'rainbow';
|
themeMode: 'light' | 'dark' | 'rainbow';
|
||||||
@ -44,7 +47,11 @@ export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) {
|
|||||||
className={rainbowTheme.isRainbowMode ? rainbowStyles.rainbowMode : ''}
|
className={rainbowTheme.isRainbowMode ? rainbowStyles.rainbowMode : ''}
|
||||||
style={{ minHeight: '100vh' }}
|
style={{ minHeight: '100vh' }}
|
||||||
>
|
>
|
||||||
{children}
|
<ToastProvider>
|
||||||
|
<ToastPortalBinder />
|
||||||
|
{children}
|
||||||
|
<ToastRenderer />
|
||||||
|
</ToastProvider>
|
||||||
</div>
|
</div>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
</RainbowThemeContext.Provider>
|
</RainbowThemeContext.Provider>
|
||||||
|
137
frontend/src/components/toast/ToastContext.tsx
Normal file
137
frontend/src/components/toast/ToastContext.tsx
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import React, { createContext, useCallback, useContext, useMemo, useRef, useState, useEffect } from 'react';
|
||||||
|
import { ToastApi, ToastInstance, ToastOptions } from './types';
|
||||||
|
|
||||||
|
function normalizeProgress(value: number | undefined): number | undefined {
|
||||||
|
if (typeof value !== 'number' || Number.isNaN(value)) return undefined;
|
||||||
|
// Accept 0..1 as fraction or 0..100 as percent
|
||||||
|
if (value <= 1) return Math.max(0, Math.min(1, value)) * 100;
|
||||||
|
return Math.max(0, Math.min(100, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateId() {
|
||||||
|
return `toast_${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DefaultOpts = Required<Pick<ToastOptions, 'alertType' | 'title' | 'isPersistentPopup' | 'location' | 'durationMs'>> &
|
||||||
|
Partial<Omit<ToastOptions, 'id' | 'alertType' | 'title' | 'isPersistentPopup' | 'location' | 'durationMs'>>;
|
||||||
|
|
||||||
|
const defaultOptions: DefaultOpts = {
|
||||||
|
alertType: 'neutral',
|
||||||
|
title: '',
|
||||||
|
isPersistentPopup: false,
|
||||||
|
location: 'bottom-right',
|
||||||
|
durationMs: 6000,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ToastContextShape extends ToastApi {
|
||||||
|
toasts: ToastInstance[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextShape | null>(null);
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const ctx = useContext(ToastContext);
|
||||||
|
if (!ctx) throw new Error('useToast must be used within ToastProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<ToastInstance[]>([]);
|
||||||
|
const timers = useRef<Record<string, number>>({});
|
||||||
|
|
||||||
|
const scheduleAutoDismiss = useCallback((toast: ToastInstance) => {
|
||||||
|
if (toast.isPersistentPopup) return;
|
||||||
|
window.clearTimeout(timers.current[toast.id]);
|
||||||
|
timers.current[toast.id] = window.setTimeout(() => {
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== toast.id));
|
||||||
|
}, toast.durationMs);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const show = useCallback<ToastApi['show']>((options) => {
|
||||||
|
const id = options.id || generateId();
|
||||||
|
const merged: ToastInstance = {
|
||||||
|
...defaultOptions,
|
||||||
|
...options,
|
||||||
|
id,
|
||||||
|
progress: normalizeProgress(options.progressBarPercentage),
|
||||||
|
justCompleted: false,
|
||||||
|
expandable: options.expandable !== false,
|
||||||
|
isExpanded: options.expandable === false ? true : false,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
} as ToastInstance;
|
||||||
|
setToasts(prev => {
|
||||||
|
const next = [...prev.filter(t => t.id !== id), merged];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
scheduleAutoDismiss(merged);
|
||||||
|
return id;
|
||||||
|
}, [scheduleAutoDismiss]);
|
||||||
|
|
||||||
|
const update = useCallback<ToastApi['update']>((id, updates) => {
|
||||||
|
setToasts(prev => prev.map(t => {
|
||||||
|
if (t.id !== id) return t;
|
||||||
|
const progress = updates.progressBarPercentage !== undefined
|
||||||
|
? normalizeProgress(updates.progressBarPercentage)
|
||||||
|
: t.progress;
|
||||||
|
|
||||||
|
const next: ToastInstance = {
|
||||||
|
...t,
|
||||||
|
...updates,
|
||||||
|
progress,
|
||||||
|
} as ToastInstance;
|
||||||
|
|
||||||
|
// Detect completion
|
||||||
|
if (typeof progress === 'number' && progress >= 100 && !t.justCompleted) {
|
||||||
|
// On completion: finalize type as success unless explicitly provided otherwise
|
||||||
|
next.justCompleted = false;
|
||||||
|
if (!updates.alertType) {
|
||||||
|
next.alertType = 'success';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateProgress = useCallback<ToastApi['updateProgress']>((id, progress) => {
|
||||||
|
update(id, { progressBarPercentage: progress });
|
||||||
|
}, [update]);
|
||||||
|
|
||||||
|
const dismiss = useCallback<ToastApi['dismiss']>((id) => {
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== id));
|
||||||
|
window.clearTimeout(timers.current[id]);
|
||||||
|
delete timers.current[id];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismissAll = useCallback<ToastApi['dismissAll']>(() => {
|
||||||
|
setToasts([]);
|
||||||
|
Object.values(timers.current).forEach(t => window.clearTimeout(t));
|
||||||
|
timers.current = {};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<ToastContextShape>(() => ({
|
||||||
|
toasts,
|
||||||
|
show,
|
||||||
|
update,
|
||||||
|
updateProgress,
|
||||||
|
dismiss,
|
||||||
|
dismissAll,
|
||||||
|
}), [toasts, show, update, updateProgress, dismiss, dismissAll]);
|
||||||
|
|
||||||
|
// Handle expand/collapse toggles from renderer without widening API
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent).detail as { id: string } | undefined;
|
||||||
|
if (!detail?.id) return;
|
||||||
|
setToasts(prev => prev.map(t => t.id === detail.id ? { ...t, isExpanded: !t.isExpanded } : t));
|
||||||
|
};
|
||||||
|
window.addEventListener('toast:toggle', handler as EventListener);
|
||||||
|
return () => window.removeEventListener('toast:toggle', handler as EventListener);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={value}>{children}</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
134
frontend/src/components/toast/ToastPlayground.tsx
Normal file
134
frontend/src/components/toast/ToastPlayground.tsx
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { alert, updateToastProgress, updateToast, dismissToast, dismissAllToasts } from './index';
|
||||||
|
|
||||||
|
function wait(ms: number) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ToastPlayground() {
|
||||||
|
const runProgress = async () => {
|
||||||
|
const id = alert({
|
||||||
|
alertType: 'neutral',
|
||||||
|
title: 'Downloading…',
|
||||||
|
body: 'Fetching data from server',
|
||||||
|
progressBarPercentage: 0,
|
||||||
|
isPersistentPopup: true,
|
||||||
|
location: 'bottom-right',
|
||||||
|
});
|
||||||
|
for (let p = 0; p <= 100; p += 10) {
|
||||||
|
updateToastProgress(id, p);
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await wait(250);
|
||||||
|
}
|
||||||
|
updateToast(id, { title: 'Download complete', body: 'File saved', isPersistentPopup: false, alertType: 'success' });
|
||||||
|
setTimeout(() => dismissToast(id), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const withButtons = () => {
|
||||||
|
alert({
|
||||||
|
alertType: 'warning',
|
||||||
|
title: 'Replace existing file?',
|
||||||
|
body: 'A file with the same name already exists.',
|
||||||
|
buttonText: 'Replace',
|
||||||
|
buttonCallback: () => alert({ alertType: 'success', title: 'Replaced', body: 'Your file has been replaced.' }),
|
||||||
|
isPersistentPopup: true,
|
||||||
|
location: 'top-right',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const withCustomIcon = () => {
|
||||||
|
alert({
|
||||||
|
alertType: 'neutral',
|
||||||
|
title: 'Custom icon',
|
||||||
|
body: 'This toast shows a custom SVG icon.',
|
||||||
|
icon: (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<path d="M10 5v6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<circle cx="10" cy="14.5" r="1" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
isPersistentPopup: false,
|
||||||
|
location: 'top-left',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const differentLocations = () => {
|
||||||
|
(['top-left', 'top-right', 'bottom-left', 'bottom-right'] as const).forEach((loc) => {
|
||||||
|
alert({ alertType: 'neutral', title: `Toast @ ${loc}`, body: 'Location test', location: loc });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const success = () => alert({ alertType: 'success', title: 'Success', body: 'Operation completed.' });
|
||||||
|
const error = () => alert({ alertType: 'error', title: 'Error', body: 'Something went wrong.' });
|
||||||
|
const warning = () => alert({ alertType: 'warning', title: 'Warning', body: 'Please check your inputs.' });
|
||||||
|
const neutral = () => alert({ alertType: 'neutral', title: 'Information', body: 'Heads up!' });
|
||||||
|
|
||||||
|
const persistent = () => alert({ alertType: 'neutral', title: 'Persistent toast', body: 'Click × to close.', isPersistentPopup: true });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: 1150,
|
||||||
|
background: 'linear-gradient(to top, rgba(0,0,0,0.08), transparent)',
|
||||||
|
padding: '12px 8px',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
overflowX: 'auto',
|
||||||
|
padding: '8px',
|
||||||
|
borderRadius: 12,
|
||||||
|
background: 'var(--bg-surface)',
|
||||||
|
border: '1px solid var(--border-default)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button onClick={success}>Success</Button>
|
||||||
|
<Button onClick={error}>Error</Button>
|
||||||
|
<Button onClick={warning}>Warning</Button>
|
||||||
|
<Button onClick={neutral}>Neutral</Button>
|
||||||
|
<Divider />
|
||||||
|
<Button onClick={withButtons}>With button</Button>
|
||||||
|
<Button onClick={withCustomIcon}>Custom icon</Button>
|
||||||
|
<Button onClick={differentLocations}>All locations</Button>
|
||||||
|
<Button onClick={runProgress}>Progress demo</Button>
|
||||||
|
<Button onClick={persistent}>Persistent</Button>
|
||||||
|
<Divider />
|
||||||
|
<Button onClick={() => dismissAllToasts()}>Dismiss all</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Button({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: 10,
|
||||||
|
border: '1px solid var(--border-default)',
|
||||||
|
background: 'var(--bg-surface)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Divider() {
|
||||||
|
return <div style={{ width: 1, background: 'var(--border-default)' }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
176
frontend/src/components/toast/ToastRenderer.tsx
Normal file
176
frontend/src/components/toast/ToastRenderer.tsx
Normal file
@ -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<ToastLocation, React.CSSProperties> = {
|
||||||
|
'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<Record<ToastLocation, ToastInstance[]>>((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) => (
|
||||||
|
<div key={loc} style={{ position: 'fixed', zIndex: 1200, display: 'flex', gap: 12, pointerEvents: 'none', ...locationToClass[loc] }}>
|
||||||
|
{grouped[loc].map(t => {
|
||||||
|
const colors = getColors(t);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
role="status"
|
||||||
|
style={{
|
||||||
|
minWidth: 320,
|
||||||
|
maxWidth: 560,
|
||||||
|
background: t.alertType === 'neutral' ? 'var(--bg-surface)' : colors.bg,
|
||||||
|
color: colors.text,
|
||||||
|
border: `1px solid ${t.alertType === 'neutral' ? 'var(--border-default)' : colors.border}`,
|
||||||
|
boxShadow: 'var(--shadow-lg)',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Top row: Icon + Title + Controls */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
{/* Icon */}
|
||||||
|
<div style={{ width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
{t.icon ?? (
|
||||||
|
<LocalIcon icon={`material-symbols:${getDefaultIconName(t)}`} width={20} height={20} style={{ color: colors.bar }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div style={{ fontWeight: 700, flex: 1 }}>{t.title}</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
|
{t.expandable && (
|
||||||
|
<button
|
||||||
|
aria-label="Toggle details"
|
||||||
|
onClick={() => {
|
||||||
|
const evt = new CustomEvent('toast:toggle', { detail: { id: t.id } });
|
||||||
|
window.dispatchEvent(evt);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 999,
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
transform: t.isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||||
|
transition: 'transform 160ms ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="material-symbols:expand-more-rounded" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
aria-label="Dismiss"
|
||||||
|
onClick={() => dismiss(t.id)}
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 999,
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="material-symbols:close-rounded" width={20} height={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(t.isExpanded || !t.expandable) && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
opacity: 0.9,
|
||||||
|
marginTop: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.body}
|
||||||
|
{t.buttonText && t.buttonCallback && (
|
||||||
|
<button
|
||||||
|
onClick={t.buttonCallback}
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: 12,
|
||||||
|
border: `1px solid ${colors.border}`,
|
||||||
|
background: 'transparent',
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.buttonText}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{typeof t.progress === 'number' && (
|
||||||
|
<div style={{ marginTop: 12, height: 6, background: 'var(--bg-muted)', borderRadius: 999, overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${t.progress}%`, height: '100%', background: colors.bar, transition: 'width 160ms ease' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
61
frontend/src/components/toast/index.ts
Normal file
61
frontend/src/components/toast/index.ts
Normal file
@ -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<typeof createImperativeApi> | 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<ToastOptions>) {
|
||||||
|
_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();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
48
frontend/src/components/toast/types.ts
Normal file
48
frontend/src/components/toast/types.ts
Normal file
@ -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<ToastOptions, 'id' | 'progressBarPercentage'> {
|
||||||
|
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<ToastOptions>) => void;
|
||||||
|
updateProgress: (id: string, progress: number) => void;
|
||||||
|
dismiss: (id: string) => void;
|
||||||
|
dismissAll: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useRef } from 'react';
|
import { useCallback, useRef } from 'react';
|
||||||
import axios, { CancelTokenSource } from 'axios';
|
import axios, { CancelTokenSource } from '../../../services/http';
|
||||||
import { processResponse, ResponseHandler } from '../../../utils/toolResponseProcessor';
|
import { processResponse, ResponseHandler } from '../../../utils/toolResponseProcessor';
|
||||||
import type { ProcessingProgress } from './useToolState';
|
import type { ProcessingProgress } from './useToolState';
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useRef, useEffect } from 'react';
|
import { useCallback, useRef, useEffect } from 'react';
|
||||||
import axios from 'axios';
|
import axios from '../../../services/http';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useFileContext } from '../../../contexts/FileContext';
|
import { useFileContext } from '../../../contexts/FileContext';
|
||||||
import { useToolState, type ProcessingProgress } from './useToolState';
|
import { useToolState, type ProcessingProgress } from './useToolState';
|
||||||
|
69
frontend/src/services/http.ts
Normal file
69
frontend/src/services/http.ts
Normal file
@ -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<Response> {
|
||||||
|
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';
|
||||||
|
|
||||||
|
|
@ -30,6 +30,30 @@
|
|||||||
--color-primary-800: #1e40af;
|
--color-primary-800: #1e40af;
|
||||||
--color-primary-900: #1e3a8a;
|
--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-50: #fef2f2;
|
||||||
--color-red-100: #fee2e2;
|
--color-red-100: #fee2e2;
|
||||||
--color-red-200: #fecaca;
|
--color-red-200: #fecaca;
|
||||||
@ -241,6 +265,30 @@
|
|||||||
--color-gray-800: #e5e7eb;
|
--color-gray-800: #e5e7eb;
|
||||||
--color-gray-900: #f3f4f6;
|
--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 */
|
/* Dark theme semantic colors */
|
||||||
--bg-surface: #2A2F36;
|
--bg-surface: #2A2F36;
|
||||||
--bg-raised: #1F2329;
|
--bg-raised: #1F2329;
|
||||||
|
@ -14,6 +14,32 @@ const primary: MantineColorsTuple = [
|
|||||||
'var(--color-primary-900)',
|
'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 = [
|
const gray: MantineColorsTuple = [
|
||||||
'var(--color-gray-50)',
|
'var(--color-gray-50)',
|
||||||
'var(--color-gray-100)',
|
'var(--color-gray-100)',
|
||||||
@ -34,6 +60,8 @@ export const mantineTheme = createTheme({
|
|||||||
// Color palette
|
// Color palette
|
||||||
colors: {
|
colors: {
|
||||||
primary,
|
primary,
|
||||||
|
green,
|
||||||
|
yellow,
|
||||||
gray,
|
gray,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -22,6 +22,42 @@ module.exports = {
|
|||||||
800: 'rgb(var(--gray-800) / <alpha-value>)',
|
800: 'rgb(var(--gray-800) / <alpha-value>)',
|
||||||
900: 'rgb(var(--gray-900) / <alpha-value>)',
|
900: 'rgb(var(--gray-900) / <alpha-value>)',
|
||||||
},
|
},
|
||||||
|
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
|
// Custom semantic colors for app-specific usage
|
||||||
surface: 'rgb(var(--surface) / <alpha-value>)',
|
surface: 'rgb(var(--surface) / <alpha-value>)',
|
||||||
background: 'rgb(var(--background) / <alpha-value>)',
|
background: 'rgb(var(--background) / <alpha-value>)',
|
||||||
|
Loading…
Reference in New Issue
Block a user