From e66e53c817438c717bcbf59ca1c340b55cc0606e Mon Sep 17 00:00:00 2001 From: Andrii Arsenin Date: Tue, 14 Apr 2026 22:54:03 +0300 Subject: [PATCH] chore: apply code formatting and linting - format long import statements with consistent line wrapping - apply consistent indentation across client and server modules - remove generated tsconfig.tsbuildinfo file --- .gitignore | 1 + client/package.json | 2 + client/src/App.tsx | 24 ++++-- client/src/api/client.ts | 9 ++- .../modals/DeleteConfirmationModal.tsx | 45 +++++++++++ .../components/modals/RunScenarioModal.tsx | 80 +++++++++++++++++++ client/src/components/modals/index.ts | 4 + client/src/i18n/locales/en.json | 24 ++++++ client/src/lib/toast-events.ts | 11 +-- client/src/main.tsx | 15 ++-- .../pages/credential/CreateCredentialPage.tsx | 15 ++-- .../pages/credential/CredentialDetailPage.tsx | 40 +++------- .../src/pages/credential/CredentialsPage.tsx | 29 +++---- .../pages/credential/EditCredentialPage.tsx | 16 ++-- .../environment/CreateEnvironmentPage.tsx | 15 ++-- .../pages/environment/EditEnvironmentPage.tsx | 16 ++-- .../environment/EnvironmentDetailPage.tsx | 68 ++++++++-------- .../pages/environment/EnvironmentsPage.tsx | 29 +++---- client/src/pages/run/AllRunsPage.tsx | 54 +++++-------- client/src/pages/run/RunDetailPage.tsx | 78 ++++++++++++++---- client/src/pages/run/RunsPage.tsx | 15 ++-- .../src/pages/scenario/CreateScenarioPage.tsx | 15 ++-- client/src/pages/scenario/CreateStepPage.tsx | 15 ++-- .../src/pages/scenario/EditScenarioPage.tsx | 16 ++-- client/src/pages/scenario/EditStepPage.tsx | 16 ++-- .../src/pages/scenario/ScenarioDetailPage.tsx | 73 ++++++++++++----- client/src/pages/scenario/ScenariosPage.tsx | 26 +++--- .../src/pages/session/SessionDetailPage.tsx | 41 +++------- client/src/pages/session/SessionsPage.tsx | 25 ++---- .../src/pages/snippet/CreateSnippetPage.tsx | 15 ++-- client/src/pages/snippet/EditSnippetPage.tsx | 16 ++-- .../src/pages/snippet/SnippetDetailPage.tsx | 22 +++-- client/src/pages/snippet/SnippetsPage.tsx | 40 +++++----- client/src/ui/Breadcrumbs/Breadcrumbs.tsx | 9 ++- client/src/ui/Toast/ToastProvider.tsx | 79 ++++++++---------- client/src/ui/Toast/toast-context.ts | 12 +++ client/src/ui/Toast/useToast.ts | 10 +++ client/src/ui/index.ts | 3 +- client/tsconfig.tsbuildinfo | 1 - package-lock.json | 25 ++++++ server/src/browser/browser.controller.ts | 8 +- server/src/browser/browser.module.ts | 8 +- server/src/browser/browser.service.ts | 10 ++- .../code-executor/code-executor.service.ts | 19 +++-- .../src/code-executor/exec-context.builder.ts | 6 +- server/src/environment/environment.service.ts | 4 +- server/src/main.ts | 5 +- server/src/mcp/mcp.service.ts | 29 +++++-- .../scenario/scenario-scheduler.service.ts | 14 +++- server/src/scenario/scenario.controller.ts | 6 +- server/src/scenario/scenario.service.ts | 14 +++- server/src/snippet/snippet.service.ts | 15 +++- server/test/browser.controller.spec.ts | 58 +++++++++++++- server/test/environment.controller.spec.ts | 4 +- server/test/mcp.controller.spec.ts | 5 +- server/test/scenario.controller.spec.ts | 71 +++++++++------- server/test/session.controller.spec.ts | 4 +- 57 files changed, 851 insertions(+), 478 deletions(-) create mode 100644 client/src/components/modals/DeleteConfirmationModal.tsx create mode 100644 client/src/components/modals/RunScenarioModal.tsx create mode 100644 client/src/components/modals/index.ts create mode 100644 client/src/ui/Toast/toast-context.ts create mode 100644 client/src/ui/Toast/useToast.ts delete mode 100644 client/tsconfig.tsbuildinfo diff --git a/.gitignore b/.gitignore index e1555bd..a5f1fe5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ server/dist client/dist *storybook.log storybook-static +tsconfig.tsbuildinfo diff --git a/client/package.json b/client/package.json index d2d4dcd..fab3062 100644 --- a/client/package.json +++ b/client/package.json @@ -8,6 +8,7 @@ "preview": "vite preview", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", + "test:compile": "tsc --noEmit", "test:storybook": "vitest run --project storybook", "lint": "eslint src .storybook", "lint:fix": "eslint src .storybook --fix", @@ -15,6 +16,7 @@ }, "dependencies": { "@monaco-editor/react": "^4.7.0", + "@tanstack/react-query": "^5.99.0", "highlight.js": "^11.11.1", "i18next": "^26.0.4", "lucide-react": "^1.7.0", diff --git a/client/src/App.tsx b/client/src/App.tsx index 080d605..9a14dc7 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -11,13 +11,19 @@ const EnvironmentsPage = lazy(() => import('./pages/environment/EnvironmentsPage').then((m) => ({ default: m.EnvironmentsPage })), ); const EnvironmentDetailPage = lazy(() => - import('./pages/environment/EnvironmentDetailPage').then((m) => ({ default: m.EnvironmentDetailPage })), + import('./pages/environment/EnvironmentDetailPage').then((m) => ({ + default: m.EnvironmentDetailPage, + })), ); const CreateEnvironmentPage = lazy(() => - import('./pages/environment/CreateEnvironmentPage').then((m) => ({ default: m.CreateEnvironmentPage })), + import('./pages/environment/CreateEnvironmentPage').then((m) => ({ + default: m.CreateEnvironmentPage, + })), ); const EditEnvironmentPage = lazy(() => - import('./pages/environment/EditEnvironmentPage').then((m) => ({ default: m.EditEnvironmentPage })), + import('./pages/environment/EditEnvironmentPage').then((m) => ({ + default: m.EditEnvironmentPage, + })), ); const SessionsPage = lazy(() => @@ -46,9 +52,7 @@ const EditStepPage = lazy(() => import('./pages/scenario/EditStepPage').then((m) => ({ default: m.EditStepPage })), ); -const RunsPage = lazy(() => - import('./pages/run/RunsPage').then((m) => ({ default: m.RunsPage })), -); +const RunsPage = lazy(() => import('./pages/run/RunsPage').then((m) => ({ default: m.RunsPage }))); const RunDetailPage = lazy(() => import('./pages/run/RunDetailPage').then((m) => ({ default: m.RunDetailPage })), ); @@ -60,13 +64,17 @@ const CredentialsPage = lazy(() => import('./pages/credential/CredentialsPage').then((m) => ({ default: m.CredentialsPage })), ); const CreateCredentialPage = lazy(() => - import('./pages/credential/CreateCredentialPage').then((m) => ({ default: m.CreateCredentialPage })), + import('./pages/credential/CreateCredentialPage').then((m) => ({ + default: m.CreateCredentialPage, + })), ); const EditCredentialPage = lazy(() => import('./pages/credential/EditCredentialPage').then((m) => ({ default: m.EditCredentialPage })), ); const CredentialDetailPage = lazy(() => - import('./pages/credential/CredentialDetailPage').then((m) => ({ default: m.CredentialDetailPage })), + import('./pages/credential/CredentialDetailPage').then((m) => ({ + default: m.CredentialDetailPage, + })), ); const SnippetsPage = lazy(() => diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 6488a23..22ef526 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -15,7 +15,10 @@ import { emitApiErrorToast } from '../lib/toast-events'; // API calls are versioned under /api/v1 by default. // Set VITE_API_URL (for example, http://localhost:13000/api/v1) to use a different origin. -const BASE_URL = ((import.meta.env.VITE_API_URL as string | undefined) ?? '/api/v1').replace(/\/$/, ''); +const BASE_URL = ((import.meta.env.VITE_API_URL as string | undefined) ?? '/api/v1').replace( + /\/$/, + '', +); async function request(path: string, init?: RequestInit): Promise { let res: Response; @@ -90,7 +93,9 @@ export const snippets = { get(id: string): Promise { return request(`/snippets/${id}`); }, - create(payload: Pick & { description?: string }): Promise { + create( + payload: Pick & { description?: string }, + ): Promise { return request('/snippets', { method: 'POST', body: JSON.stringify(payload), diff --git a/client/src/components/modals/DeleteConfirmationModal.tsx b/client/src/components/modals/DeleteConfirmationModal.tsx new file mode 100644 index 0000000..9c50e3c --- /dev/null +++ b/client/src/components/modals/DeleteConfirmationModal.tsx @@ -0,0 +1,45 @@ +import { useTranslation } from 'react-i18next'; +import { X, Trash2 } from 'lucide-react'; +import { Modal, Button } from '../../ui'; + +export interface DeleteConfirmationModalProps { + open: boolean; + title: string; + message: string; + onClose: () => void; + onConfirm: () => void; + isDeleting: boolean; +} + +export function DeleteConfirmationModal({ + open, + title, + message, + onClose, + onConfirm, + isDeleting, +}: DeleteConfirmationModalProps) { + const { t } = useTranslation(); + + return ( + !isDeleting && onClose()} + footer={ + <> + + + + } + > + {message} + + ); +} diff --git a/client/src/components/modals/RunScenarioModal.tsx b/client/src/components/modals/RunScenarioModal.tsx new file mode 100644 index 0000000..5493db8 --- /dev/null +++ b/client/src/components/modals/RunScenarioModal.tsx @@ -0,0 +1,80 @@ +import { useTranslation } from 'react-i18next'; +import { X, Play } from 'lucide-react'; +import { Modal, Button, Select } from '../../ui'; +import type { Environment } from '../../api'; + +export interface RunScenarioModalProps { + open: boolean; + environments: Environment[]; + selectedEnvId: string; + onEnvSelect: (envId: string) => void; + saveSessionFlag: boolean; + onSaveSessionChange: (flag: boolean) => void; + onClose: () => void; + onRun: () => void; + isRunning: boolean; + actionLabel?: string; +} + +export function RunScenarioModal({ + open, + environments, + selectedEnvId, + onEnvSelect, + saveSessionFlag, + onSaveSessionChange, + onClose, + onRun, + isRunning, + actionLabel, +}: RunScenarioModalProps) { + const { t } = useTranslation(); + + return ( + !isRunning && onClose()} + footer={ + <> + + + + } + > + {environments.length === 0 ? ( +

