From b56be3c52dc1235e1034e416523c1509fc448bc0 Mon Sep 17 00:00:00 2001 From: VitekHub Date: Tue, 7 Jul 2026 17:35:20 +0200 Subject: [PATCH 1/2] feat: add error boundaries, loading states, and 404 page --- CLAUDE.md | 6 +- README.md | 39 +++++----- docs/implementation-plan/09-phase-9-polish.md | 26 +++---- docs/implementation-plan/README.md | 2 +- src/app/AppErrorBoundary.test.tsx | 56 +++++++++++++++ src/app/AppErrorBoundary.tsx | 72 +++++++++++++++++++ src/app/ErrorBoundary.test.tsx | 38 ---------- src/app/ErrorBoundary.tsx | 59 --------------- src/app/NotFoundPage.tsx | 10 +++ src/app/RouteErrorBoundary.test.tsx | 71 ++++++++++++++++++ src/app/RouteErrorBoundary.tsx | 50 +++++++++++++ src/app/layouts/VaultLockButton.tsx | 5 +- src/app/router.tsx | 4 +- src/app/routes/__root.tsx | 6 +- src/app/routes/_authenticated.dashboard.tsx | 13 ++-- .../_authenticated.dashboard_.$entryId.tsx | 11 ++- src/features/auth/ui/UsernameAvailability.tsx | 5 +- .../fields/model/field-error-messages.ts | 24 +++++++ src/features/fields/model/use-field-editor.ts | 10 ++- .../fields/model/use-realtime-sync.test.ts | 17 +++-- .../fields/model/use-realtime-sync.ts | 2 +- src/features/fields/ui/DashboardPage.test.tsx | 37 ++++++++++ src/features/fields/ui/DashboardPage.tsx | 26 ++++++- .../fields/ui/RotateFieldKeyDialog.tsx | 2 + src/features/fields/ui/SaveIndicator.tsx | 5 +- .../vault/model/use-vault-timeout.test.ts | 8 +++ src/features/vault/model/use-vault-timeout.ts | 6 +- src/main.tsx | 5 +- src/shared/i18n/locales/cs/common.json | 7 +- src/shared/i18n/locales/cs/entries.json | 5 +- src/shared/i18n/locales/cs/fields.json | 3 + src/shared/i18n/locales/cs/vault.json | 3 +- src/shared/i18n/locales/en/common.json | 7 +- src/shared/i18n/locales/en/entries.json | 5 +- src/shared/i18n/locales/en/fields.json | 3 + src/shared/i18n/locales/en/vault.json | 3 +- src/shared/ui/ErrorState.test.tsx | 58 +++++++++++++++ src/shared/ui/ErrorState.tsx | 56 +++++++++++++++ src/shared/ui/Spinner.test.tsx | 35 +++++++++ src/shared/ui/Spinner.tsx | 23 ++++++ 40 files changed, 658 insertions(+), 165 deletions(-) create mode 100644 src/app/AppErrorBoundary.test.tsx create mode 100644 src/app/AppErrorBoundary.tsx delete mode 100644 src/app/ErrorBoundary.test.tsx delete mode 100644 src/app/ErrorBoundary.tsx create mode 100644 src/app/NotFoundPage.tsx create mode 100644 src/app/RouteErrorBoundary.test.tsx create mode 100644 src/app/RouteErrorBoundary.tsx create mode 100644 src/features/fields/model/field-error-messages.ts create mode 100644 src/shared/ui/ErrorState.test.tsx create mode 100644 src/shared/ui/ErrorState.tsx create mode 100644 src/shared/ui/Spinner.test.tsx create mode 100644 src/shared/ui/Spinner.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 076cf3b..0e0aff8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,7 @@ Field Keys (one per field) → wrapped by KEK with AAD(fieldName, version) ### App Hierarchy ``` -main.tsx → AppProviders (QueryClientProvider > AuthProvider > RouterProvider) +main.tsx → AppErrorBoundary → AppProviders (QueryClientProvider > AuthProvider > RouterProvider) → __root.tsx (ThemeProvider + Toaster) → _public (redirects to /dashboard if authed — guard in route beforeLoad) → /login, /register, /recover @@ -190,11 +190,15 @@ See `docs/implementation-plan/README.md` for the full 36-step plan. - Step 30 (Regenerate Seed Phrase) — complete - Step 31 (Seed Phrase Recovery Flow + UI) — complete - Step 32 (Key Rotation + UI) — complete +- Step 33 (Mobile Responsive Refinements) — complete +- Step 34 (Loading States, Error Boundaries, Toast Notifications) — complete ### Implementation Notes Non-obvious decisions not visible from code alone: +- **Two error boundaries must stay separate**: `AppErrorBoundary` wraps `` in `main.tsx` and renders a dependency-free fallback (no i18n, no theme, inline styles, hardcoded English). `RouteErrorBoundary` is the router's error component and uses i18n/theme/`mapErrorToMessage`. They cannot be merged because `AppErrorBoundary` must work when providers themselves are broken. +- **Field query error display avoids "empty but looks loaded" flash**: `useFieldEditor` returns the full `fieldQuery` (TanStack Query result) instead of picking off `error`/`refetch` separately. When a field errors and the user clicks retry, TanStack Query clears `error` and sets `isFetching: true`. Without checking `isFetching`, the UI would briefly show empty field editors that look correctly loaded. Checking `fieldQuery.isFetching` shows a spinner during refetch instead. - **Auth store `isRestoringSession`**: defaults `true`; `reset()` does NOT touch it (logout doesn't re-trigger initialization) - **Auto-lock**: `useVaultTimeout` hook in ProtectedLayout resets a 15-minute inactivity timer on user activity (mousemove, keydown, mousedown, touchstart, scroll); calls `lockVault()` on expiry - **VaultUnlockDialog**: uses a separate `vault-dialog-store` (in `features/vault/model/vault-dialog-store.ts`) created via `createDialogStore` factory. The dialog can be opened/closed independently of vault lock state. This lets the user dismiss the dialog without unlocking, and lets the sidebar/mobile nav trigger `open()` directly diff --git a/README.md b/README.md index 05640ee..7a69c95 100644 --- a/README.md +++ b/README.md @@ -84,24 +84,27 @@ Dependency direction: `routes -> features -> shared`. No cross-feature imports. │ │──▶ Tailwind CSS └─────┬─────┘ ▼ -┌───────────────────┐ -│ AppProviders │ -│ ┌──────────────┐ │ -│ │ QueryClient │ │ -│ │ ┌──────────┐ │ │ -│ │ │ Auth │ │ │ -│ │ │ ┌──────┐ │ │ │ -│ │ │ │Router│ │ │ │ -│ │ │ └──┬───┘ │ │ │ -│ │ └────┬─────┘ │ │ -│ └──────┼───────┘ │ -└─────────┼─────────┘ - ▼ - ┌────────────┐ - │ __root │──▶ ThemeProvider + Toaster - └─────┬──────┘ - ┌───┴───┐ - ▼ ▼ +┌───────────────────────┐ +│ AppErrorBoundary │ ← catches provider/router crashes +│ ┌───────────────────┐ │ +│ │ AppProviders │ │ +│ │ ┌──────────────┐ │ │ +│ │ │ QueryClient │ │ │ +│ │ │ ┌──────────┐ │ │ │ +│ │ │ │ Auth │ │ │ │ +│ │ │ │ ┌──────┐ │ │ │ │ +│ │ │ │ │Router│ │ │ │ │ +│ │ │ │ └──┬───┘ │ │ │ │ +│ │ │ └────┼─────┘ │ │ │ +│ │ └──────┼───────┘ │ │ +│ └─────────┼─────────┘ │ +└───────────┼───────────┘ + ▼ + ┌────────────┐ + │ __root │────▶ ThemeProvider + Toaster + └─────┬──────┘ + ┌───┴───┐ + ▼ ▼ _public _authenticated (guest) (logged in) │ │ diff --git a/docs/implementation-plan/09-phase-9-polish.md b/docs/implementation-plan/09-phase-9-polish.md index 45f5c7f..e957c96 100644 --- a/docs/implementation-plan/09-phase-9-polish.md +++ b/docs/implementation-plan/09-phase-9-polish.md @@ -1,6 +1,6 @@ # Phase 9: Polish -## Step 33 — Mobile Responsive Refinements +## Step 33 — Mobile Responsive Refinements ✅ **Goal:** Ensure all pages work well on mobile viewports. @@ -24,29 +24,31 @@ --- -## Step 34 — Loading States, Error Boundaries, Toast Notifications +## Step 34 — Loading States, Error Boundaries, Toast Notifications ✅ **Goal:** Professional UX for loading, errors, and notifications. **Code:** - Loading states: - - Skeleton loaders for field content while fetching + - Spinner component for loading states (field content, crypto operations) - Spinner during Argon2id derivation (login, register, password change) - Spinner during key wrapping/unwrapping operations - - "Saving..." indicator on field auto-save -- Error boundaries (enhance the root error boundary from Step 3): - - Crypto errors: "Decryption failed. Your data may be corrupted." with support link - - Network errors: "Connection lost. Changes will sync when reconnected." + - Inline save status indicator for field auto-save (saving/saved/error with retry) +- Error boundaries (two separate boundaries): + - App-level boundary above providers: dependency-free fallback (no i18n, no theme, inline styles, hardcoded English) + - Route-level boundary inside router: uses i18n, theme, and shared error-to-message mapper + - Shared `ErrorState` component for inline error display with retry and "Go home" actions + - Crypto errors: "Decryption failed. Your data may be corrupted.", "Corrupted data" + - Network errors: "Network error. Please try again." - Route-level Suspense fallbacks already in place from Step 3 -- Toast notifications: - - Success: "Saved", "Password changed", "Key rotated" - - Error: "Save failed — retrying", "Wrong password" - - Warning: "Vault locked due to inactivity", "Remote change detected" +- Notifications: + - Inline status indicators for save state (saving/saved/paused/error) + - Toast for vault lock due to inactivity and other one-time events - Use shadcn `Toaster` component - i18n strings for all error/success messages **Tests:** -- Component test: skeleton loaders shown during loading +- Component test: spinners shown during loading - Component test: error boundary catches and displays error - Component test: toast notifications appear for success/error - Integration: network error → error toast → retry → success toast diff --git a/docs/implementation-plan/README.md b/docs/implementation-plan/README.md index 644fac8..7ff038a 100644 --- a/docs/implementation-plan/README.md +++ b/docs/implementation-plan/README.md @@ -71,7 +71,7 @@ This is the implementation plan for Cipher Note, an end-to-end encrypted note-ta - [x] Step 31 — Seed Phrase Recovery Flow + UI - [x] Step 32 — Key Rotation + UI - [x] Step 33 — Mobile Responsive Refinements -- [ ] Step 34 — Loading States, Error Boundaries, Toast Notifications +- [x] Step 34 — Loading States, Error Boundaries, Toast Notifications - [ ] Step 35 — Security Hardening - [ ] Step 36 — E2E Tests (Playwright) diff --git a/src/app/AppErrorBoundary.test.tsx b/src/app/AppErrorBoundary.test.tsx new file mode 100644 index 0000000..8db453f --- /dev/null +++ b/src/app/AppErrorBoundary.test.tsx @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { AppErrorBoundary } from '@/app/AppErrorBoundary' + +function Boom(): never { + throw new Error('kaboom') +} + +describe('AppErrorBoundary', () => { + let errorSpy: ReturnType + let reloadSpy: ReturnType + + beforeEach(() => { + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + reloadSpy = vi.fn() + Object.defineProperty(window, 'location', { + value: { reload: reloadSpy }, + writable: true, + }) + }) + + afterEach(() => { + errorSpy.mockRestore() + }) + + it('renders children when no error', () => { + render( + + ok content + , + ) + expect(screen.getByText('ok content')).toBeInTheDocument() + }) + + it('renders fallback when a child throws', () => { + render( + + + , + ) + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + expect(screen.queryByText('ok content')).toBeNull() + expect(errorSpy).toHaveBeenCalled() + }) + + it('reloads the page when the reload button is clicked', async () => { + render( + + + , + ) + await userEvent.click(screen.getByRole('button', { name: 'Reload' })) + expect(reloadSpy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/app/AppErrorBoundary.tsx b/src/app/AppErrorBoundary.tsx new file mode 100644 index 0000000..682aa8d --- /dev/null +++ b/src/app/AppErrorBoundary.tsx @@ -0,0 +1,72 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react' + +interface AppErrorBoundaryProps { + children: ReactNode +} + +interface AppErrorBoundaryState { + error: Error | null +} + +/** + * Top-level safety net above the provider tree. Catches render errors thrown + * by providers or anywhere below them that the TanStack Router boundary does + * not. Renders a minimal, dependency-free fallback (no i18n, no theme tokens) + * so it still works when providers/i18n themselves have crashed. + */ +class AppErrorBoundary extends Component { + state: AppErrorBoundaryState = { error: null } + + static getDerivedStateFromError(error: unknown): AppErrorBoundaryState { + return { error: error instanceof Error ? error : new Error(String(error)) } + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + console.error('Uncaught application error', error, info) + } + + handleReload = (): void => { + window.location.reload() + } + + render(): ReactNode { + if (this.state.error) { + return ( +
+
+

