Feature/toasts and error handling (#4496)

# Description of Changes

- Added error handling and toast notifications

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
EthanHealy01
2025-09-25 21:03:53 +01:00
committed by GitHub
parent 21b1428ab5
commit fd52dc0226
32 changed files with 1845 additions and 94 deletions
@@ -56,6 +56,20 @@
border-bottom: 1px solid var(--header-selected-bg);
}
/* Error highlight (transient) */
.headerError {
background: var(--color-red-200);
color: var(--text-primary);
border-bottom: 2px solid var(--color-red-500);
}
/* Unsupported (but not errored) header appearance */
.headerUnsupported {
background: var(--unsupported-bar-bg); /* neutral gray */
color: #FFFFFF;
border-bottom: 1px solid var(--unsupported-bar-border);
}
/* Selected border color in light mode */
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
outline-color: var(--card-selected-border);
@@ -80,6 +94,7 @@
.kebab {
justify-self: end;
color: #FFFFFF !important;
}
/* Menu dropdown */
@@ -217,6 +232,22 @@
height: 20px;
}
/* Error pill shown when a file failed processing */
.errorPill {
margin-left: 1.75rem;
background: var(--color-red-500);
color: #ffffff;
padding: 4px 8px;
border-radius: 12px;
font-size: 10px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
min-width: 56px;
height: 20px;
}
/* Animations */
@keyframes pulse {
0%, 100% {
@@ -1,6 +1,6 @@
import React, { useState, useCallback, useRef, useMemo } from 'react';
import {
Text, Center, Box, Notification, LoadingOverlay, Stack, Group, Portal
Text, Center, Box, LoadingOverlay, Stack, Group
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useFileSelection, useFileState, useFileManagement } from '../../contexts/FileContext';
@@ -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';
@@ -46,8 +47,16 @@ const FileEditor = ({
// Get file selection context
const { setSelectedFiles } = useFileSelection();
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [_status, _setStatus] = 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);
// Enable selection mode automatically in tool mode
@@ -82,7 +91,7 @@ const FileEditor = ({
// Process uploaded files using context
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
setError(null);
_setError(null);
try {
const allExtractedFiles: File[] = [];
@@ -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];
@@ -215,7 +224,7 @@ const FileEditor = ({
// Update context (this automatically updates tool selection since they use the same action)
setSelectedFiles(newSelection);
}, [setSelectedFiles, toolMode, setStatus, activeStirlingFileStubs]);
}, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs]);
// File reordering handler for drag and drop
@@ -271,8 +280,8 @@ const FileEditor = ({
// Update status
const moveCount = filesToMove.length;
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
}, [activeStirlingFileStubs, reorderFiles, setStatus]);
showStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
}, [activeStirlingFileStubs, reorderFiles, _setStatus]);
@@ -297,7 +306,7 @@ const FileEditor = ({
if (record && file) {
downloadBlob(file, file.name);
}
}, [activeStirlingFileStubs, selectors, setStatus]);
}, [activeStirlingFileStubs, selectors, _setStatus]);
const handleViewFile = useCallback((fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
@@ -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 && (
<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>
</Dropzone>
);
@@ -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';
@@ -12,6 +13,7 @@ import { StirlingFileStub } from '../../types/fileContext';
import styles from './FileEditor.module.css';
import { useFileContext } from '../../contexts/FileContext';
import { useFileState } from '../../contexts/file/fileHooks';
import { FileId } from '../../types/file';
import { formatFileSize } from '../../utils/fileUtils';
import ToolChain from '../shared/ToolChain';
@@ -27,7 +29,7 @@ interface FileEditorThumbnailProps {
onToggleFile: (fileId: FileId) => void;
onDeleteFile: (fileId: FileId) => void;
onViewFile: (fileId: FileId) => void;
onSetStatus: (status: string) => void;
_onSetStatus: (status: string) => void;
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
onDownloadFile: (fileId: FileId) => void;
toolMode?: boolean;
@@ -40,13 +42,15 @@ const FileEditorThumbnail = ({
selectedFiles,
onToggleFile,
onDeleteFile,
onSetStatus,
_onSetStatus,
onReorderFiles,
onDownloadFile,
isSupported = true,
}: FileEditorThumbnailProps) => {
const { t } = useTranslation();
const { pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions } = useFileContext();
const { state } = useFileState();
const hasError = state.ui.errorFileIds.includes(file.id);
// ---- Drag state ----
const [isDragging, setIsDragging] = useState(false);
@@ -187,9 +191,20 @@ const FileEditorThumbnail = ({
// ---- Card interactions ----
const handleCardClick = () => {
if (!isSupported) return;
// Clear error state if file has an error (click to clear error)
if (hasError) {
try { fileActions.clearFileError(file.id); } catch (_e) { void _e; }
}
onToggleFile(file.id);
};
// ---- Style helpers ----
const getHeaderClassName = () => {
if (hasError) return styles.headerError;
if (!isSupported) return styles.headerUnsupported;
return isSelected ? styles.headerSelected : styles.headerResting;
};
return (
<div
@@ -199,10 +214,7 @@ const FileEditorThumbnail = ({
data-selected={isSelected}
data-supported={isSupported}
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
style={{
opacity: isSupported ? (isDragging ? 0.9 : 1) : 0.5,
filter: isSupported ? 'none' : 'grayscale(50%)',
}}
style={{opacity: isDragging ? 0.9 : 1}}
tabIndex={0}
role="listitem"
aria-selected={isSelected}
@@ -210,13 +222,16 @@ const FileEditorThumbnail = ({
>
{/* Header bar */}
<div
className={`${styles.header} ${
isSelected ? styles.headerSelected : styles.headerResting
}`}
className={`${styles.header} ${getHeaderClassName()}`}
data-has-error={hasError}
>
{/* Logo/checkbox area */}
<div className={styles.logoMark}>
{isSupported ? (
{hasError ? (
<div className={styles.errorPill}>
<span>{t('error._value', 'Error')}</span>
</div>
) : isSupported ? (
<CheckboxIndicator
checked={isSelected}
onChange={() => onToggleFile(file.id)}
@@ -263,10 +278,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 +293,7 @@ const FileEditorThumbnail = ({
<button
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" />
<span>{t('download', 'Download')}</span>
@@ -290,7 +305,7 @@ const FileEditorThumbnail = ({
className={`${styles.actionRow} ${styles.actionDanger}`}
onClick={() => {
onDeleteFile(file.id);
onSetStatus(`Deleted ${file.name}`);
alert({ alertType: 'neutral', title: `Deleted ${file.name}`, expandable: false, durationMs: 3500 });
setShowActions(false);
}}
>
@@ -328,7 +343,10 @@ const FileEditorThumbnail = ({
</div>
{/* Preview area */}
<div className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}>
<div
className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}
style={isSupported || hasError ? undefined : { filter: 'grayscale(80%)', opacity: 0.6 }}
>
<div className={styles.previewPaper}>
{file.thumbnailUrl && (
<img
@@ -13,6 +13,7 @@ import PageEditorControls from '../pageEditor/PageEditorControls';
import Viewer from '../viewer/Viewer';
import LandingPage from '../shared/LandingPage';
import Footer from '../shared/Footer';
import DismissAllErrorsButton from '../shared/DismissAllErrorsButton';
// No props needed - component uses contexts directly
export default function Workbench() {
@@ -151,6 +152,9 @@ export default function Workbench() {
selectedToolKey={selectedToolId}
/>
{/* Dismiss All Errors Button */}
<DismissAllErrorsButton />
{/* Main content area */}
<Box
className="flex-1 min-h-0 relative z-10 workbench-scrollable "
@@ -0,0 +1,51 @@
import React from 'react';
import { Button, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useFileState } from '../../contexts/FileContext';
import { useFileActions } from '../../contexts/file/fileHooks';
import CloseIcon from '@mui/icons-material/Close';
interface DismissAllErrorsButtonProps {
className?: string;
}
const DismissAllErrorsButton: React.FC<DismissAllErrorsButtonProps> = ({ className }) => {
const { t } = useTranslation();
const { state } = useFileState();
const { actions } = useFileActions();
// Check if there are any files in error state
const hasErrors = state.ui.errorFileIds.length > 0;
// Don't render if there are no errors
if (!hasErrors) {
return null;
}
const handleDismissAllErrors = () => {
actions.clearAllFileErrors();
};
return (
<Group className={className}>
<Button
variant="light"
color="red"
size="sm"
leftSection={<CloseIcon fontSize="small" />}
onClick={handleDismissAllErrors}
style={{
position: 'absolute',
top: '1rem',
right: '1rem',
zIndex: 1000,
pointerEvents: 'auto'
}}
>
{t('error.dismissAllErrors', 'Dismiss All Errors')} ({state.ui.errorFileIds.length})
</Button>
</Group>
);
};
export default DismissAllErrorsButton;
@@ -3,6 +3,9 @@ import { MantineProvider } from '@mantine/core';
import { useRainbowTheme } from '../../hooks/useRainbowTheme';
import { mantineTheme } from '../../theme/mantineTheme';
import rainbowStyles from '../../styles/rainbow.module.css';
import { ToastProvider } from '../toast';
import ToastRenderer from '../toast/ToastRenderer';
import { ToastPortalBinder } from '../toast';
interface RainbowThemeContextType {
themeMode: 'light' | 'dark' | 'rainbow';
@@ -44,7 +47,11 @@ export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) {
className={rainbowTheme.isRainbowMode ? rainbowStyles.rainbowMode : ''}
style={{ minHeight: '100vh' }}
>
{children}
<ToastProvider>
<ToastPortalBinder />
{children}
<ToastRenderer />
</ToastProvider>
</div>
</MantineProvider>
</RainbowThemeContext.Provider>
+6 -1
View File
@@ -4,7 +4,7 @@ import LocalIcon from './LocalIcon';
import './rightRail/RightRail.css';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import { useRightRail } from '../../contexts/RightRailContext';
import { useFileState, useFileSelection, useFileManagement } from '../../contexts/FileContext';
import { useFileState, useFileSelection, useFileManagement, useFileContext } from '../../contexts/FileContext';
import { useNavigationState } from '../../contexts/NavigationContext';
import { useTranslation } from 'react-i18next';
@@ -39,6 +39,7 @@ export default function RightRail() {
// File state and selection
const { state, selectors } = useFileState();
const { actions: fileActions } = useFileContext();
const { selectedFiles, selectedFileIds, setSelectedFiles } = useFileSelection();
const { removeFiles } = useFileManagement();
@@ -70,6 +71,8 @@ export default function RightRail() {
// Select all file IDs
const allIds = state.files.ids;
setSelectedFiles(allIds);
// Clear any previous error flags when selecting all
try { fileActions.clearAllFileErrors(); } catch (_e) { void _e; }
return;
}
@@ -82,6 +85,8 @@ export default function RightRail() {
const handleDeselectAll = useCallback(() => {
if (currentView === 'fileEditor' || currentView === 'viewer') {
setSelectedFiles([]);
// Clear any previous error flags when deselecting all
try { fileActions.clearAllFileErrors(); } catch (_e) { void _e; }
return;
}
if (currentView === 'pageEditor') {
@@ -0,0 +1,309 @@
# Toast Component
A global notification system with expandable content, progress tracking, and smart error coalescing. Provides an imperative API for showing success, error, warning, and neutral notifications with customizable content and behavior.
---
## Highlights
* 🎯 **Global System**: Imperative API accessible from anywhere in the app via `alert()` function.
* 🎨 **Four Alert Types**: Success (green), Error (red), Warning (yellow), Neutral (theme-aware).
* 📱 **Expandable Content**: Collapsible toasts with chevron controls and smooth animations.
***Smart Coalescing**: Duplicate error toasts merge with count badges (e.g., "Server error 4").
* 📊 **Progress Tracking**: Built-in progress bars with completion animations.
* 🎛️ **Customizable**: Rich JSX content, buttons with callbacks, custom icons.
* 🌙 **Themeable**: Uses CSS variables; supports light/dark mode out of the box.
***Accessible**: Proper ARIA roles, keyboard navigation, and screen reader support.
* 🔄 **Auto-dismiss**: Configurable duration with persistent popup option.
* 📍 **Positioning**: Four corner positions with proper stacking.
---
## Behavior
### Default
* **Auto-dismiss**: Toasts disappear after 6 seconds unless `isPersistentPopup: true`.
* **Expandable**: Click chevron to expand/collapse body content (default: collapsed).
* **Coalescing**: Identical error toasts merge with count badges.
* **Progress**: Progress bars always visible when present, even when collapsed.
### Error Handling
* **Network Errors**: Automatically caught by Axios and fetch interceptors.
* **Friendly Fallbacks**: Shows "There was an error processing your request" for unhelpful backend responses.
* **Smart Titles**: "Server error" for 5xx, "Request error" for 4xx, "Network error" for others.
---
## Installation
The toast system is already integrated at the app root. No additional setup required.
```tsx
import { alert, updateToast, dismissToast } from '@/components/toast';
```
---
## Basic Usage
### Simple Notifications
```tsx
// Success notification
alert({
alertType: 'success',
title: 'File processed successfully',
body: 'Your document has been converted to PDF.'
});
// Error notification
alert({
alertType: 'error',
title: 'Processing failed',
body: 'Unable to process the selected files.'
});
// Warning notification
alert({
alertType: 'warning',
title: 'Low disk space',
body: 'Consider freeing up some storage space.'
});
// Neutral notification
alert({
alertType: 'neutral',
title: 'Information',
body: 'This is a neutral notification.'
});
```
### With Custom Content
```tsx
// Rich JSX content with buttons
alert({
alertType: 'success',
title: 'Download complete',
body: (
<div>
<p>File saved to Downloads folder</p>
<button onClick={() => openFolder()}>Open folder</button>
</div>
),
buttonText: 'View file',
buttonCallback: () => openFile(),
isPersistentPopup: true
});
```
### Progress Tracking
```tsx
// Show progress
const toastId = alert({
alertType: 'neutral',
title: 'Processing files...',
body: 'Converting your documents',
progressBarPercentage: 0
});
// Update progress
updateToast(toastId, { progressBarPercentage: 50 });
// Complete with success
updateToast(toastId, {
alertType: 'success',
title: 'Processing complete',
body: 'All files converted successfully',
progressBarPercentage: 100
});
```
### Custom Positioning
```tsx
alert({
alertType: 'error',
title: 'Connection lost',
body: 'Please check your internet connection.',
location: 'top-right'
});
```
---
## API
### `alert(options: ToastOptions)`
The primary function for showing toasts.
```ts
interface ToastOptions {
alertType?: 'success' | 'error' | 'warning' | 'neutral';
title: string;
body?: React.ReactNode;
buttonText?: string;
buttonCallback?: () => void;
isPersistentPopup?: boolean;
location?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
icon?: React.ReactNode;
progressBarPercentage?: number; // 0-1 as fraction or 0-100 as percent
durationMs?: number;
id?: string;
expandable?: boolean;
}
```
### `updateToast(id: string, options: Partial<ToastOptions>)`
Update an existing toast.
```tsx
const toastId = alert({ title: 'Processing...', progressBarPercentage: 0 });
updateToast(toastId, { progressBarPercentage: 75 });
```
### `dismissToast(id: string)`
Dismiss a specific toast.
```tsx
dismissToast(toastId);
```
### `dismissAllToasts()`
Dismiss all visible toasts.
```tsx
dismissAllToasts();
```
---
## Alert Types
| Type | Color | Icon | Use Case |
|------|-------|------|----------|
| `success` | Green | ✓ | Successful operations, completions |
| `error` | Red | ✗ | Failures, errors, exceptions |
| `warning` | Yellow | ⚠ | Warnings, cautions, low resources |
| `neutral` | Theme | | Information, general messages |
---
## Positioning
| Location | Description |
|----------|-------------|
| `top-left` | Top-left corner |
| `top-right` | Top-right corner |
| `bottom-left` | Bottom-left corner |
| `bottom-right` | Bottom-right corner (default) |
---
## Accessibility
* Toasts use `role="status"` for screen readers.
* Chevron and close buttons have proper `aria-label` attributes.
* Keyboard navigation supported (Escape to dismiss).
* Focus management for interactive content.
---
## Examples
### File Processing Workflow
```tsx
// Start processing
const toastId = alert({
alertType: 'neutral',
title: 'Processing files...',
body: 'Converting 5 documents',
progressBarPercentage: 0,
isPersistentPopup: true
});
// Update progress
updateToast(toastId, { progressBarPercentage: 30 });
updateToast(toastId, { progressBarPercentage: 60 });
// Complete successfully
updateToast(toastId, {
alertType: 'success',
title: 'Processing complete',
body: 'All 5 documents converted successfully',
progressBarPercentage: 100,
isPersistentPopup: false
});
```
### Error with Action
```tsx
alert({
alertType: 'error',
title: 'Upload failed',
body: 'File size exceeds the 10MB limit.',
buttonText: 'Try again',
buttonCallback: () => retryUpload(),
isPersistentPopup: true
});
```
### Non-expandable Toast
```tsx
alert({
alertType: 'success',
title: 'Settings saved',
body: 'Your preferences have been updated.',
expandable: false,
durationMs: 3000
});
```
### Custom Icon
```tsx
alert({
alertType: 'neutral',
title: 'New feature available',
body: 'Check out the latest updates.',
icon: <LocalIcon icon="star" />
});
```
---
## Integration
### Network Error Handling
The toast system automatically catches network errors from Axios and fetch requests:
```tsx
// These automatically show error toasts
axios.post('/api/convert', formData);
fetch('/api/process', { method: 'POST', body: data });
```
### Manual Error Handling
```tsx
try {
await processFiles();
alert({ alertType: 'success', title: 'Files processed' });
} catch (error) {
alert({
alertType: 'error',
title: 'Processing failed',
body: error.message
});
}
```
@@ -0,0 +1,150 @@
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 hasButton = !!(options.buttonText && options.buttonCallback);
const merged: ToastInstance = {
...defaultOptions,
...options,
id,
progress: normalizeProgress(options.progressBarPercentage),
justCompleted: false,
expandable: hasButton ? false : (options.expandable !== false),
isExpanded: hasButton ? true : (options.expandable === false ? true : (options.alertType === 'error' ? true : false)),
createdAt: Date.now(),
} as ToastInstance;
setToasts(prev => {
// Coalesce duplicates by alertType + title + body text if no explicit id was provided
if (!options.id) {
const bodyText = typeof merged.body === 'string' ? merged.body : '';
const existingIndex = prev.findIndex(t => t.alertType === merged.alertType && t.title === merged.title && (typeof t.body === 'string' ? t.body : '') === bodyText);
if (existingIndex !== -1) {
const updated = [...prev];
const existing = updated[existingIndex];
const nextCount = (existing.count ?? 1) + 1;
updated[existingIndex] = { ...existing, count: nextCount, createdAt: Date.now() };
return updated;
}
}
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>
);
}
@@ -0,0 +1,209 @@
/* Toast Container Styles */
.toast-container {
position: fixed;
z-index: 1200;
display: flex;
gap: 12px;
pointer-events: none;
}
.toast-container--top-left {
top: 16px;
left: 16px;
flex-direction: column;
}
.toast-container--top-right {
top: 16px;
right: 16px;
flex-direction: column;
}
.toast-container--bottom-left {
bottom: 16px;
left: 16px;
flex-direction: column-reverse;
}
.toast-container--bottom-right {
bottom: 16px;
right: 16px;
flex-direction: column-reverse;
}
/* Toast Item Styles */
.toast-item {
min-width: 320px;
max-width: 560px;
box-shadow: var(--shadow-lg);
border-radius: 16px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: auto;
}
/* Toast Alert Type Colors */
.toast-item--success {
background: var(--color-green-100);
color: var(--text-primary);
border: 1px solid var(--color-green-400);
}
.toast-item--error {
background: var(--color-red-100);
color: var(--text-primary);
border: 1px solid var(--color-red-400);
}
.toast-item--warning {
background: var(--color-yellow-100);
color: var(--text-primary);
border: 1px solid var(--color-yellow-400);
}
.toast-item--neutral {
background: var(--bg-surface);
color: var(--text-primary);
border: 1px solid var(--border-default);
}
/* Toast Header Row */
.toast-header {
display: flex;
align-items: center;
gap: 12px;
}
.toast-icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.toast-title-container {
font-weight: 700;
flex: 1;
display: flex;
align-items: center;
gap: 8px;
}
.toast-count-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.08);
color: inherit;
font-size: 12px;
font-weight: 700;
}
.toast-controls {
display: flex;
gap: 4px;
align-items: center;
}
.toast-button {
width: 28px;
height: 28px;
border-radius: 999px;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.toast-expand-button {
transform: rotate(0deg);
transition: transform 160ms ease;
}
.toast-expand-button--expanded {
transform: rotate(180deg);
}
/* Progress Bar */
.toast-progress-container {
margin-top: 8px;
height: 6px;
background: var(--bg-muted);
border-radius: 999px;
overflow: hidden;
}
.toast-progress-bar {
height: 100%;
transition: width 160ms ease;
}
.toast-progress-bar--success {
background: var(--color-green-500);
}
.toast-progress-bar--error {
background: var(--color-red-500);
}
.toast-progress-bar--warning {
background: var(--color-yellow-500);
}
.toast-progress-bar--neutral {
background: var(--color-gray-500);
}
/* Toast Body */
.toast-body {
font-size: 14px;
opacity: 0.9;
margin-top: 8px;
}
/* Toast Action Button */
.toast-action-container {
margin-top: 12px;
display: flex;
justify-content: flex-start;
}
.toast-action-button {
padding: 8px 12px;
border-radius: 12px;
border: 1px solid;
background: transparent;
font-weight: 600;
cursor: pointer;
margin-left: auto;
}
.toast-action-button--success {
color: var(--text-primary);
border-color: var(--color-green-400);
}
.toast-action-button--error {
color: var(--text-primary);
border-color: var(--color-red-400);
}
.toast-action-button--warning {
color: var(--text-primary);
border-color: var(--color-yellow-400);
}
.toast-action-button--neutral {
color: var(--text-primary);
border-color: var(--border-default);
}
@@ -0,0 +1,138 @@
import React from 'react';
import { useToast } from './ToastContext';
import { ToastInstance, ToastLocation } from './types';
import { LocalIcon } from '../shared/LocalIcon';
import './ToastRenderer.css';
const locationToClass: Record<ToastLocation, string> = {
'top-left': 'toast-container--top-left',
'top-right': 'toast-container--top-right',
'bottom-left': 'toast-container--bottom-left',
'bottom-right': 'toast-container--bottom-right',
};
function getToastItemClass(t: ToastInstance): string {
return `toast-item toast-item--${t.alertType}`;
}
function getProgressBarClass(t: ToastInstance): string {
return `toast-progress-bar toast-progress-bar--${t.alertType}`;
}
function getActionButtonClass(t: ToastInstance): string {
return `toast-action-button toast-action-button--${t.alertType}`;
}
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} className={`toast-container ${locationToClass[loc]}`}>
{grouped[loc].map(t => {
return (
<div
key={t.id}
role="status"
className={getToastItemClass(t)}
>
{/* Top row: Icon + Title + Controls */}
<div className="toast-header">
{/* Icon */}
<div className="toast-icon">
{t.icon ?? (
<LocalIcon icon={`material-symbols:${getDefaultIconName(t)}`} width={20} height={20} />
)}
</div>
{/* Title + count badge */}
<div className="toast-title-container">
<span>{t.title}</span>
{typeof t.count === 'number' && t.count > 1 && (
<span className="toast-count-badge">{t.count}</span>
)}
</div>
{/* Controls */}
<div className="toast-controls">
{t.expandable && (
<button
aria-label="Toggle details"
onClick={() => {
const evt = new CustomEvent('toast:toggle', { detail: { id: t.id } });
window.dispatchEvent(evt);
}}
className={`toast-button toast-expand-button ${t.isExpanded ? 'toast-expand-button--expanded' : ''}`}
>
<LocalIcon icon="material-symbols:expand-more-rounded" />
</button>
)}
<button
aria-label="Dismiss"
onClick={() => dismiss(t.id)}
className="toast-button"
>
<LocalIcon icon="material-symbols:close-rounded" width={20} height={20} />
</button>
</div>
</div>
{/* Progress bar - always show when present */}
{typeof t.progress === 'number' && (
<div className="toast-progress-container">
<div
className={getProgressBarClass(t)}
style={{ width: `${t.progress}%` }}
/>
</div>
)}
{/* Body content - only show when expanded */}
{(t.isExpanded || !t.expandable) && (
<div className="toast-body">
{t.body}
</div>
)}
{/* Button - always show when present, positioned below body */}
{t.buttonText && t.buttonCallback && (
<div className="toast-action-container">
<button
onClick={t.buttonCallback}
className={getActionButtonClass(t)}
>
{t.buttonText}
</button>
</div>
)}
</div>
);
})}
</div>
))}
</>
);
}
+61
View 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();
}
+50
View File
@@ -0,0 +1,50 @@
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;
/** Number of coalesced duplicates */
count?: number;
/** 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;
}