diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 4766f14..b0de9f1 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -11,19 +11,29 @@ import type { ScenarioStep, Snippet, } from './types'; +import { emitApiErrorToast } from '../lib/toast-events'; // In dev, Vite proxies /environments /sessions /scenarios to localhost:3000. // In production (or when VITE_API_URL is set) we hit the configured origin directly. const BASE_URL: string = (import.meta.env.VITE_API_URL as string | undefined) ?? ''; async function request(path: string, init?: RequestInit): Promise { - const res = await fetch(`${BASE_URL}${path}`, { - headers: { 'Content-Type': 'application/json', ...init?.headers }, - ...init, - }); + let res: Response; + try { + res = await fetch(`${BASE_URL}${path}`, { + headers: { 'Content-Type': 'application/json', ...init?.headers }, + ...init, + }); + } catch (err) { + const message = (err as Error).message || 'Network request failed'; + emitApiErrorToast(message); + throw new Error(message); + } if (!res.ok) { const text = await res.text().catch(() => res.statusText); - throw new Error(`${res.status} ${text}`); + const message = `${res.status} ${text}`; + emitApiErrorToast(message); + throw new Error(message); } const contentLength = res.headers.get('content-length'); if (res.status === 204 || contentLength === '0') return undefined as T; diff --git a/client/src/lib/toast-events.ts b/client/src/lib/toast-events.ts new file mode 100644 index 0000000..b8ef018 --- /dev/null +++ b/client/src/lib/toast-events.ts @@ -0,0 +1,29 @@ +export const API_ERROR_TOAST_EVENT = 'liqa:api-error-toast'; + +export interface ApiErrorToastDetail { + message: string; +} + +export function emitApiErrorToast(message: string): void { + if (typeof window === 'undefined') return; + window.dispatchEvent( + new CustomEvent(API_ERROR_TOAST_EVENT, { + detail: { message }, + }), + ); +} + +export function subscribeApiErrorToasts( + handler: (message: string) => void, +): () => void { + if (typeof window === 'undefined') return () => {}; + const listener = (event: Event) => { + const custom = event as CustomEvent; + const message = custom.detail?.message; + if (message) handler(message); + }; + window.addEventListener(API_ERROR_TOAST_EVENT, listener as EventListener); + return () => { + window.removeEventListener(API_ERROR_TOAST_EVENT, listener as EventListener); + }; +} diff --git a/client/src/pages/credential/CreateCredentialPage.tsx b/client/src/pages/credential/CreateCredentialPage.tsx index bde2582..abcf71a 100644 --- a/client/src/pages/credential/CreateCredentialPage.tsx +++ b/client/src/pages/credential/CreateCredentialPage.tsx @@ -60,8 +60,6 @@ export function CreateCredentialPage() { /> - {error &&