Something went wrong

+

+ An unexpected error occurred. Reloading the page usually fixes it. +

+ +
+
+ ) + } + + return this.props.children + } +} + +export { AppErrorBoundary } diff --git a/src/app/ErrorBoundary.test.tsx b/src/app/ErrorBoundary.test.tsx deleted file mode 100644 index ad6a9c4..0000000 --- a/src/app/ErrorBoundary.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { CryptoError, DecryptionError, CorruptedDataError } from '@/shared/crypto/core/errors' - -describe('CryptoError classes', () => { - it('CryptoError has correct name', () => { - const error = new CryptoError('test error') - expect(error.name).toBe('CryptoError') - expect(error.message).toBe('test error') - expect(error).toBeInstanceOf(Error) - expect(error).toBeInstanceOf(CryptoError) - }) - - it('DecryptionError extends CryptoError', () => { - const error = new DecryptionError() - expect(error.name).toBe('DecryptionError') - expect(error.message).toBe('vault:errors.decryptFailed') - expect(error).toBeInstanceOf(CryptoError) - expect(error).toBeInstanceOf(Error) - }) - - it('DecryptionError accepts custom message', () => { - const error = new DecryptionError('custom message') - expect(error.message).toBe('custom message') - }) - - it('CorruptedDataError extends CryptoError', () => { - const error = new CorruptedDataError() - expect(error.name).toBe('CorruptedDataError') - expect(error.message).toBe('vault:errors.corruptedData') - expect(error).toBeInstanceOf(CryptoError) - expect(error).toBeInstanceOf(Error) - }) - - it('CorruptedDataError accepts custom message', () => { - const error = new CorruptedDataError('custom message') - expect(error.message).toBe('custom message') - }) -}) diff --git a/src/app/ErrorBoundary.tsx b/src/app/ErrorBoundary.tsx deleted file mode 100644 index b26c3d9..0000000 --- a/src/app/ErrorBoundary.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { useTranslation } from 'react-i18next' -import type { ErrorComponentProps } from '@tanstack/react-router' -import { Button } from '@/shared/ui/button' -import { Argon2Error, CorruptedDataError, DecryptionError, MnemonicError } from '@/shared/crypto/core/errors' -import { AuthError, AuthErrorCode } from '@/shared/auth/auth-errors' -import { ApiError, ApiErrorCode } from '@/shared/api/api-errors' - -/** Crypto errors with fixed descriptions — add new ones here instead of extending the if-chain. */ -const CRYPTO_ERRORS: readonly (readonly [new () => Error, string])[] = [ - [DecryptionError, 'vault:errors.decryptFailed'], - [CorruptedDataError, 'vault:errors.corruptedData'], - [Argon2Error, 'vault:errors.argon2Failed'], - [MnemonicError, 'auth:errors.mnemonicFailed'], -] - -function getErrorMessage(error: Error): { title: string; description: string } { - for (const [ErrorClass, description] of CRYPTO_ERRORS) { - if (error instanceof ErrorClass) { - return { title: 'common:status.error', description } - } - } - - if (error instanceof AuthError) { - return error.code === AuthErrorCode.NETWORK_ERROR - ? { title: 'common:status.error', description: 'common:errors.networkError' } - : { title: 'common:status.error', description: 'common:errors.unexpectedError' } - } - - if (error instanceof ApiError) { - return error.code === ApiErrorCode.NETWORK_ERROR - ? { title: 'common:status.error', description: 'common:errors.networkError' } - : { title: 'common:status.error', description: 'common:errors.unexpectedError' } - } - - return { - title: 'common:status.error', - description: error.message || 'common:status.error', - } -} - -function RootErrorBoundary({ error }: ErrorComponentProps) { - const { t } = useTranslation() - const err = error instanceof Error ? error : new Error(String(error)) - const { title, description } = getErrorMessage(err) - - return ( -
-
-

{t(title)}

-

{description.includes(':') ? t(description) : description}

- -
-
- ) -} - -export { RootErrorBoundary } diff --git a/src/app/NotFoundPage.tsx b/src/app/NotFoundPage.tsx new file mode 100644 index 0000000..d25966a --- /dev/null +++ b/src/app/NotFoundPage.tsx @@ -0,0 +1,10 @@ +import { useTranslation } from 'react-i18next' +import { ErrorState } from '@/shared/ui/ErrorState' + +function NotFoundPage() { + const { t } = useTranslation('common') + + return +} + +export { NotFoundPage } diff --git a/src/app/RouteErrorBoundary.test.tsx b/src/app/RouteErrorBoundary.test.tsx new file mode 100644 index 0000000..7842c81 --- /dev/null +++ b/src/app/RouteErrorBoundary.test.tsx @@ -0,0 +1,71 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@/test/utils' +import { CryptoError, DecryptionError, CorruptedDataError } from '@/shared/crypto/core/errors' +import { AuthError, AuthErrorCode } from '@/shared/auth/auth-errors' +import { RouteErrorBoundary } from '@/app/RouteErrorBoundary' + +describe('CryptoError classes', () => { + it('CryptoError has correct name', () => { + const error = new CryptoError('test error') + expect(error.name).toBe('CryptoError') + expect(error.message).toBe('test error') + expect(error).toBeInstanceOf(Error) + expect(error).toBeInstanceOf(CryptoError) + }) + + it('DecryptionError extends CryptoError', () => { + const error = new DecryptionError() + expect(error.name).toBe('DecryptionError') + expect(error.message).toBe('vault:errors.decryptFailed') + expect(error).toBeInstanceOf(CryptoError) + expect(error).toBeInstanceOf(Error) + }) + + it('DecryptionError accepts custom message', () => { + const error = new DecryptionError('custom message') + expect(error.message).toBe('custom message') + }) + + it('CorruptedDataError extends CryptoError', () => { + const error = new CorruptedDataError() + expect(error.name).toBe('CorruptedDataError') + expect(error.message).toBe('vault:errors.corruptedData') + expect(error).toBeInstanceOf(CryptoError) + expect(error).toBeInstanceOf(Error) + }) + + it('CorruptedDataError accepts custom message', () => { + const error = new CorruptedDataError('custom message') + expect(error.message).toBe('custom message') + }) +}) + +describe('RouteErrorBoundary', () => { + const reset = vi.fn() + + it('renders the title and a retry button', () => { + render() + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + }) + + it('maps DecryptionError to the corruption copy', () => { + render() + expect(screen.getByText('Decryption failed. Your data may be corrupted.')).toBeInTheDocument() + }) + + it('maps a network AuthError to the network copy', () => { + render() + expect(screen.getByText('Network error. Please try again.')).toBeInTheDocument() + }) + + it('maps an unknown error to the unexpected copy', () => { + render() + expect(screen.getByText('An unexpected error occurred. Please try again.')).toBeInTheDocument() + }) + + it('accepts a non-Error value via coercion', () => { + render() + expect(screen.getByText('An unexpected error occurred. Please try again.')).toBeInTheDocument() + }) +}) diff --git a/src/app/RouteErrorBoundary.tsx b/src/app/RouteErrorBoundary.tsx new file mode 100644 index 0000000..06b561b --- /dev/null +++ b/src/app/RouteErrorBoundary.tsx @@ -0,0 +1,50 @@ +import { useTranslation } from 'react-i18next' +import type { ErrorComponentProps } from '@tanstack/react-router' +import { Button } from '@/shared/ui/button' +import { Argon2Error, CorruptedDataError, DecryptionError, MnemonicError } from '@/shared/crypto/core/errors' +import { AuthErrorCode } from '@/shared/auth/auth-errors' +import { ApiErrorCode } from '@/shared/api/api-errors' +import { mapErrorToMessage, type ErrorKeySpec } from '@/shared/lib/error-messages' + +/** + * Spec for fatal route errors. Crypto errors get their dedicated copy; network + * errors get a network message; everything else falls back to the generic + * unexpected-error copy. Reused via the shared `mapErrorToMessage` so this + * stays in sync with the per-feature mappers. + */ +const ROUTE_ERROR_SPEC: ErrorKeySpec = { + instanceChecks: [ + [DecryptionError, 'vault:errors.decryptFailed'], + [CorruptedDataError, 'vault:errors.corruptedData'], + [Argon2Error, 'vault:errors.argon2Failed'], + [MnemonicError, 'auth:errors.mnemonicFailed'], + ], + authCodes: { + [AuthErrorCode.NETWORK_ERROR]: 'common:errors.networkError', + }, + apiCodes: { + [ApiErrorCode.NETWORK_ERROR]: 'common:errors.networkError', + }, + networkKey: 'common:errors.networkError', + fallbackKey: 'common:errors.unexpectedError', +} + +function RouteErrorBoundary({ error }: ErrorComponentProps) { + const { t } = useTranslation() + const err = error instanceof Error ? error : new Error(String(error)) + const description = mapErrorToMessage(err, t, ROUTE_ERROR_SPEC) + + return ( +
+
+