{t('scenarios.run_modal_no_env')}

+ ) : ( + <> + onSaveSessionChange(e.target.checked)} + /> + {t('common.save_session')} + + + )} +
+ ); +} diff --git a/client/src/components/modals/index.ts b/client/src/components/modals/index.ts new file mode 100644 index 0000000..b9ca6e9 --- /dev/null +++ b/client/src/components/modals/index.ts @@ -0,0 +1,4 @@ +export { DeleteConfirmationModal } from './DeleteConfirmationModal'; +export type { DeleteConfirmationModalProps } from './DeleteConfirmationModal'; +export { RunScenarioModal } from './RunScenarioModal'; +export type { RunScenarioModalProps } from './RunScenarioModal'; diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index dd3c114..c802540 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -1,4 +1,17 @@ { + "common": { + "button_cancel": "Cancel", + "button_confirm": "Confirm", + "button_delete": "Delete", + "confirm_delete_credential": "Are you sure you want to delete this credential?", + "confirm_delete_environment": "Are you sure you want to delete this environment?", + "confirm_delete_scenario": "Are you sure you want to delete this scenario?", + "confirm_delete_step": "Are you sure you want to delete this step?", + "confirm_delete_session": "Are you sure you want to delete this session?", + "confirm_delete_snippet": "Are you sure you want to delete this snippet?", + "confirm_remove_credential": "Are you sure you want to remove this credential from scenario?", + "save_session": "Save session" + }, "errors": { "not_found": "Not found", "not_found_session": "Session {{id}} does not exist or has been deleted.", @@ -19,6 +32,8 @@ }, "environments": { "title": "Environments", + "created": "Environment created", + "updated": "Environment updated", "col_id": "ID", "col_name": "Name", "col_data": "Data", @@ -51,6 +66,8 @@ }, "credentials": { "title": "Credentials", + "created": "Credential created", + "updated": "Credential updated", "empty": "No credentials yet.", "loading": "Loading…", "menu_label": "Credential options", @@ -99,6 +116,9 @@ "field_lastUsed": "Last Used" }, "scenarios": { "title": "Scenarios", + "created": "Scenario created", + "updated": "Scenario updated", + "run_started": "Scenario run started", "col_id": "ID", "col_name": "Name", "col_updated": "Updated", @@ -165,6 +185,8 @@ "page_size": "Items per page" }, "steps": { + "created": "Step created", + "updated": "Step updated", "create_title": "Add Step", "edit_title": "Edit Step #{{order}}", "loading": "Loading…", @@ -215,6 +237,8 @@ }, "snippets": { "title": "Snippets", + "created": "Snippet created", + "updated": "Snippet updated", "empty": "No snippets yet.", "loading": "Loading…", "menu_label": "Snippet options", diff --git a/client/src/lib/toast-events.ts b/client/src/lib/toast-events.ts index b8ef018..9d862c6 100644 --- a/client/src/lib/toast-events.ts +++ b/client/src/lib/toast-events.ts @@ -6,6 +6,7 @@ export interface ApiErrorToastDetail { export function emitApiErrorToast(message: string): void { if (typeof window === 'undefined') return; + window.dispatchEvent( new CustomEvent(API_ERROR_TOAST_EVENT, { detail: { message }, @@ -13,17 +14,17 @@ export function emitApiErrorToast(message: string): void { ); } -export function subscribeApiErrorToasts( - handler: (message: string) => void, -): () => void { +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); + + window.addEventListener(API_ERROR_TOAST_EVENT, listener); return () => { - window.removeEventListener(API_ERROR_TOAST_EVENT, listener as EventListener); + window.removeEventListener(API_ERROR_TOAST_EVENT, listener); }; } diff --git a/client/src/main.tsx b/client/src/main.tsx index cef3f98..dc784c2 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,17 +1,22 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import './i18n'; import './ui/tokens.css'; import App from './App'; import { ToastProvider } from './ui'; +const queryClient = new QueryClient(); + createRoot(document.getElementById('root')!).render( - - - - - + + + + + + + , ); diff --git a/client/src/pages/credential/CreateCredentialPage.tsx b/client/src/pages/credential/CreateCredentialPage.tsx index abcf71a..d55fb78 100644 --- a/client/src/pages/credential/CreateCredentialPage.tsx +++ b/client/src/pages/credential/CreateCredentialPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, SubmitEvent } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { KeyRound, X, Save } from 'lucide-react'; @@ -16,9 +16,8 @@ export function CreateCredentialPage() { const [nameError, setNameError] = useState(''); const [dataError, setDataError] = useState(''); const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = async (e: SubmitEvent) => { e.preventDefault(); let valid = true; if (!name.trim()) { @@ -35,14 +34,12 @@ export function CreateCredentialPage() { } if (!valid) return; setSaving(true); - setError(null); try { const credential = await credentials.create(name.trim(), data.trim() || undefined); - toast.success('Credential created'); + toast.success(t('credentials.created')); navigate(`/credentials/${credential.id}`); } catch (err) { const message = (err as Error).message; - setError(message); toast.error(message); } finally { setSaving(false); @@ -54,7 +51,11 @@ export function CreateCredentialPage() {
, onClick: () => navigate('/credentials') }, + { + label: t('credentials.title'), + icon: , + onClick: () => navigate('/credentials'), + }, { label: t('credentials.create_title') }, ]} /> diff --git a/client/src/pages/credential/CredentialDetailPage.tsx b/client/src/pages/credential/CredentialDetailPage.tsx index 33189aa..42b69ff 100644 --- a/client/src/pages/credential/CredentialDetailPage.tsx +++ b/client/src/pages/credential/CredentialDetailPage.tsx @@ -6,15 +6,8 @@ import { CodeBlock } from '../../ui'; import { stringify as yamlStringify } from 'yaml'; import { credentials } from '../../api'; import type { Credential } from '../../api'; -import { - Breadcrumbs, - Button, - Card, - DescriptionList, - Modal, - Timestamp, - UuidBadge, -} from '../../ui'; +import { Breadcrumbs, Button, Card, DescriptionList, Timestamp, UuidBadge } from '../../ui'; +import { DeleteConfirmationModal } from '../../components/modals'; import styles from '../Page.module.css'; export function CredentialDetailPage() { @@ -23,7 +16,6 @@ export function CredentialDetailPage() { const navigate = useNavigate(); const [credential, setCredential] = useState(null); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [deleting, setDeleting] = useState(false); @@ -32,7 +24,6 @@ export function CredentialDetailPage() { credentials .get(id) .then(setCredential) - .catch((err: Error) => setError(err.message)) .finally(() => setLoading(false)); }, [id]); @@ -65,7 +56,11 @@ export function CredentialDetailPage() {
, onClick: () => navigate('/credentials') }, + { + label: t('credentials.title'), + icon: , + onClick: () => navigate('/credentials'), + }, { label: credential?.name ?? `#${id}` }, ]} /> @@ -136,23 +131,14 @@ export function CredentialDetailPage() { )} - !deleting && setConfirmDeleteOpen(false)} - footer={( - <> - - - - )} - > - Are you sure you want to delete this credential? - + message={t('common.confirm_delete_credential')} + onClose={() => setConfirmDeleteOpen(false)} + onConfirm={handleDelete} + isDeleting={deleting} + />
); } diff --git a/client/src/pages/credential/CredentialsPage.tsx b/client/src/pages/credential/CredentialsPage.tsx index b7a511b..63763c3 100644 --- a/client/src/pages/credential/CredentialsPage.tsx +++ b/client/src/pages/credential/CredentialsPage.tsx @@ -11,10 +11,10 @@ import { Card, ContextMenu, DescriptionList, - Modal, Timestamp, UuidBadge, } from '../../ui'; +import { DeleteConfirmationModal } from '../../components/modals'; import styles from '../Page.module.css'; function CredentialCard({ @@ -103,7 +103,6 @@ export function CredentialsPage() { const navigate = useNavigate(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); const [deleteId, setDeleteId] = useState(null); const [deleting, setDeleting] = useState(false); const fileInputRef = useRef(null); @@ -112,7 +111,6 @@ export function CredentialsPage() { credentials .list() .then((res) => setItems(res.data)) - .catch((err: Error) => setError(err.message)) .finally(() => setLoading(false)); }; @@ -145,8 +143,8 @@ export function CredentialsPage() { const payload = yamlParse(text) as unknown; const imported = await credentials.importCredential(payload); navigate(`/credentials/${imported.id}`); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + } catch { + // Import errors are silently ignored } }; @@ -179,23 +177,14 @@ export function CredentialsPage() {
)} - !deleting && setDeleteId(null)} - footer={( - <> - - - - )} - > - Are you sure you want to delete this credential? - + message={t('common.confirm_delete_credential')} + onClose={() => setDeleteId(null)} + onConfirm={handleDelete} + isDeleting={deleting} + /> ); } diff --git a/client/src/pages/credential/EditCredentialPage.tsx b/client/src/pages/credential/EditCredentialPage.tsx index 5c550b5..47834fa 100644 --- a/client/src/pages/credential/EditCredentialPage.tsx +++ b/client/src/pages/credential/EditCredentialPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, SubmitEvent } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { KeyRound, X, Save } from 'lucide-react'; @@ -20,7 +20,6 @@ export function EditCredentialPage() { const [dataError, setDataError] = useState(''); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); useEffect(() => { if (!id) return; @@ -31,11 +30,10 @@ export function EditCredentialPage() { setName(c.name); setData(c.data ?? ''); }) - .catch((err: Error) => setError(err.message)) .finally(() => setLoading(false)); }, [id]); - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = async (e: SubmitEvent) => { e.preventDefault(); let valid = true; if (!name.trim()) { @@ -52,17 +50,15 @@ export function EditCredentialPage() { } if (!valid) return; setSaving(true); - setError(null); try { await credentials.update(id!, { name: name.trim(), data: data.trim() || null, }); - toast.success('Credential updated'); + toast.success(t('credentials.updated')); navigate(`/credentials/${id}`); } catch (err) { const message = (err as Error).message; - setError(message); toast.error(message); } finally { setSaving(false); @@ -74,7 +70,11 @@ export function EditCredentialPage() {
, onClick: () => navigate('/credentials') }, + { + label: t('credentials.title'), + icon: , + onClick: () => navigate('/credentials'), + }, { label: credential?.name ?? `#${id}`, onClick: () => navigate(`/credentials/${id}`), diff --git a/client/src/pages/environment/CreateEnvironmentPage.tsx b/client/src/pages/environment/CreateEnvironmentPage.tsx index 144f304..95937b2 100644 --- a/client/src/pages/environment/CreateEnvironmentPage.tsx +++ b/client/src/pages/environment/CreateEnvironmentPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, SubmitEvent } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { X, Globe, Save } from 'lucide-react'; @@ -16,9 +16,8 @@ export function CreateEnvironmentPage() { const [nameError, setNameError] = useState(''); const [dataError, setDataError] = useState(''); const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = async (e: SubmitEvent) => { e.preventDefault(); if (!name.trim()) { setNameError(t('environments.form_name_required')); @@ -42,14 +41,12 @@ export function CreateEnvironmentPage() { return; } setSaving(true); - setError(null); try { const env = await environments.create(name.trim(), parsedData); - toast.success('Environment created'); + toast.success(t('environments.created')); navigate(`/environments/${env.id}`); } catch (err) { const message = (err as Error).message; - setError(message); toast.error(message); } finally { setSaving(false); @@ -61,7 +58,11 @@ export function CreateEnvironmentPage() {
, onClick: () => navigate('/environments') }, + { + label: t('environments.title'), + icon: , + onClick: () => navigate('/environments'), + }, { label: t('environments.create_title') }, ]} /> diff --git a/client/src/pages/environment/EditEnvironmentPage.tsx b/client/src/pages/environment/EditEnvironmentPage.tsx index b602905..15196b9 100644 --- a/client/src/pages/environment/EditEnvironmentPage.tsx +++ b/client/src/pages/environment/EditEnvironmentPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, SubmitEvent } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { X, Globe, Save } from 'lucide-react'; @@ -20,7 +20,6 @@ export function EditEnvironmentPage() { const [dataError, setDataError] = useState(''); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); useEffect(() => { if (!id) return; @@ -31,11 +30,10 @@ export function EditEnvironmentPage() { setName(data.name); setDataJson(JSON.stringify(data.data ?? {}, null, 2)); }) - .catch((err: Error) => setError(err.message)) .finally(() => setLoading(false)); }, [id]); - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = async (e: SubmitEvent) => { e.preventDefault(); if (!name.trim()) { setNameError(t('environments.form_name_required')); @@ -59,17 +57,15 @@ export function EditEnvironmentPage() { return; } setSaving(true); - setError(null); try { await environments.update(id!, { name: name.trim(), data: parsedData, }); - toast.success('Environment updated'); + toast.success(t('environments.updated')); navigate(`/environments/${id}`); } catch (err) { const message = (err as Error).message; - setError(message); toast.error(message); } finally { setSaving(false); @@ -81,7 +77,11 @@ export function EditEnvironmentPage() {
, onClick: () => navigate('/environments') }, + { + label: t('environments.title'), + icon: , + onClick: () => navigate('/environments'), + }, { label: env?.name ?? `#${id}`, onClick: () => navigate(`/environments/${id}`), diff --git a/client/src/pages/environment/EnvironmentDetailPage.tsx b/client/src/pages/environment/EnvironmentDetailPage.tsx index f4ebc65..98ed6a6 100644 --- a/client/src/pages/environment/EnvironmentDetailPage.tsx +++ b/client/src/pages/environment/EnvironmentDetailPage.tsx @@ -1,20 +1,29 @@ +import { Globe, Pencil, Trash2, Upload } from 'lucide-react'; import { useEffect, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Upload, Pencil, Trash2, Globe } from 'lucide-react'; +import { useNavigate, useParams } from 'react-router-dom'; import { stringify as yamlStringify } from 'yaml'; -import { environments } from '../../api'; import type { Environment } from '../../api'; -import { Breadcrumbs, Button, Card, DescriptionList, Modal, Timestamp, UuidBadge } from '../../ui'; +import { environments } from '../../api'; +import { DeleteConfirmationModal } from '../../components/modals'; +import { + Breadcrumbs, + Button, + Card, + DescriptionList, + Timestamp, + UuidBadge, + useToast, +} from '../../ui'; import styles from '../Page.module.css'; export function EnvironmentDetailPage() { const { t } = useTranslation(); + const toast = useToast(); const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const [env, setEnv] = useState(null); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [deleting, setDeleting] = useState(false); const dataEntries = Object.entries(env?.data ?? {}).filter(([, v]) => v); @@ -24,9 +33,9 @@ export function EnvironmentDetailPage() { environments .get(id) .then(setEnv) - .catch((err: Error) => setError(err.message)) + .catch((err: Error) => toast.error((err as Error).message)) .finally(() => setLoading(false)); - }, [id]); + }, [id, toast]); const handleDelete = async () => { if (!env) return; @@ -57,7 +66,11 @@ export function EnvironmentDetailPage() {
, onClick: () => navigate('/environments') }, + { + label: t('environments.title'), + icon: , + onClick: () => navigate('/environments'), + }, { label: env?.name ?? `#${id}` }, ]} /> @@ -107,12 +120,12 @@ export function EnvironmentDetailPage() {

{t('environments.section_data')}

- {dataEntries.length === 0 ? ( -

{t('environments.no_data')}

- ) : ( - ({ + {dataEntries.length === 0 ? ( +

{t('environments.no_data')}

+ ) : ( + ({ term: key, detail: ( @@ -120,30 +133,21 @@ export function EnvironmentDetailPage() { ), }))} - /> - )} -
+ /> + )} +
)} - !deleting && setConfirmDeleteOpen(false)} - footer={( - <> - - - - )} - > - Are you sure you want to delete this environment? - + message={t('common.confirm_delete_environment')} + onClose={() => setConfirmDeleteOpen(false)} + onConfirm={handleDelete} + isDeleting={deleting} + />
); } diff --git a/client/src/pages/environment/EnvironmentsPage.tsx b/client/src/pages/environment/EnvironmentsPage.tsx index 25156d6..556e3ea 100644 --- a/client/src/pages/environment/EnvironmentsPage.tsx +++ b/client/src/pages/environment/EnvironmentsPage.tsx @@ -12,10 +12,10 @@ import { Card, ContextMenu, DescriptionList, - Modal, Timestamp, UuidBadge, } from '../../ui'; +import { DeleteConfirmationModal } from '../../components/modals'; import styles from '../Page.module.css'; function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: string) => void }) { @@ -120,7 +120,6 @@ export function EnvironmentsPage() { const navigate = useNavigate(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); const [deleteId, setDeleteId] = useState(null); const [deleting, setDeleting] = useState(false); const fileInputRef = useRef(null); @@ -129,7 +128,6 @@ export function EnvironmentsPage() { environments .list() .then((res) => setItems(res.data)) - .catch((err: Error) => setError(err.message)) .finally(() => setLoading(false)); }; @@ -162,8 +160,8 @@ export function EnvironmentsPage() { const payload = yamlParse(text) as unknown; const imported = await environments.importEnvironment(payload); navigate(`/environments/${imported.id}`); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + } catch { + // Import errors are silently ignored } }; @@ -196,23 +194,14 @@ export function EnvironmentsPage() {
)} - !deleting && setDeleteId(null)} - footer={( - <> - - - - )} - > - Are you sure you want to delete this environment? - + message={t('common.confirm_delete_environment')} + onClose={() => setDeleteId(null)} + onConfirm={handleDelete} + isDeleting={deleting} + />
); } diff --git a/client/src/pages/run/AllRunsPage.tsx b/client/src/pages/run/AllRunsPage.tsx index 3f27207..f9732e7 100644 --- a/client/src/pages/run/AllRunsPage.tsx +++ b/client/src/pages/run/AllRunsPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Activity } from 'lucide-react'; @@ -39,35 +39,20 @@ export function AllRunsPage() { const { t } = useTranslation(); const navigate = useNavigate(); - const [items, setItems] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [pulseKey, setPulseKey] = useState(0); - const pollRef = useRef | null>(null); - const hasDataRef = useRef(false); - - const load = useCallback(() => { - if (!hasDataRef.current) setLoading(true); - runs - .listAll() - .then((res) => { - setItems(res.data as AllRunRow[]); - setError(null); - setPulseKey((k) => k + 1); - hasDataRef.current = true; - }) - .catch((err: Error) => setError(err.message)) - .finally(() => setLoading(false)); - }, []); - - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - load(); - pollRef.current = setInterval(load, 10_000); - return () => { - if (pollRef.current) clearInterval(pollRef.current); - }; - }, []); + const { + data: items = [], + isLoading, + error, + refetch, + } = useQuery({ + queryKey: ['runs'], + queryFn: async () => { + const res = await runs.listAll(); + return res.data as AllRunRow[]; + }, + refetchInterval: 10_000, + staleTime: 0, + }); const columns: TableColumn[] = [ { key: 'id', header: t('runs.col_id'), render: (r) => , width: 60 }, @@ -110,14 +95,19 @@ export function AllRunsPage() {
}]} /> - + refetch()} + />
r.id} - loading={loading} + loading={isLoading} emptyMessage={t('runs.empty')} pageSize={20} pageSizeOptions={[10, 20, 50]} diff --git a/client/src/pages/run/RunDetailPage.tsx b/client/src/pages/run/RunDetailPage.tsx index 02370ba..c274821 100644 --- a/client/src/pages/run/RunDetailPage.tsx +++ b/client/src/pages/run/RunDetailPage.tsx @@ -19,7 +19,6 @@ import { Card, CodeBlock, DescriptionList, - Notification, Pagination, Search, Table, @@ -175,7 +174,14 @@ export function RunDetailPage() { { key: 'description', header: ( -
+
{t('runs.step_description')} {run?.stepRuns.some((s) => s.output) && ( )} @@ -250,15 +261,15 @@ export function RunDetailPage() {
{isExpanded && s.output && (
- { try { return JSON.stringify(JSON.parse(s.output), null, 2); } catch { return s.output; } - })()} - language="json" + })()} + language="json" />
)} @@ -275,7 +286,11 @@ export function RunDetailPage() { width: 70, render: (l) => {l.level}, }, - { key: 'message', header: t('runs.log_message'), render: (l) => {l.message} }, + { + key: 'message', + header: t('runs.log_message'), + render: (l) => {l.message}, + }, { key: 'time', header: t('runs.log_time'), @@ -289,7 +304,11 @@ export function RunDetailPage() {
, onClick: () => navigate('/scenarios') }, + { + label: t('scenarios.title'), + icon: , + onClick: () => navigate('/scenarios'), + }, { label: scenario?.name ?? `${id}`, onClick: () => navigate(`/scenarios/${id}`), @@ -302,7 +321,12 @@ export function RunDetailPage() { { label: `${runId}` }, ]} /> - +
{loading &&

{t('runs.loading')}

} @@ -327,10 +351,30 @@ export function RunDetailPage() { { term: t('runs.field_updated'), detail: }, ]; if (run.environment) { - items.push({ term: 'Environment', detail: {run.environment.name} }); + items.push({ + term: 'Environment', + detail: ( + + {run.environment.name} + + ), + }); } if (run.session) { - items.push({ term: 'Session', detail: {run.session.sessionName} }); + items.push({ + term: 'Session', + detail: ( + + {run.session.sessionName} + + ), + }); } return items; })()} diff --git a/client/src/pages/run/RunsPage.tsx b/client/src/pages/run/RunsPage.tsx index 029f6c1..6f7759b 100644 --- a/client/src/pages/run/RunsPage.tsx +++ b/client/src/pages/run/RunsPage.tsx @@ -76,7 +76,6 @@ export function RunsPage() { }, [id]); useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect load(); pollRef.current = setInterval(load, 10_000); return () => { @@ -102,7 +101,7 @@ export function RunsPage() { setRunning(true); try { const run = await scenarios.run(id!, selectedEnvId); - toast.success('Scenario run started'); + toast.success(t('scenarios.run_started')); navigate(`/scenarios/${id}/runs/${run.id}`); setRunModalOpen(false); } catch (err) { @@ -139,7 +138,11 @@ export function RunsPage() {
, onClick: () => navigate('/scenarios') }, + { + label: t('scenarios.title'), + icon: , + onClick: () => navigate('/scenarios'), + }, { label: scenario?.name ?? `#${id}`, onClick: () => navigate(`/scenarios/${id}`), @@ -171,16 +174,16 @@ export function RunsPage() { open={runModalOpen} title={t('scenarios.run_modal_title')} onClose={() => !running && setRunModalOpen(false)} - footer={( + footer={ <> - )} + } > {envs.length === 0 ? (

{t('scenarios.run_modal_no_env')}

diff --git a/client/src/pages/scenario/CreateScenarioPage.tsx b/client/src/pages/scenario/CreateScenarioPage.tsx index 94fb0b8..00ee982 100644 --- a/client/src/pages/scenario/CreateScenarioPage.tsx +++ b/client/src/pages/scenario/CreateScenarioPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, SubmitEvent } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { X, Save, ClipboardList } from 'lucide-react'; @@ -14,23 +14,20 @@ export function CreateScenarioPage() { const [name, setName] = useState(''); const [nameError, setNameError] = useState(''); const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = async (e: SubmitEvent) => { e.preventDefault(); if (!name.trim()) { setNameError(t('scenarios.form_name_required')); return; } setSaving(true); - setError(null); try { const scenario = await scenarios.create(name.trim()); - toast.success('Scenario created'); + toast.success(t('scenarios.created')); navigate(`/scenarios/${scenario.id}`); } catch (err) { const message = (err as Error).message; - setError(message); toast.error(message); } finally { setSaving(false); @@ -42,7 +39,11 @@ export function CreateScenarioPage() {
, onClick: () => navigate('/scenarios') }, + { + label: t('scenarios.title'), + icon: , + onClick: () => navigate('/scenarios'), + }, { label: t('scenarios.create_title') }, ]} /> diff --git a/client/src/pages/scenario/CreateStepPage.tsx b/client/src/pages/scenario/CreateStepPage.tsx index d045aac..e0d41c3 100644 --- a/client/src/pages/scenario/CreateStepPage.tsx +++ b/client/src/pages/scenario/CreateStepPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, SubmitEvent } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { X, Save, ClipboardList } from 'lucide-react'; @@ -17,7 +17,6 @@ export function CreateStepPage() { const [title, setTitle] = useState(''); const [execCode, setExecCode] = useState(''); const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); useEffect(() => { if (!id) return; @@ -27,20 +26,18 @@ export function CreateStepPage() { .catch(() => null); }, [id]); - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = async (e: SubmitEvent) => { e.preventDefault(); setSaving(true); - setError(null); try { await steps.create(id!, { title: title.trim() || undefined, execCode: execCode.trim() || undefined, }); - toast.success('Step created'); + toast.success(t('steps.created')); navigate(`/scenarios/${id}`); } catch (err) { const message = (err as Error).message; - setError(message); toast.error(message); } finally { setSaving(false); @@ -52,7 +49,11 @@ export function CreateStepPage() {
, onClick: () => navigate('/scenarios') }, + { + label: t('scenarios.title'), + icon: , + onClick: () => navigate('/scenarios'), + }, { label: scenario?.name ?? `#${id}`, onClick: () => navigate(`/scenarios/${id}`), diff --git a/client/src/pages/scenario/EditScenarioPage.tsx b/client/src/pages/scenario/EditScenarioPage.tsx index 6f4f041..b9b2cc0 100644 --- a/client/src/pages/scenario/EditScenarioPage.tsx +++ b/client/src/pages/scenario/EditScenarioPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, SubmitEvent } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { X, Save, ClipboardList } from 'lucide-react'; @@ -18,7 +18,6 @@ export function EditScenarioPage() { const [nameError, setNameError] = useState(''); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); useEffect(() => { if (!id) return; @@ -28,25 +27,22 @@ export function EditScenarioPage() { setScenario(data); setName(data.name); }) - .catch((err: Error) => setError(err.message)) .finally(() => setLoading(false)); }, [id]); - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = async (e: SubmitEvent) => { e.preventDefault(); if (!name.trim()) { setNameError(t('scenarios.form_name_required')); return; } setSaving(true); - setError(null); try { await scenarios.update(id!, { name: name.trim() }); - toast.success('Scenario updated'); + toast.success(t('scenarios.updated')); navigate(`/scenarios/${id}`); } catch (err) { const message = (err as Error).message; - setError(message); toast.error(message); } finally { setSaving(false); @@ -58,7 +54,11 @@ export function EditScenarioPage() {
, onClick: () => navigate('/scenarios') }, + { + label: t('scenarios.title'), + icon: , + onClick: () => navigate('/scenarios'), + }, { label: scenario?.name ?? `#${id}`, onClick: () => navigate(`/scenarios/${id}`), diff --git a/client/src/pages/scenario/EditStepPage.tsx b/client/src/pages/scenario/EditStepPage.tsx index 82b09aa..2e9a453 100644 --- a/client/src/pages/scenario/EditStepPage.tsx +++ b/client/src/pages/scenario/EditStepPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, SubmitEvent } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { X, Save, ClipboardList } from 'lucide-react'; @@ -19,7 +19,6 @@ export function EditStepPage() { const [execCode, setExecCode] = useState(''); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); useEffect(() => { if (!id || !stepId) return; @@ -30,24 +29,21 @@ export function EditStepPage() { setTitle(st.title ?? ''); setExecCode(st.execCode ?? ''); }) - .catch((err: Error) => setError(err.message)) .finally(() => setLoading(false)); }, [id, stepId]); - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = async (e: SubmitEvent) => { e.preventDefault(); setSaving(true); - setError(null); try { await steps.update(id!, stepId!, { title: title.trim() || undefined, execCode: execCode.trim() || undefined, }); - toast.success('Step updated'); + toast.success(t('steps.updated')); navigate(`/scenarios/${id}`); } catch (err) { const message = (err as Error).message; - setError(message); toast.error(message); } finally { setSaving(false); @@ -59,7 +55,11 @@ export function EditStepPage() {
, onClick: () => navigate('/scenarios') }, + { + label: t('scenarios.title'), + icon: , + onClick: () => navigate('/scenarios'), + }, { label: scenario?.name ?? `#${id}`, onClick: () => navigate(`/scenarios/${id}`), diff --git a/client/src/pages/scenario/ScenarioDetailPage.tsx b/client/src/pages/scenario/ScenarioDetailPage.tsx index b606b3f..ea0df13 100644 --- a/client/src/pages/scenario/ScenarioDetailPage.tsx +++ b/client/src/pages/scenario/ScenarioDetailPage.tsx @@ -1,10 +1,31 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, SubmitEvent } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Play, Pencil, Plus, History, Trash2, Upload, GripVertical, ClipboardList } from 'lucide-react'; +import { + Play, + Pencil, + Plus, + History, + Trash2, + Upload, + GripVertical, + ClipboardList, +} from 'lucide-react'; import { stringify as yamlStringify } from 'yaml'; -import { environments, scenarios, steps, scenarioCredentials, credentials as credentialsApi } from '../../api'; -import type { Scenario, ScenarioStep, ScenarioCredential, Credential, Environment } from '../../api'; +import { + environments, + scenarios, + steps, + scenarioCredentials, + credentials as credentialsApi, +} from '../../api'; +import type { + Scenario, + ScenarioStep, + ScenarioCredential, + Credential, + Environment, +} from '../../api'; import { Breadcrumbs, Button, @@ -28,7 +49,6 @@ export function ScenarioDetailPage() { const toast = useToast(); const [scenario, setScenario] = useState<(Scenario & { steps: ScenarioStep[] }) | null>(null); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); // Credentials state const [scenarioCreds, setScenarioCreds] = useState([]); @@ -60,7 +80,6 @@ export function ScenarioDetailPage() { setScenario(s); setScenarioCreds(s.scenarioCredentials ?? []); }) - .catch((err: Error) => setError(err.message)) .finally(() => setLoading(false)); credentialsApi .list(1, 200) @@ -91,7 +110,7 @@ export function ScenarioDetailPage() { setRunning(true); try { const run = await scenarios.run(scenario.id, selectedEnvId, saveSessionFlag); - toast.success('Scenario run started'); + toast.success(t('scenarios.run_started')); navigate(`/scenarios/${id}/runs/${run.id}`); setRunModalOpen(false); setSaveSessionFlag(false); @@ -140,7 +159,7 @@ export function ScenarioDetailPage() { } }; - const handleAddCredential = async (e: React.FormEvent) => { + const handleAddCredential = async (e: SubmitEvent) => { e.preventDefault(); let valid = true; if (!addCredId) { @@ -321,7 +340,11 @@ export function ScenarioDetailPage() {
, onClick: () => navigate('/scenarios') }, + { + label: t('scenarios.title'), + icon: , + onClick: () => navigate('/scenarios'), + }, { label: scenario?.name ?? `#${id}` }, ]} /> @@ -343,7 +366,11 @@ export function ScenarioDetailPage() { {t('scenarios.action_edit')} - @@ -513,36 +540,36 @@ export function ScenarioDetailPage() { : t('scenarios.action_delete') } onClose={() => !deleting && setPendingDelete(null)} - footer={( + footer={ <> - )} + } > - {pendingDelete?.type === 'step' && 'Are you sure you want to delete this step?'} - {pendingDelete?.type === 'credential' && 'Are you sure you want to remove this credential from scenario?'} - {pendingDelete?.type === 'scenario' && 'Are you sure you want to delete this scenario?'} + {pendingDelete?.type === 'step' && t('common.confirm_delete_step')} + {pendingDelete?.type === 'credential' && t('common.confirm_remove_credential')} + {pendingDelete?.type === 'scenario' && t('common.confirm_delete_scenario')} !running && setRunModalOpen(false)} - footer={( + footer={ <> - )} + } > {envs.length === 0 ? (

{t('scenarios.run_modal_no_env')}

@@ -554,13 +581,15 @@ export function ScenarioDetailPage() { onChange={(e) => setSelectedEnvId(e.target.value)} options={envs.map((env) => ({ value: env.id, label: env.name }))} /> -
!running && setRunScenarioId(null)} - footer={( + footer={ <> - )} + } > {envs.length === 0 ? (

{t('scenarios.run_modal_no_env')}

@@ -217,13 +215,15 @@ export function ScenariosPage() { onChange={(e) => setSelectedEnvId(e.target.value)} options={envs.map((env) => ({ value: env.id, label: env.name }))} /> -
); } diff --git a/client/src/pages/snippet/CreateSnippetPage.tsx b/client/src/pages/snippet/CreateSnippetPage.tsx index f047c7c..14fcfc8 100644 --- a/client/src/pages/snippet/CreateSnippetPage.tsx +++ b/client/src/pages/snippet/CreateSnippetPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, SubmitEvent } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { X, Save, Braces } from 'lucide-react'; @@ -19,9 +19,8 @@ export function CreateSnippetPage() { const [titleError, setTitleError] = useState(''); const [codeError, setCodeError] = useState(''); const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = async (e: SubmitEvent) => { e.preventDefault(); let valid = true; if (!alias.trim()) { @@ -38,7 +37,6 @@ export function CreateSnippetPage() { } if (!valid) return; setSaving(true); - setError(null); try { const snippet = await snippets.create({ alias: alias.trim(), @@ -46,11 +44,10 @@ export function CreateSnippetPage() { description: description.trim() || undefined, code: code.trim(), }); - toast.success('Snippet created'); + toast.success(t('snippets.created')); navigate(`/snippets/${snippet.id}`); } catch (err) { const message = (err as Error).message; - setError(message); toast.error(message); } finally { setSaving(false); @@ -62,7 +59,11 @@ export function CreateSnippetPage() {
, onClick: () => navigate('/snippets') }, + { + label: t('snippets.title'), + icon: , + onClick: () => navigate('/snippets'), + }, { label: t('snippets.create_title') }, ]} /> diff --git a/client/src/pages/snippet/EditSnippetPage.tsx b/client/src/pages/snippet/EditSnippetPage.tsx index 51608bd..109b90d 100644 --- a/client/src/pages/snippet/EditSnippetPage.tsx +++ b/client/src/pages/snippet/EditSnippetPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, SubmitEvent } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { X, Save, Braces } from 'lucide-react'; @@ -23,7 +23,6 @@ export function EditSnippetPage() { const [codeError, setCodeError] = useState(''); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); useEffect(() => { if (!id) return; @@ -36,11 +35,10 @@ export function EditSnippetPage() { setDescription(s.description ?? ''); setCode(s.code); }) - .catch((err: Error) => setError(err.message)) .finally(() => setLoading(false)); }, [id]); - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = async (e: SubmitEvent) => { e.preventDefault(); let valid = true; if (!alias.trim()) { @@ -57,7 +55,6 @@ export function EditSnippetPage() { } if (!valid) return; setSaving(true); - setError(null); try { await snippets.update(id!, { alias: alias.trim(), @@ -65,11 +62,10 @@ export function EditSnippetPage() { description: description.trim() || undefined, code: code.trim(), }); - toast.success('Snippet updated'); + toast.success(t('snippets.updated')); navigate(`/snippets/${id}`); } catch (err) { const message = (err as Error).message; - setError(message); toast.error(message); } finally { setSaving(false); @@ -81,7 +77,11 @@ export function EditSnippetPage() {
, onClick: () => navigate('/snippets') }, + { + label: t('snippets.title'), + icon: , + onClick: () => navigate('/snippets'), + }, { label: snippet?.title ?? `#${id}`, onClick: () => navigate(`/snippets/${id}`), diff --git a/client/src/pages/snippet/SnippetDetailPage.tsx b/client/src/pages/snippet/SnippetDetailPage.tsx index e86fb14..963e8e2 100644 --- a/client/src/pages/snippet/SnippetDetailPage.tsx +++ b/client/src/pages/snippet/SnippetDetailPage.tsx @@ -24,7 +24,6 @@ export function SnippetDetailPage() { const navigate = useNavigate(); const [snippet, setSnippet] = useState(null); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [deleting, setDeleting] = useState(false); @@ -33,7 +32,6 @@ export function SnippetDetailPage() { snippets .get(id) .then(setSnippet) - .catch((err: Error) => setError(err.message)) .finally(() => setLoading(false)); }, [id]); @@ -66,7 +64,11 @@ export function SnippetDetailPage() {
, onClick: () => navigate('/snippets') }, + { + label: t('snippets.title'), + icon: , + onClick: () => navigate('/snippets'), + }, { label: snippet?.title ?? `#${id}` }, ]} /> @@ -141,18 +143,22 @@ export function SnippetDetailPage() { open={confirmDeleteOpen} title={t('snippets.action_delete')} onClose={() => !deleting && setConfirmDeleteOpen(false)} - footer={( + footer={ <> - - )} + } > - Are you sure you want to delete this snippet? + {t('common.confirm_delete_snippet')}
); diff --git a/client/src/pages/snippet/SnippetsPage.tsx b/client/src/pages/snippet/SnippetsPage.tsx index fd55e9f..876919c 100644 --- a/client/src/pages/snippet/SnippetsPage.tsx +++ b/client/src/pages/snippet/SnippetsPage.tsx @@ -17,7 +17,10 @@ import { } from '../../ui'; import styles from '../Page.module.css'; -function firstParagraphBlocks(markdown: string, limit = 2): { preview: string; truncated: boolean } { +function firstParagraphBlocks( + markdown: string, + limit = 2, +): { preview: string; truncated: boolean } { const blocks = markdown .split(/\n\s*\n/g) .map((block) => block.trim()) @@ -81,16 +84,17 @@ function SnippetCard({ snippet, onDelete }: { snippet: Snippet; onDelete: (id: s

{t('snippets.field_alias')}: {snippet.alias}

- {snippet.description && (() => { - const { preview, truncated } = firstParagraphBlocks(snippet.description, 2); - if (!preview) return null; - return ( - <> - - {truncated &&

...

} - - ); - })()} + {snippet.description && + (() => { + const { preview, truncated } = firstParagraphBlocks(snippet.description, 2); + if (!preview) return null; + return ( + <> + + {truncated &&

...

} + + ); + })()} ); } @@ -113,7 +117,6 @@ export function SnippetsPage() { const navigate = useNavigate(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); const [deleteId, setDeleteId] = useState(null); const [deleting, setDeleting] = useState(false); const fileInputRef = useRef(null); @@ -122,7 +125,6 @@ export function SnippetsPage() { snippets .list() .then((res) => setItems(res.data)) - .catch((err: Error) => setError(err.message)) .finally(() => setLoading(false)); }; @@ -155,8 +157,8 @@ export function SnippetsPage() { const payload = yamlParse(text) as unknown; const imported = await snippets.importSnippet(payload); navigate(`/snippets/${imported.id}`); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + } catch { + // Import errors are silently ignored } }; @@ -195,18 +197,18 @@ export function SnippetsPage() { open={deleteId != null} title={t('snippets.action_delete')} onClose={() => !deleting && setDeleteId(null)} - footer={( + footer={ <> - )} + } > - Are you sure you want to delete this snippet? + {t('common.confirm_delete_snippet')}
); diff --git a/client/src/ui/Breadcrumbs/Breadcrumbs.tsx b/client/src/ui/Breadcrumbs/Breadcrumbs.tsx index d951819..f89cc22 100644 --- a/client/src/ui/Breadcrumbs/Breadcrumbs.tsx +++ b/client/src/ui/Breadcrumbs/Breadcrumbs.tsx @@ -36,15 +36,18 @@ export function Breadcrumbs({ items, separator, className }: BreadcrumbsProps) {
  • {isLast ? ( - {item.icon}{item.label} + {item.icon} + {item.label} ) : item.href ? ( - {item.icon}{item.label} + {item.icon} + {item.label} ) : ( )} {!isLast && {sep}} diff --git a/client/src/ui/Toast/ToastProvider.tsx b/client/src/ui/Toast/ToastProvider.tsx index 70a6b7e..63b5a0c 100644 --- a/client/src/ui/Toast/ToastProvider.tsx +++ b/client/src/ui/Toast/ToastProvider.tsx @@ -1,9 +1,8 @@ -import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, 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'; +import { ToastContext, type ToastApi, type ToastVariant } from './toast-context'; interface ToastItem { id: number; @@ -12,15 +11,6 @@ interface ToastItem { closing: boolean; } -interface ToastApi { - show: (message: string, variant?: ToastVariant) => void; - success: (message: string) => void; - error: (message: string) => void; - info: (message: string) => void; -} - -const ToastContext = createContext(null); - const ICONS: Record = { success: , error: , @@ -41,33 +31,38 @@ export function ToastProvider({ children, durationMs = 3500 }: ToastProviderProp setItems((prev) => prev.filter((item) => item.id !== id)); }, []); - const dismiss = useCallback((id: number) => { - setItems((prev) => - prev.map((item) => - item.id === id ? { ...item, closing: true } : item, - ), - ); - window.setTimeout(() => remove(id), EXIT_ANIMATION_MS); - }, [remove]); + const dismiss = useCallback( + (id: number) => { + setItems((prev) => prev.map((item) => (item.id === id ? { ...item, closing: true } : item))); + window.setTimeout(() => remove(id), EXIT_ANIMATION_MS); + }, + [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 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); - }, [dismiss, durationMs]); + const id = Date.now() + Math.floor(Math.random() * 1000); + setItems((prev) => [...prev, { id, message, variant, closing: false }]); + window.setTimeout(() => dismiss(id), durationMs); + }, + [dismiss, durationMs], + ); - const api = useMemo(() => ({ - show, - success: (message: string) => show(message, 'success'), - error: (message: string) => show(message, 'error'), - info: (message: string) => show(message, 'info'), - }), [show]); + const api = useMemo( + () => ({ + show, + success: (message: string) => show(message, 'success'), + error: (message: string) => show(message, 'error'), + info: (message: string) => show(message, 'info'), + }), + [show], + ); useEffect(() => { return subscribeApiErrorToasts((message) => api.error(message)); @@ -86,7 +81,9 @@ export function ToastProvider({ children, durationMs = 3500 }: ToastProviderProp role="status" >
    - +

    {item.message}