{error}

} -
diff --git a/client/src/pages/credential/CredentialDetailPage.tsx b/client/src/pages/credential/CredentialDetailPage.tsx index e0711d0..431c85c 100644 --- a/client/src/pages/credential/CredentialDetailPage.tsx +++ b/client/src/pages/credential/CredentialDetailPage.tsx @@ -11,7 +11,6 @@ import { Button, Card, DescriptionList, - Notification, Timestamp, UuidBadge, } from '../../ui'; @@ -83,15 +82,6 @@ export function CredentialDetailPage() { )}
- {error && ( - - {error.startsWith('404') ? t('errors.not_found_credential', { id }) : error} - - )} - {loading &&

{t('credentials.loading')}

} {credential && ( diff --git a/client/src/pages/credential/CredentialsPage.tsx b/client/src/pages/credential/CredentialsPage.tsx index e236f58..7889a4e 100644 --- a/client/src/pages/credential/CredentialsPage.tsx +++ b/client/src/pages/credential/CredentialsPage.tsx @@ -154,7 +154,6 @@ export function CredentialsPage() { - {error &&

{error}

} {loading ? (

{t('credentials.loading')}

) : ( diff --git a/client/src/pages/credential/EditCredentialPage.tsx b/client/src/pages/credential/EditCredentialPage.tsx index 3b2b8b0..5c550b5 100644 --- a/client/src/pages/credential/EditCredentialPage.tsx +++ b/client/src/pages/credential/EditCredentialPage.tsx @@ -84,7 +84,6 @@ export function EditCredentialPage() { /> - {error &&

{error}

} {loading &&

{t('credentials.loading')}

} {!loading && credential && ( diff --git a/client/src/pages/environment/CreateEnvironmentPage.tsx b/client/src/pages/environment/CreateEnvironmentPage.tsx index 29848e5..144f304 100644 --- a/client/src/pages/environment/CreateEnvironmentPage.tsx +++ b/client/src/pages/environment/CreateEnvironmentPage.tsx @@ -67,8 +67,6 @@ export function CreateEnvironmentPage() { /> - {error &&

{error}

} -
diff --git a/client/src/pages/environment/EditEnvironmentPage.tsx b/client/src/pages/environment/EditEnvironmentPage.tsx index b6b9821..b602905 100644 --- a/client/src/pages/environment/EditEnvironmentPage.tsx +++ b/client/src/pages/environment/EditEnvironmentPage.tsx @@ -91,7 +91,6 @@ export function EditEnvironmentPage() { />
- {error &&

{error}

} {loading &&

{t('environments.loading')}

} {!loading && env && ( diff --git a/client/src/pages/environment/EnvironmentDetailPage.tsx b/client/src/pages/environment/EnvironmentDetailPage.tsx index 0907a44..0a2d1ae 100644 --- a/client/src/pages/environment/EnvironmentDetailPage.tsx +++ b/client/src/pages/environment/EnvironmentDetailPage.tsx @@ -5,7 +5,7 @@ import { Upload, Pencil, Trash2, Globe } from 'lucide-react'; import { stringify as yamlStringify } from 'yaml'; import { environments } from '../../api'; import type { Environment } from '../../api'; -import { Breadcrumbs, Button, Card, DescriptionList, Notification, Timestamp, UuidBadge } from '../../ui'; +import { Breadcrumbs, Button, Card, DescriptionList, Timestamp, UuidBadge } from '../../ui'; import styles from '../Page.module.css'; export function EnvironmentDetailPage() { @@ -75,15 +75,6 @@ export function EnvironmentDetailPage() { )} - {error && ( - - {error.startsWith('404') ? t('errors.not_found_environment', { id }) : error} - - )} - {loading &&

{t('environments.loading')}

} {env && ( diff --git a/client/src/pages/environment/EnvironmentsPage.tsx b/client/src/pages/environment/EnvironmentsPage.tsx index bd28bf9..44fc6d0 100644 --- a/client/src/pages/environment/EnvironmentsPage.tsx +++ b/client/src/pages/environment/EnvironmentsPage.tsx @@ -171,7 +171,6 @@ export function EnvironmentsPage() { - {error &&

{error}

} {loading ? (

{t('environments.loading')}

) : ( diff --git a/client/src/pages/run/RunDetailPage.tsx b/client/src/pages/run/RunDetailPage.tsx index 6738b0f..6f89590 100644 --- a/client/src/pages/run/RunDetailPage.tsx +++ b/client/src/pages/run/RunDetailPage.tsx @@ -213,11 +213,6 @@ export function RunDetailPage() { - {error && ( - - {error.startsWith('404') ? t('errors.not_found') : error} - - )} {loading &&

{t('runs.loading')}

} {run && ( diff --git a/client/src/pages/scenario/CreateScenarioPage.tsx b/client/src/pages/scenario/CreateScenarioPage.tsx index 8cae2d6..94fb0b8 100644 --- a/client/src/pages/scenario/CreateScenarioPage.tsx +++ b/client/src/pages/scenario/CreateScenarioPage.tsx @@ -48,8 +48,6 @@ export function CreateScenarioPage() { /> - {error &&

{error}

} -
diff --git a/client/src/pages/scenario/CreateStepPage.tsx b/client/src/pages/scenario/CreateStepPage.tsx index 800f287..163b44a 100644 --- a/client/src/pages/scenario/CreateStepPage.tsx +++ b/client/src/pages/scenario/CreateStepPage.tsx @@ -64,8 +64,6 @@ export function CreateStepPage() { />
- {error &&

{error}

} -
diff --git a/client/src/pages/scenario/EditScenarioPage.tsx b/client/src/pages/scenario/EditScenarioPage.tsx index 9393399..6f4f041 100644 --- a/client/src/pages/scenario/EditScenarioPage.tsx +++ b/client/src/pages/scenario/EditScenarioPage.tsx @@ -68,7 +68,6 @@ export function EditScenarioPage() { />
- {error &&

{error}

} {loading &&

{t('scenarios.loading')}

} {!loading && scenario && ( diff --git a/client/src/pages/scenario/EditStepPage.tsx b/client/src/pages/scenario/EditStepPage.tsx index 4bb56cb..f7a0d3c 100644 --- a/client/src/pages/scenario/EditStepPage.tsx +++ b/client/src/pages/scenario/EditStepPage.tsx @@ -72,7 +72,6 @@ export function EditStepPage() { /> - {error &&

{error}

} {loading &&

{t('steps.loading')}

} {!loading && step && ( diff --git a/client/src/pages/scenario/ScenarioDetailPage.tsx b/client/src/pages/scenario/ScenarioDetailPage.tsx index 1c0eef0..3b9e91e 100644 --- a/client/src/pages/scenario/ScenarioDetailPage.tsx +++ b/client/src/pages/scenario/ScenarioDetailPage.tsx @@ -11,7 +11,6 @@ import { Card, DescriptionList, Input, - Notification, Select, Table, Timestamp, @@ -306,14 +305,6 @@ export function ScenarioDetailPage() { )} - {error && ( - - {error.startsWith('404') ? t('errors.not_found_scenario', { id }) : error} - - )} {loading &&

{t('scenarios.loading')}

} {scenario && ( diff --git a/client/src/pages/scenario/ScenariosPage.tsx b/client/src/pages/scenario/ScenariosPage.tsx index 2576228..fad86ea 100644 --- a/client/src/pages/scenario/ScenariosPage.tsx +++ b/client/src/pages/scenario/ScenariosPage.tsx @@ -114,7 +114,6 @@ export function ScenariosPage() { - {error &&

{error}

} - {error && ( - - {error.startsWith('404') ? t('errors.not_found_session', { id }) : error} - - )} {loading &&

{t('sessions.loading')}

} {session && ( diff --git a/client/src/pages/session/SessionsPage.tsx b/client/src/pages/session/SessionsPage.tsx index 9f98f09..c4a51c7 100644 --- a/client/src/pages/session/SessionsPage.tsx +++ b/client/src/pages/session/SessionsPage.tsx @@ -78,7 +78,6 @@ export function SessionsPage() {
}]} />
- {error &&

{error}

}
- {error &&

{error}

} -
diff --git a/client/src/pages/snippet/EditSnippetPage.tsx b/client/src/pages/snippet/EditSnippetPage.tsx index 68bbe4a..0d06a37 100644 --- a/client/src/pages/snippet/EditSnippetPage.tsx +++ b/client/src/pages/snippet/EditSnippetPage.tsx @@ -83,7 +83,6 @@ export function EditSnippetPage() { />
- {error &&

{error}

} {loading &&

{t('snippets.loading')}

} {!loading && snippet && ( diff --git a/client/src/pages/snippet/SnippetDetailPage.tsx b/client/src/pages/snippet/SnippetDetailPage.tsx index 59ec657..036ab2c 100644 --- a/client/src/pages/snippet/SnippetDetailPage.tsx +++ b/client/src/pages/snippet/SnippetDetailPage.tsx @@ -11,7 +11,6 @@ import { Button, Card, DescriptionList, - Notification, Timestamp, UuidBadge, } from '../../ui'; @@ -83,15 +82,6 @@ export function SnippetDetailPage() { )} - {error && ( - - {error.startsWith('404') ? t('errors.not_found_snippet', { id }) : error} - - )} - {loading &&

{t('snippets.loading')}

} {snippet && ( diff --git a/client/src/pages/snippet/SnippetsPage.tsx b/client/src/pages/snippet/SnippetsPage.tsx index d9ed9fd..e2b5897 100644 --- a/client/src/pages/snippet/SnippetsPage.tsx +++ b/client/src/pages/snippet/SnippetsPage.tsx @@ -132,7 +132,6 @@ export function SnippetsPage() { - {error &&

{error}

} {loading &&

{t('snippets.loading')}

} {!loading && ( diff --git a/client/src/ui/Toast/ToastProvider.tsx b/client/src/ui/Toast/ToastProvider.tsx index 23c3749..70a6b7e 100644 --- a/client/src/ui/Toast/ToastProvider.tsx +++ b/client/src/ui/Toast/ToastProvider.tsx @@ -1,6 +1,7 @@ -import { createContext, useCallback, useContext, useMemo, useState } from 'react'; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { AlertCircle, CheckCircle2, Info, X } from 'lucide-react'; import styles from './ToastProvider.module.css'; +import { subscribeApiErrorToasts } from '../../lib/toast-events'; type ToastVariant = 'success' | 'error' | 'info'; @@ -34,6 +35,7 @@ export interface ToastProviderProps { export function ToastProvider({ children, durationMs = 3500 }: ToastProviderProps) { const EXIT_ANIMATION_MS = 100; const [items, setItems] = useState([]); + const lastShownAtRef = useRef>(new Map()); const remove = useCallback((id: number) => { setItems((prev) => prev.filter((item) => item.id !== id)); @@ -49,6 +51,12 @@ export function ToastProvider({ children, durationMs = 3500 }: ToastProviderProp }, [remove]); const show = useCallback((message: string, variant: ToastVariant = 'info') => { + const now = Date.now(); + const dedupeKey = `${variant}:${message}`; + const previousTs = lastShownAtRef.current.get(dedupeKey) ?? 0; + if (now - previousTs < 600) return; + lastShownAtRef.current.set(dedupeKey, now); + const id = Date.now() + Math.floor(Math.random() * 1000); setItems((prev) => [...prev, { id, message, variant, closing: false }]); window.setTimeout(() => dismiss(id), durationMs); @@ -61,6 +69,10 @@ export function ToastProvider({ children, durationMs = 3500 }: ToastProviderProp info: (message: string) => show(message, 'info'), }), [show]); + useEffect(() => { + return subscribeApiErrorToasts((message) => api.error(message)); + }, [api]); + return ( {children} diff --git a/client/tsconfig.tsbuildinfo b/client/tsconfig.tsbuildinfo index 59b4829..95155e5 100644 --- a/client/tsconfig.tsbuildinfo +++ b/client/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/declarations.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/index.ts","./src/api/types.ts","./src/hooks/useTheme.ts","./src/i18n/index.ts","./src/lib/hljs.ts","./src/pages/credential/CreateCredentialPage.tsx","./src/pages/credential/CredentialDetailPage.tsx","./src/pages/credential/CredentialsPage.tsx","./src/pages/credential/EditCredentialPage.tsx","./src/pages/environment/CreateEnvironmentPage.tsx","./src/pages/environment/EditEnvironmentPage.tsx","./src/pages/environment/EnvironmentDetailPage.tsx","./src/pages/environment/EnvironmentsPage.tsx","./src/pages/run/AllRunsPage.tsx","./src/pages/run/RunDetailPage.tsx","./src/pages/run/RunsPage.tsx","./src/pages/scenario/CreateScenarioPage.tsx","./src/pages/scenario/CreateStepPage.tsx","./src/pages/scenario/EditScenarioPage.tsx","./src/pages/scenario/EditStepPage.tsx","./src/pages/scenario/ScenarioDetailPage.tsx","./src/pages/scenario/ScenariosPage.tsx","./src/pages/session/SessionDetailPage.tsx","./src/pages/session/SessionsPage.tsx","./src/pages/snippet/CreateSnippetPage.tsx","./src/pages/snippet/EditSnippetPage.tsx","./src/pages/snippet/SnippetDetailPage.tsx","./src/pages/snippet/SnippetsPage.tsx","./src/ui/index.ts","./src/ui/AutoRefreshIndicator/AutoRefreshIndicator.tsx","./src/ui/Badge/Badge.tsx","./src/ui/Breadcrumbs/Breadcrumbs.tsx","./src/ui/Button/Button.tsx","./src/ui/Card/Card.tsx","./src/ui/CodeBlock/CodeBlock.tsx","./src/ui/CodeEditor/CodeEditor.tsx","./src/ui/ContextMenu/ContextMenu.tsx","./src/ui/DescriptionList/DescriptionList.tsx","./src/ui/Input/Input.tsx","./src/ui/Notification/Notification.tsx","./src/ui/Pagination/Pagination.tsx","./src/ui/Search/Search.tsx","./src/ui/Select/Select.tsx","./src/ui/SidePanel/SidePanel.tsx","./src/ui/Table/Table.tsx","./src/ui/Textarea/Textarea.tsx","./src/ui/ThemeSwitcher/ThemeSwitcher.tsx","./src/ui/Timestamp/Timestamp.tsx","./src/ui/Toast/ToastProvider.tsx","./src/ui/UuidBadge/UuidBadge.tsx"],"version":"5.8.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/declarations.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/index.ts","./src/api/types.ts","./src/hooks/useTheme.ts","./src/i18n/index.ts","./src/lib/hljs.ts","./src/lib/toast-events.ts","./src/pages/credential/CreateCredentialPage.tsx","./src/pages/credential/CredentialDetailPage.tsx","./src/pages/credential/CredentialsPage.tsx","./src/pages/credential/EditCredentialPage.tsx","./src/pages/environment/CreateEnvironmentPage.tsx","./src/pages/environment/EditEnvironmentPage.tsx","./src/pages/environment/EnvironmentDetailPage.tsx","./src/pages/environment/EnvironmentsPage.tsx","./src/pages/run/AllRunsPage.tsx","./src/pages/run/RunDetailPage.tsx","./src/pages/run/RunsPage.tsx","./src/pages/scenario/CreateScenarioPage.tsx","./src/pages/scenario/CreateStepPage.tsx","./src/pages/scenario/EditScenarioPage.tsx","./src/pages/scenario/EditStepPage.tsx","./src/pages/scenario/ScenarioDetailPage.tsx","./src/pages/scenario/ScenariosPage.tsx","./src/pages/session/SessionDetailPage.tsx","./src/pages/session/SessionsPage.tsx","./src/pages/snippet/CreateSnippetPage.tsx","./src/pages/snippet/EditSnippetPage.tsx","./src/pages/snippet/SnippetDetailPage.tsx","./src/pages/snippet/SnippetsPage.tsx","./src/ui/index.ts","./src/ui/AutoRefreshIndicator/AutoRefreshIndicator.tsx","./src/ui/Badge/Badge.tsx","./src/ui/Breadcrumbs/Breadcrumbs.tsx","./src/ui/Button/Button.tsx","./src/ui/Card/Card.tsx","./src/ui/CodeBlock/CodeBlock.tsx","./src/ui/CodeEditor/CodeEditor.tsx","./src/ui/ContextMenu/ContextMenu.tsx","./src/ui/DescriptionList/DescriptionList.tsx","./src/ui/Input/Input.tsx","./src/ui/Notification/Notification.tsx","./src/ui/Pagination/Pagination.tsx","./src/ui/Search/Search.tsx","./src/ui/Select/Select.tsx","./src/ui/SidePanel/SidePanel.tsx","./src/ui/Table/Table.tsx","./src/ui/Textarea/Textarea.tsx","./src/ui/ThemeSwitcher/ThemeSwitcher.tsx","./src/ui/Timestamp/Timestamp.tsx","./src/ui/Toast/ToastProvider.tsx","./src/ui/UuidBadge/UuidBadge.tsx"],"version":"5.8.3"} \ No newline at end of file