{t('common:status.error')}

+

{description}

+ +
+
+ ) +} + +export { RouteErrorBoundary, ROUTE_ERROR_SPEC } diff --git a/src/app/layouts/VaultLockButton.tsx b/src/app/layouts/VaultLockButton.tsx index 2ded7cd..4077255 100644 --- a/src/app/layouts/VaultLockButton.tsx +++ b/src/app/layouts/VaultLockButton.tsx @@ -1,9 +1,10 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { Lock, Unlock, Loader2 } from 'lucide-react' +import { Lock, Unlock } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/shared/ui/button' +import { Spinner } from '@/shared/ui/Spinner' import { keyVault } from '@/shared/crypto/vault/key-vault' import { useCryptoStore } from '@/shared/crypto/vault/crypto-store' import { useSyncStatusStore, isSaving, isPaused } from '@/features/fields/model/sync-status-store' @@ -50,7 +51,7 @@ function VaultLockButton({ variant, onBeforeToggle, className }: VaultLockButton } function renderLockIcon(size: string) { - if (isLocking) return + if (isLocking) return if (isVaultLocked) return return } diff --git a/src/app/router.tsx b/src/app/router.tsx index 695ffbb..d17b13a 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -1,7 +1,7 @@ import { createRouter } from '@tanstack/react-router' import { routeTree } from './routeTree.gen' import { PageSkeleton } from '@/app/Pending' -import { RootErrorBoundary } from '@/app/ErrorBoundary' +import { RouteErrorBoundary } from '@/app/RouteErrorBoundary' import type { AuthContext } from '@/shared/auth/auth-context' function createAppRouter(auth: AuthContext) { @@ -10,7 +10,7 @@ function createAppRouter(auth: AuthContext) { basepath: import.meta.env.BASE_URL.replace(/\/$/, '') || '/', context: { auth }, defaultPendingComponent: PageSkeleton, - defaultErrorComponent: RootErrorBoundary, + defaultErrorComponent: RouteErrorBoundary, defaultPendingMs: 200, defaultPendingMinMs: 300, }) diff --git a/src/app/routes/__root.tsx b/src/app/routes/__root.tsx index e10d6ac..aa03d90 100644 --- a/src/app/routes/__root.tsx +++ b/src/app/routes/__root.tsx @@ -2,7 +2,8 @@ import { createRootRouteWithContext, Outlet } from '@tanstack/react-router' import { ThemeProvider } from '@/shared/lib/theme-provider' import { Toaster } from '@/shared/ui/sonner' import { PageSkeleton } from '@/app/Pending' -import { RootErrorBoundary } from '@/app/ErrorBoundary' +import { RouteErrorBoundary } from '@/app/RouteErrorBoundary' +import { NotFoundPage } from '@/app/NotFoundPage' import { PreAlphaBanner } from '@/shared/ui/PreAlphaBanner' import type { AuthContext } from '@/shared/auth/auth-context' @@ -12,7 +13,8 @@ interface RouterContext { export const Route = createRootRouteWithContext()({ component: RootLayout, - errorComponent: RootErrorBoundary, + errorComponent: RouteErrorBoundary, + notFoundComponent: NotFoundPage, pendingComponent: PageSkeleton, }) diff --git a/src/app/routes/_authenticated.dashboard.tsx b/src/app/routes/_authenticated.dashboard.tsx index 8fdaefd..3e6f3d6 100644 --- a/src/app/routes/_authenticated.dashboard.tsx +++ b/src/app/routes/_authenticated.dashboard.tsx @@ -1,16 +1,21 @@ -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute, useNavigate } from '@tanstack/react-router' import { useEntries, useCreateEntry } from '@/features/fields/model/use-entry' import { EmptyState, DashboardWelcome } from '@/features/fields/ui/DashboardPage' import { LockedVaultCard } from '@/features/vault/ui/LockedVaultCard' -import { useNavigate } from '@tanstack/react-router' +import { DashboardSkeleton } from '@/app/Pending' +import { ErrorState } from '@/shared/ui/ErrorState' function DashboardIndex() { - const { data: entries, isLoading } = useEntries() + const { data: entries, isLoading, isError, refetch } = useEntries() const createEntry = useCreateEntry() const navigate = useNavigate() - if (isLoading) return null + if (isLoading) return + if (isError) + return ( + void refetch()} /> + ) if (!entries || entries.length === 0) { return ( diff --git a/src/app/routes/_authenticated.dashboard_.$entryId.tsx b/src/app/routes/_authenticated.dashboard_.$entryId.tsx index fa32623..7d9ad93 100644 --- a/src/app/routes/_authenticated.dashboard_.$entryId.tsx +++ b/src/app/routes/_authenticated.dashboard_.$entryId.tsx @@ -1,16 +1,19 @@ import { createFileRoute } from '@tanstack/react-router' +import type { ErrorComponentProps } from '@tanstack/react-router' import { EntryDetailPage } from '@/features/fields/ui/DashboardPage' import { LockedVaultCard } from '@/features/vault/ui/LockedVaultCard' import { useEntryStatus } from '@/features/fields/model/use-entry-status' import { ENTRY_STATUS } from '@/features/fields/model/entry-status' import { EntryStatusBanner } from '@/features/fields/ui/EntryStatusBanner' +import { DashboardSkeleton } from '@/app/Pending' +import { ErrorState } from '@/shared/ui/ErrorState' function EntryDetailRoute() { const { entryId } = Route.useParams() const entryStatus = useEntryStatus(entryId) - if (entryStatus === ENTRY_STATUS.LOADING) return null + if (entryStatus === ENTRY_STATUS.LOADING) return return (
@@ -22,8 +25,14 @@ function EntryDetailRoute() { ) } +function EntryDetailError({ error }: ErrorComponentProps) { + void error + return +} + const Route = createFileRoute('/_authenticated/dashboard_/$entryId')({ component: EntryDetailRoute, + errorComponent: EntryDetailError, }) export { Route } diff --git a/src/features/auth/ui/UsernameAvailability.tsx b/src/features/auth/ui/UsernameAvailability.tsx index 5015ab2..631368f 100644 --- a/src/features/auth/ui/UsernameAvailability.tsx +++ b/src/features/auth/ui/UsernameAvailability.tsx @@ -1,5 +1,6 @@ import { useTranslation } from 'react-i18next' -import { Loader2, Check, X } from 'lucide-react' +import { Check, X } from 'lucide-react' +import { Spinner } from '@/shared/ui/Spinner' import { cn } from '@/shared/lib/utils' import type { UsernameAvailabilityStatus } from '@/features/auth/model/use-username-availability' @@ -22,7 +23,7 @@ function UsernameAvailability({ status }: UsernameAvailabilityProps) { status === 'checking' && 'text-muted-foreground', )} > - {status === 'checking' && } + {status === 'checking' && } {status === 'available' && } {status === 'taken' && } {status === 'checking' && t('register.checkingUsername')} diff --git a/src/features/fields/model/field-error-messages.ts b/src/features/fields/model/field-error-messages.ts new file mode 100644 index 0000000..843daa9 --- /dev/null +++ b/src/features/fields/model/field-error-messages.ts @@ -0,0 +1,24 @@ +import type { TFunction } from 'i18next' +import { DecryptionError, CorruptedDataError } from '@/shared/crypto/core/errors' +import { mapErrorToMessage, type ErrorKeySpec } from '@/shared/lib/error-messages' + +/** + * Error spec for field-load failures. A `DecryptionError` means the field's + * ciphertext is corrupted or the field key is wrong; everything else is a + * load failure (network, RPC error, etc.). + */ +const FIELD_LOAD_ERROR_SPEC: ErrorKeySpec = { + instanceChecks: [ + [DecryptionError, 'vault:errors.decryptFailed'], + [CorruptedDataError, 'vault:errors.corruptedData'], + ], + networkKey: 'common:errors.networkError', + fallbackKey: 'fields:errors.loadFailed', +} + +/** Maps a field-load error to a user-facing i18n string. */ +export function getFieldLoadErrorMessage(error: unknown, t: TFunction): string { + return mapErrorToMessage(error, t, FIELD_LOAD_ERROR_SPEC) +} + +export { FIELD_LOAD_ERROR_SPEC } diff --git a/src/features/fields/model/use-field-editor.ts b/src/features/fields/model/use-field-editor.ts index 9fe6cc6..f0e37da 100644 --- a/src/features/fields/model/use-field-editor.ts +++ b/src/features/fields/model/use-field-editor.ts @@ -13,6 +13,7 @@ export interface UseFieldEditorResult { fieldSyncStatus: SyncStatus retrySave: () => void isOfflineAwaitingData: boolean + fieldQuery: ReturnType } /** @@ -98,7 +99,14 @@ function useFieldEditor(entryId: string, fieldName: FieldName): UseFieldEditorRe const fieldValue = isVaultLocked ? '' : (draft ?? fieldQuery.data ?? '') const isOfflineAwaitingData = fieldQuery.isPaused && !fieldQuery.data - return { fieldValue, saveFieldValue, fieldSyncStatus: syncStatus, retrySave, isOfflineAwaitingData } + return { + fieldValue, + saveFieldValue, + fieldSyncStatus: syncStatus, + retrySave, + isOfflineAwaitingData, + fieldQuery, + } } export { useFieldEditor } diff --git a/src/features/fields/model/use-realtime-sync.test.ts b/src/features/fields/model/use-realtime-sync.test.ts index 73ece5c..a51795e 100644 --- a/src/features/fields/model/use-realtime-sync.test.ts +++ b/src/features/fields/model/use-realtime-sync.test.ts @@ -10,7 +10,7 @@ const ctx = vi.hoisted(() => { const mockSubscribe = vi.fn<(userId: string, callbacks: import('@/shared/realtime/realtime.types').RealtimeCallbacks) => Promise>() const mockUnsubscribe = vi.fn<() => void>() - const toastInfo = vi.fn<(msg: string, options?: unknown) => string | number>() + const toastWarning = vi.fn<(msg: string, options?: unknown) => string | number>() const toastSuccess = vi.fn<(msg: string, options?: unknown) => string | number>() const toastError = vi.fn<(msg: string, options?: unknown) => string | number>() const mockSyncFieldKeys = vi.fn<(userId: string) => Promise>() @@ -20,7 +20,7 @@ const ctx = vi.hoisted(() => { callbacksRef, mockSubscribe, mockUnsubscribe, - toastInfo, + toastWarning, toastSuccess, toastError, mockSyncFieldKeys, @@ -38,7 +38,7 @@ vi.mock('@/shared/auth/use-current-user', () => ({ })) vi.mock('sonner', () => ({ - toast: { info: ctx.toastInfo, success: ctx.toastSuccess, error: ctx.toastError }, + toast: { warning: ctx.toastWarning, success: ctx.toastSuccess, error: ctx.toastError }, })) vi.mock('@/shared/crypto/vault/key-vault', () => ({ @@ -129,7 +129,7 @@ describe('useRealtimeSync', () => { callbacks().onFieldChange(FIELD_EVENT) expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.field.detail('e1', 'note') }) - expect(ctx.toastInfo).not.toHaveBeenCalled() + expect(ctx.toastWarning).not.toHaveBeenCalled() }) it('sets remote-update status and invalidates for genuine remote changes', () => { @@ -152,7 +152,7 @@ describe('useRealtimeSync', () => { // Echo should be suppressed entirely — no invalidate, no status, no toast expect(invalidateSpy).not.toHaveBeenCalled() expect(useSyncStatusStore.getState().status['e1']?.['note'] ?? SYNC_STATUS.IDLE).toBe(SYNC_STATUS.IDLE) - expect(ctx.toastInfo).not.toHaveBeenCalled() + expect(ctx.toastWarning).not.toHaveBeenCalled() }) it('does not suppress when timestamps differ (not an echo)', () => { @@ -199,6 +199,9 @@ describe('useRealtimeSync', () => { // Conflict: no invalidate, no remote-update status expect(invalidateSpy).not.toHaveBeenCalled() expect(useSyncStatusStore.getState().status['e1']?.['note'] ?? SYNC_STATUS.IDLE).toBe(SYNC_STATUS.IDLE) + // Conflict toast is shown as a warning + expect(ctx.toastWarning).toHaveBeenCalledTimes(1) + expect(ctx.toastWarning.mock.calls[0][0]).toEqual(expect.any(String)) }) it('does not treat a pending save for a different field as a conflict', async () => { @@ -212,7 +215,7 @@ describe('useRealtimeSync', () => { callbacks().onFieldChange({ ...FIELD_EVENT, fieldName: 'title' }) expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.field.detail('e1', 'title') }) - expect(ctx.toastInfo).not.toHaveBeenCalled() + expect(ctx.toastWarning).not.toHaveBeenCalled() }) it('invalidates the entries list on a remote entry change', () => { @@ -232,7 +235,7 @@ describe('useRealtimeSync', () => { expect(invalidateSpy).not.toHaveBeenCalled() expect(useSyncStatusStore.getState().status['e1']?.['note'] ?? SYNC_STATUS.IDLE).toBe(SYNC_STATUS.IDLE) - expect(ctx.toastInfo).not.toHaveBeenCalled() + expect(ctx.toastWarning).not.toHaveBeenCalled() }) it('skips onEntryChange when the vault is locked', () => { diff --git a/src/features/fields/model/use-realtime-sync.ts b/src/features/fields/model/use-realtime-sync.ts index b68cb6f..8753cf8 100644 --- a/src/features/fields/model/use-realtime-sync.ts +++ b/src/features/fields/model/use-realtime-sync.ts @@ -49,7 +49,7 @@ function useRealtimeSync(): void { // 2. Conflict: a local save is in flight, last-write-wins, don't invalidate. if (hasPendingSave(queryClient, data.entryId, data.fieldName)) { - toast.info(t('realtime.conflict'), { + toast.warning(t('realtime.conflict'), { id: `conflict:${data.entryId}:${data.fieldName}`, }) return diff --git a/src/features/fields/ui/DashboardPage.test.tsx b/src/features/fields/ui/DashboardPage.test.tsx index 633810a..2db1ead 100644 --- a/src/features/fields/ui/DashboardPage.test.tsx +++ b/src/features/fields/ui/DashboardPage.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen } from '@/test/utils' +import userEvent from '@testing-library/user-event' import { useCryptoStore } from '@/shared/crypto/vault/crypto-store' import { useSyncStatusStore, SYNC_STATUS } from '@/features/fields/model/sync-status-store' import { useAuthStore } from '@/features/auth/model/auth-store' @@ -7,6 +8,12 @@ import { LockedVaultCard } from '@/features/vault/ui/LockedVaultCard' import { EntryDetailPage, EmptyState } from './DashboardPage' +const fieldQueryOverrides = vi.hoisted(() => ({ + error: null as Error | null, + isFetching: false, + refetch: vi.fn<() => Promise>(), +})) + // Mock useFieldEditor to avoid needing full TanStack Query + auth setup vi.mock('@/features/fields/model/use-field-editor', () => { return { @@ -16,6 +23,11 @@ vi.mock('@/features/fields/model/use-field-editor', () => { fieldSyncStatus: SYNC_STATUS.IDLE, retrySave: vi.fn(), isOfflineAwaitingData: false, + fieldQuery: { + error: fieldQueryOverrides.error, + isFetching: fieldQueryOverrides.isFetching, + refetch: fieldQueryOverrides.refetch, + }, }), } }) @@ -28,6 +40,9 @@ describe('EntryDetailPage', () => { }) useSyncStatusStore.getState().resetAll() useAuthStore.setState({ user: { id: '1', username: 'testuser', createdAt: '2024-01-01T00:00:00Z' } }) + fieldQueryOverrides.error = null + fieldQueryOverrides.isFetching = false + fieldQueryOverrides.refetch.mockReset() }) it('renders all field cards when unlocked', () => { @@ -73,6 +88,28 @@ describe('EntryDetailPage', () => { // resetAll() clears everything, so all entries are gone expect(Object.keys(status)).toHaveLength(0) }) + + it('shows an inline error with retry when a field query fails', async () => { + fieldQueryOverrides.error = new Error('load failed') + render(} />) + + // All four fields render the load-failed copy and a retry button + const retryButtons = screen.getAllByRole('button', { name: /Retry/i }) + expect(retryButtons).toHaveLength(4) + expect(screen.getAllByText("Couldn't load this field.")).toHaveLength(4) + + await userEvent.click(retryButtons[0]) + expect(fieldQueryOverrides.refetch).toHaveBeenCalledTimes(1) + }) + + it('shows a spinner while refetching after an error', () => { + fieldQueryOverrides.error = null + fieldQueryOverrides.isFetching = true + render(} />) + + // Four spinners — one per field + expect(document.querySelectorAll('.animate-spin')).toHaveLength(4) + }) }) describe('EmptyState', () => { diff --git a/src/features/fields/ui/DashboardPage.tsx b/src/features/fields/ui/DashboardPage.tsx index 0530532..35413b6 100644 --- a/src/features/fields/ui/DashboardPage.tsx +++ b/src/features/fields/ui/DashboardPage.tsx @@ -1,10 +1,12 @@ import { useEffect, useRef, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' -import { Plus, FileText } from 'lucide-react' +import { Plus, FileText, AlertCircle } from 'lucide-react' +import { Spinner } from '@/shared/ui/Spinner' import { useCryptoStore } from '@/shared/crypto/vault/crypto-store' import { useSyncStatusStore } from '@/features/fields/model/sync-status-store' import { useFieldEditor } from '@/features/fields/model/use-field-editor' +import { getFieldLoadErrorMessage } from '@/features/fields/model/field-error-messages' import { useEntries } from '@/features/fields/model/use-entry' import { useCurrentUser } from '@/shared/auth/use-current-user' import { FieldCard } from '@/features/fields/ui/FieldCard' @@ -25,9 +27,10 @@ function FieldEditorWrapper({ fieldName: FieldName entranceIndex: number }) { + const { t: tf } = useTranslation('fields') const isVaultLocked = useCryptoStore((s) => s.isVaultLocked) const titleInputRef = useRef(null) - const { fieldValue, saveFieldValue, fieldSyncStatus, retrySave, isOfflineAwaitingData } = useFieldEditor( + const { fieldValue, saveFieldValue, fieldSyncStatus, retrySave, isOfflineAwaitingData, fieldQuery } = useFieldEditor( entryId, fieldName, ) @@ -49,6 +52,25 @@ function FieldEditorWrapper({ } > {() => { + if (fieldQuery.error) { + return ( +
+ + {getFieldLoadErrorMessage(fieldQuery.error, tf)} + +
+ ) + } + + if (fieldQuery.isFetching) { + return ( +
+ +
+ ) + } switch (fieldName) { case 'title': return diff --git a/src/features/fields/ui/RotateFieldKeyDialog.tsx b/src/features/fields/ui/RotateFieldKeyDialog.tsx index db044d8..cd4f619 100644 --- a/src/features/fields/ui/RotateFieldKeyDialog.tsx +++ b/src/features/fields/ui/RotateFieldKeyDialog.tsx @@ -13,6 +13,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/shared/ui/alert-dialog' +import { Spinner } from '@/shared/ui/Spinner' import { useRotateFieldKeyDialogStore } from '@/shared/auth/auth-dialogs-store' import { useRequiredUserId } from '@/shared/auth/use-current-user' import { queryKeys } from '@/shared/lib/query-keys' @@ -150,6 +151,7 @@ function RotateFieldKeyDialog() { {tc('actions.cancel')} + {isSubmitting && } {isSubmitting ? t('keyRotation.rotating') : confirmLabel} diff --git a/src/features/fields/ui/SaveIndicator.tsx b/src/features/fields/ui/SaveIndicator.tsx index 289aa1e..b6771f8 100644 --- a/src/features/fields/ui/SaveIndicator.tsx +++ b/src/features/fields/ui/SaveIndicator.tsx @@ -1,7 +1,8 @@ import { useTranslation } from 'react-i18next' -import { Loader2, Check, AlertCircle, CloudOff, CloudDownload } from 'lucide-react' +import { Check, AlertCircle, CloudOff, CloudDownload } from 'lucide-react' import { SYNC_STATUS } from '@/features/fields/model/sync-status-store' import type { SyncStatus } from '@/features/fields/model/sync-status-store' +import { Spinner } from '@/shared/ui/Spinner' import { cn } from '@/shared/lib/utils' // Static keys so i18next-parser can discover them @@ -31,7 +32,7 @@ function SaveIndicator({ status, onRetry, className }: SaveIndicatorProps) { if (status === SYNC_STATUS.SAVING) { return ( - + {t(keys.text)} ) diff --git a/src/features/vault/model/use-vault-timeout.test.ts b/src/features/vault/model/use-vault-timeout.test.ts index 87aa66d..7046056 100644 --- a/src/features/vault/model/use-vault-timeout.test.ts +++ b/src/features/vault/model/use-vault-timeout.test.ts @@ -4,6 +4,12 @@ import { act } from 'react' import { useCryptoStore } from '@/shared/crypto/vault/crypto-store' import { DEFAULT_VAULT_TIMEOUT_MS } from './use-vault-timeout' +const toastWarning = vi.hoisted(() => vi.fn<(msg: string, options?: unknown) => string | number>()) + +vi.mock('sonner', () => ({ + toast: { warning: toastWarning }, +})) + vi.mock('@/shared/crypto/vault/key-vault', () => ({ keyVault: { lockVault: vi.fn<() => void>(), @@ -41,6 +47,8 @@ describe('useVaultTimeout', () => { vi.advanceTimersByTime(DEFAULT_VAULT_TIMEOUT_MS) expect(keyVault.lockVault).toHaveBeenCalledTimes(1) + expect(toastWarning).toHaveBeenCalledTimes(1) + expect(toastWarning.mock.calls[0][0]).toBe('Vault locked due to inactivity.') }) it('resets timer on mousemove', () => { diff --git a/src/features/vault/model/use-vault-timeout.ts b/src/features/vault/model/use-vault-timeout.ts index 6e7eb41..de5fa63 100644 --- a/src/features/vault/model/use-vault-timeout.ts +++ b/src/features/vault/model/use-vault-timeout.ts @@ -1,4 +1,6 @@ import { useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' import { useCryptoStore } from '@/shared/crypto/vault/crypto-store' import { keyVault } from '@/shared/crypto/vault/key-vault' @@ -9,6 +11,7 @@ const ACTIVITY_EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scr export function useVaultTimeout(timeoutMs: number = DEFAULT_VAULT_TIMEOUT_MS): void { const isVaultLocked = useCryptoStore((s) => s.isVaultLocked) + const { t } = useTranslation('vault') const timeoutRef = useRef | null>(null) useEffect(() => { @@ -26,6 +29,7 @@ export function useVaultTimeout(timeoutMs: number = DEFAULT_VAULT_TIMEOUT_MS): v } timeoutRef.current = setTimeout(() => { keyVault.lockVault() + toast.warning(t('inactivityLocked')) }, timeoutMs) } @@ -46,5 +50,5 @@ export function useVaultTimeout(timeoutMs: number = DEFAULT_VAULT_TIMEOUT_MS): v document.removeEventListener(event, handler) } } - }, [isVaultLocked, timeoutMs]) + }, [isVaultLocked, timeoutMs, t]) } diff --git a/src/main.tsx b/src/main.tsx index feb4b5b..796e693 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,9 +3,12 @@ import { createRoot } from 'react-dom/client' import '@/shared/i18n/config' import '@/app/styles/globals.css' import { AppProviders } from '@/app/Providers' +import { AppErrorBoundary } from '@/app/AppErrorBoundary' createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/src/shared/i18n/locales/cs/common.json b/src/shared/i18n/locales/cs/common.json index e3f13f9..9773a95 100644 --- a/src/shared/i18n/locales/cs/common.json +++ b/src/shared/i18n/locales/cs/common.json @@ -10,17 +10,20 @@ "confirm": "Potvrdit", "close": "Zavřít", "copy": "Zkopírovat do schránky", - "retry": "Zkusit znovu" + "retry": "Zkusit znovu", + "goHome": "Domů" }, "status": { "loading": "Načítání...", "error": "Něco se pokazilo", + "notFound": "Stránka nenalezena", "offline": "Jste offline", "backOnline": "Zpět online" }, "errors": { "networkError": "Chyba sítě. Zkuste to prosím znovu.", - "unexpectedError": "Došlo k neočekávané chybě. Zkuste to prosím znovu." + "unexpectedError": "Došlo k neočekávané chybě. Zkuste to prosím znovu.", + "pageNotFound": "Stránka, kterou hledáte, neexistuje." }, "preAlpha": { "ariaLabel": "Upozornění na předběžnou verzi", diff --git a/src/shared/i18n/locales/cs/entries.json b/src/shared/i18n/locales/cs/entries.json index d49c0e7..88de0fb 100644 --- a/src/shared/i18n/locales/cs/entries.json +++ b/src/shared/i18n/locales/cs/entries.json @@ -9,5 +9,8 @@ "notFound": "Poznámka nebyla nalezena", "deleted": "Tato poznámka byla smazána. Změny nebudou uloženy", "welcome": "Vítejte {{username}}", - "welcomeNoteCount": "Máte {{count}} poznámek. Vyberte si jednu z postranního panelu a můžete začít." + "welcomeNoteCount": "Máte {{count}} poznámek. Vyberte si jednu z postranního panelu a můžete začít.", + "errors": { + "loadFailed": "Poznámky se nepodařilo načíst." + } } diff --git a/src/shared/i18n/locales/cs/fields.json b/src/shared/i18n/locales/cs/fields.json index c841c08..d3f2780 100644 --- a/src/shared/i18n/locales/cs/fields.json +++ b/src/shared/i18n/locales/cs/fields.json @@ -32,5 +32,8 @@ "keyRotationApplied": "Klíč pro \"{{field}}\" byl aktualizován na v{{version}}.", "keyRotationFailed": "Klíče vašeho trezoru jsou zastaralé. Prosím, odemkněte trezor znovu.", "keyRotationNetworkError": "Chyba sítě při aktualizaci klíčů. Prosím, připojte se znovu a obnovte stránku." + }, + "errors": { + "loadFailed": "Toto pole se nepodařilo načíst." } } diff --git a/src/shared/i18n/locales/cs/vault.json b/src/shared/i18n/locales/cs/vault.json index 7db5df4..6c1b71c 100644 --- a/src/shared/i18n/locales/cs/vault.json +++ b/src/shared/i18n/locales/cs/vault.json @@ -23,5 +23,6 @@ "networkError": "Chyba sítě — rotace nebyla provedena.", "staleVault": "Dešifrování selhalo. Odemkněte trezor znovu a zkuste to znovu.", "locked": "Trezor je uzamčen." - } + }, + "inactivityLocked": "Trezor byl uzamčen kvůli nečinnosti." } diff --git a/src/shared/i18n/locales/en/common.json b/src/shared/i18n/locales/en/common.json index 266cd59..c85b65d 100644 --- a/src/shared/i18n/locales/en/common.json +++ b/src/shared/i18n/locales/en/common.json @@ -10,17 +10,20 @@ "confirm": "Confirm", "close": "Close", "copy": "Copy to clipboard", - "retry": "Retry" + "retry": "Retry", + "goHome": "Go home" }, "status": { "loading": "Loading...", "error": "Something went wrong", + "notFound": "Page not found", "offline": "You are offline", "backOnline": "Back online" }, "errors": { "networkError": "Network error. Please try again.", - "unexpectedError": "An unexpected error occurred. Please try again." + "unexpectedError": "An unexpected error occurred. Please try again.", + "pageNotFound": "The page you're looking for doesn't exist." }, "preAlpha": { "ariaLabel": "Pre-alpha software notice", diff --git a/src/shared/i18n/locales/en/entries.json b/src/shared/i18n/locales/en/entries.json index d7fd0c8..be3cce0 100644 --- a/src/shared/i18n/locales/en/entries.json +++ b/src/shared/i18n/locales/en/entries.json @@ -9,5 +9,8 @@ "notFound": "Note not found", "deleted": "This note was deleted. Changes won't be saved", "welcome": "Welcome {{username}}", - "welcomeNoteCount": "You have {{count}} notes. Select one from the sidebar to get started." + "welcomeNoteCount": "You have {{count}} notes. Select one from the sidebar to get started.", + "errors": { + "loadFailed": "Couldn't load your notes." + } } diff --git a/src/shared/i18n/locales/en/fields.json b/src/shared/i18n/locales/en/fields.json index 82b9035..8b1fbb3 100644 --- a/src/shared/i18n/locales/en/fields.json +++ b/src/shared/i18n/locales/en/fields.json @@ -32,5 +32,8 @@ "keyRotationApplied": "Key for \"{{field}}\" updated to v{{version}}.", "keyRotationFailed": "Your vault keys are outdated. Please unlock your vault again.", "keyRotationNetworkError": "Network error while updating keys. Please reconnect and reload." + }, + "errors": { + "loadFailed": "Couldn't load this field." } } diff --git a/src/shared/i18n/locales/en/vault.json b/src/shared/i18n/locales/en/vault.json index 4f1b82c..a1a910a 100644 --- a/src/shared/i18n/locales/en/vault.json +++ b/src/shared/i18n/locales/en/vault.json @@ -23,5 +23,6 @@ "networkError": "Network error — rotation not applied.", "staleVault": "Failed to decrypt. Re-unlock the vault and try again.", "locked": "Vault is locked." - } + }, + "inactivityLocked": "Vault locked due to inactivity." } diff --git a/src/shared/ui/ErrorState.test.tsx b/src/shared/ui/ErrorState.test.tsx new file mode 100644 index 0000000..4bbb617 --- /dev/null +++ b/src/shared/ui/ErrorState.test.tsx @@ -0,0 +1,58 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@/test/utils' +import userEvent from '@testing-library/user-event' +import { ErrorState } from '@/shared/ui/ErrorState' + +const navigateMock = vi.hoisted(() => vi.fn()) +vi.mock('@tanstack/react-router', () => ({ + useNavigate: () => navigateMock, +})) + +describe('ErrorState', () => { + it('renders a raw-string title', () => { + render() + expect(screen.getByText('Something broke')).toBeInTheDocument() + }) + + it('resolves an i18n key title', () => { + render() + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + }) + + it('renders the description when provided', () => { + render() + expect(screen.getByText("Couldn't load your notes.")).toBeInTheDocument() + }) + + it('always shows a Go home button', () => { + render() + expect(screen.getByRole('button', { name: 'Go home' })).toBeInTheDocument() + }) + + it('shows retry and Go home when onRetry is provided', () => { + const onRetry = vi.fn() + render() + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Go home' })).toBeInTheDocument() + }) + + it('shows Go home as outline when retry is available', () => { + const onRetry = vi.fn() + render() + const goHomeButton = screen.getByRole('button', { name: 'Go home' }) + expect(goHomeButton).toHaveClass('border-border') + }) + + it('calls onRetry when the retry button is clicked', async () => { + const onRetry = vi.fn() + render() + await userEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(onRetry).toHaveBeenCalledTimes(1) + }) + + it('navigates home when the Go home button is clicked', async () => { + render() + await userEvent.click(screen.getByRole('button', { name: 'Go home' })) + expect(navigateMock).toHaveBeenCalledWith({ to: '/' }) + }) +}) diff --git a/src/shared/ui/ErrorState.tsx b/src/shared/ui/ErrorState.tsx new file mode 100644 index 0000000..6b5cde4 --- /dev/null +++ b/src/shared/ui/ErrorState.tsx @@ -0,0 +1,56 @@ +import { useTranslation } from 'react-i18next' +import { useNavigate } from '@tanstack/react-router' +import { AlertCircle } from 'lucide-react' +import { Button } from '@/shared/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card' +import { cn } from '@/shared/lib/utils' + +interface ErrorStateProps { + title: string + description?: string + onRetry?: () => void + retryLabel?: string + className?: string +} + +function resolveText(t: (key: string) => string, value: string | undefined): string | undefined { + if (value === undefined) return undefined + return value.includes(':') ? t(value) : value +} + +/** Centered error card with a "Go home" link and an optional retry button. */ +function ErrorState({ title, description, onRetry, retryLabel = 'common:actions.retry', className }: ErrorStateProps) { + const { t } = useTranslation() + const navigate = useNavigate() + + return ( +
+ + + + + {resolveText(t, title)} + + + {description && {resolveText(t, description)}} + + {onRetry && ( + + )} + + + +
+ ) +} + +export { ErrorState } +export type { ErrorStateProps } diff --git a/src/shared/ui/Spinner.test.tsx b/src/shared/ui/Spinner.test.tsx new file mode 100644 index 0000000..c0a9400 --- /dev/null +++ b/src/shared/ui/Spinner.test.tsx @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@/test/utils' +import { Spinner } from '@/shared/ui/Spinner' + +describe('Spinner', () => { + it('renders with default size (md)', () => { + const { container } = render() + const svg = container.querySelector('svg') + expect(svg).not.toBeNull() + expect(svg?.getAttribute('class')).toContain('size-4') + expect(svg?.getAttribute('class')).toContain('animate-spin') + }) + + it('renders small size', () => { + const { container } = render() + expect(container.querySelector('svg')?.getAttribute('class')).toContain('size-3') + }) + + it('renders large size', () => { + const { container } = render() + expect(container.querySelector('svg')?.getAttribute('class')).toContain('size-6') + }) + + it('applies custom className and is marked aria-hidden', () => { + const { container } = render() + const svg = container.querySelector('svg') + expect(svg?.getAttribute('class')).toContain('text-muted-foreground') + expect(svg?.getAttribute('aria-hidden')).toBe('true') + }) + + it('does not expose an accessible role', () => { + render() + expect(screen.queryByRole('img')).toBeNull() + }) +}) diff --git a/src/shared/ui/Spinner.tsx b/src/shared/ui/Spinner.tsx new file mode 100644 index 0000000..671670b --- /dev/null +++ b/src/shared/ui/Spinner.tsx @@ -0,0 +1,23 @@ +import { Loader2 } from 'lucide-react' +import { cn } from '@/shared/lib/utils' + +type SpinnerSize = 'sm' | 'md' | 'lg' + +const SIZE_CLASSES: Record = { + sm: 'size-3', + md: 'size-4', + lg: 'size-6', +} + +interface SpinnerProps { + size?: SpinnerSize + className?: string +} + +/** Animated loading spinner. Wraps the lucide `Loader2` icon with a consistent size scale. */ +function Spinner({ size = 'md', className }: SpinnerProps) { + return +} + +export { Spinner } +export type { SpinnerProps, SpinnerSize } From 61e8e93fb9298e34d82295f89713efc7723fe531 Mon Sep 17 00:00:00 2001 From: VitekHub Date: Tue, 7 Jul 2026 17:51:37 +0200 Subject: [PATCH 2/2] feat: improve Spinner accessibility with role=status and screen reader label --- src/shared/ui/Spinner.test.tsx | 13 +++++++++---- src/shared/ui/Spinner.tsx | 9 ++++++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/shared/ui/Spinner.test.tsx b/src/shared/ui/Spinner.test.tsx index c0a9400..f8fbf55 100644 --- a/src/shared/ui/Spinner.test.tsx +++ b/src/shared/ui/Spinner.test.tsx @@ -21,15 +21,20 @@ describe('Spinner', () => { expect(container.querySelector('svg')?.getAttribute('class')).toContain('size-6') }) - it('applies custom className and is marked aria-hidden', () => { + it('applies custom className to the SVG', () => { const { container } = render() const svg = container.querySelector('svg') expect(svg?.getAttribute('class')).toContain('text-muted-foreground') - expect(svg?.getAttribute('aria-hidden')).toBe('true') }) - it('does not expose an accessible role', () => { + it('has status role and screen reader label for accessibility', () => { render() - expect(screen.queryByRole('img')).toBeNull() + expect(screen.getByRole('status')).toBeInTheDocument() + expect(screen.getByText('Loading...')).toBeInTheDocument() + }) + + it('hides the SVG from assistive technology', () => { + const { container } = render() + expect(container.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true') }) }) diff --git a/src/shared/ui/Spinner.tsx b/src/shared/ui/Spinner.tsx index 671670b..7b13b10 100644 --- a/src/shared/ui/Spinner.tsx +++ b/src/shared/ui/Spinner.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next' import { Loader2 } from 'lucide-react' import { cn } from '@/shared/lib/utils' @@ -16,7 +17,13 @@ interface SpinnerProps { /** Animated loading spinner. Wraps the lucide `Loader2` icon with a consistent size scale. */ function Spinner({ size = 'md', className }: SpinnerProps) { - return + const { t } = useTranslation('common') + return ( + + + {t('status.loading')} + + ) } export { Spinner }