89 KiB
Frontend Implementation Plan: AI Weekly Synth (SolidJS Rewrite)
Date: 2026-03-21 Role: Frontend Implementation Planner Target: Complete SolidJS frontend to replace the current React SPA
Table of Contents
- Project Structure
- Component Architecture
- State Management
- Routing
- i18n Architecture
- SSE Integration
- Tailwind CSS
- Forms and Validation
- PDF/Markdown Export
- React-to-SolidJS Migration Map
- Testing
1. Project Structure
1.1 Directory Layout
frontend/
├── index.html # Vite entry HTML
├── package.json
├── tsconfig.json
├── tsconfig.app.json
├── vite.config.ts
├── tailwind.config.ts # Tailwind v4 config (if needed beyond CSS)
├── public/
│ └── favicon.svg
├── src/
│ ├── index.tsx # SolidJS render entry point
│ ├── App.tsx # Root component: router + providers
│ ├── index.css # Tailwind import + custom base styles
│ │
│ ├── api/ # API client layer
│ │ ├── client.ts # Base fetch wrapper (cookies, CSRF, errors)
│ │ ├── auth.ts # Auth endpoints (register, login, verify, logout, me)
│ │ ├── syntheses.ts # Syntheses endpoints (list, get, generate, delete, email)
│ │ ├── sources.ts # Sources endpoints (list, add, delete, bulk, CSV)
│ │ ├── settings.ts # Settings endpoints (get, update, export, import)
│ │ ├── admin.ts # Admin endpoints (providers, rate-limits, users)
│ │ └── config.ts # Public config endpoint (provider list for users)
│ │
│ ├── types/ # TypeScript type definitions
│ │ ├── index.ts # Re-export barrel
│ │ ├── auth.ts # User, Session, RegisterRequest, LoginRequest
│ │ ├── synthesis.ts # Synthesis, NewsSection, NewsItem, GenerationJob
│ │ ├── source.ts # Source, CreateSourceRequest, BulkImportRequest
│ │ ├── settings.ts # UserSettings, DEFAULT_SETTINGS
│ │ ├── admin.ts # ProviderConfig, RateLimitConfig, AdminUser
│ │ └── api.ts # ApiError, PaginatedResponse, SSEEvent
│ │
│ ├── i18n/ # Internationalization
│ │ ├── index.ts # i18n context provider and useI18n hook
│ │ ├── types.ts # Translation key type definitions
│ │ └── locales/
│ │ └── fr.ts # French translations (default)
│ │
│ ├── context/ # SolidJS context providers
│ │ ├── AuthContext.tsx # Auth state (user, session check, login/logout)
│ │ └── ToastContext.tsx # Global toast notification system
│ │
│ ├── lib/ # Utilities and helpers
│ │ ├── sse.ts # EventSource wrapper for SolidJS signals
│ │ ├── dates.ts # Date formatting (date-fns/fr wrappers)
│ │ ├── export.ts # PDF and Markdown generation utilities
│ │ ├── csv.ts # CSV parse/generate utilities
│ │ └── validation.ts # Form validation rules and helpers
│ │
│ ├── components/ # Shared/reusable UI components
│ │ ├── layout/
│ │ │ ├── AppShell.tsx # Main layout: navbar + mobile menu + main content
│ │ │ ├── Navbar.tsx # Top navigation bar with responsive menu
│ │ │ ├── MobileMenu.tsx # Hamburger slide-out menu for mobile
│ │ │ └── AdminLayout.tsx # Admin section layout with sidebar nav
│ │ │
│ │ ├── auth/
│ │ │ └── ProtectedRoute.tsx # Route guard: redirects to /login if unauthenticated
│ │ │
│ │ ├── ui/
│ │ │ ├── Button.tsx # Reusable button (variants: primary, secondary, danger)
│ │ │ ├── Card.tsx # Card container with optional header/footer
│ │ │ ├── Spinner.tsx # Loading spinner (inline and full-page variants)
│ │ │ ├── Badge.tsx # Status/category badge
│ │ │ ├── ConfirmDialog.tsx # Modal confirmation dialog (standardized deletion)
│ │ │ ├── Toast.tsx # Toast notification component
│ │ │ ├── ErrorBanner.tsx # Inline error message with icon
│ │ │ ├── SuccessBanner.tsx # Inline success message with icon
│ │ │ ├── EmptyState.tsx # Empty list placeholder with action
│ │ │ └── SSEProgressIndicator.tsx # Generation progress bar with step list
│ │ │
│ │ ├── forms/
│ │ │ ├── FormField.tsx # Label + input + error message wrapper
│ │ │ ├── TextInput.tsx # Text input with validation display
│ │ │ ├── TextArea.tsx # Textarea with validation display
│ │ │ ├── Select.tsx # Select dropdown
│ │ │ ├── NumberInput.tsx # Number input with min/max
│ │ │ └── FileInput.tsx # Styled file upload input
│ │ │
│ │ └── synthesis/
│ │ ├── SynthesisCard.tsx # Card for synthesis list (Home page)
│ │ ├── SynthesisSection.tsx # Section display in detail view
│ │ └── NewsItemCard.tsx # Individual news item in detail view
│ │
│ └── pages/ # Route-level page components
│ ├── auth/
│ │ ├── Login.tsx # Email + Turnstile -> request magic link
│ │ ├── Register.tsx # Email + display name + Turnstile -> create account
│ │ └── MagicLinkVerify.tsx # Token verification redirect page
│ │
│ ├── Home.tsx # Dashboard: synthesis list + in-progress banner
│ ├── GenerateSynthesis.tsx # Generation trigger + SSE progress display
│ ├── SynthesisDetail.tsx # Full synthesis view + email + export + delete
│ ├── Sources.tsx # Sources CRUD + CSV import/export + bulk import
│ ├── Settings.tsx # User settings (theme, categories, provider/model/key)
│ │
│ └── admin/
│ ├── ProviderCatalog.tsx # Manage LLM providers and available models
│ ├── RateLimitConfig.tsx # Configure global rate limits per provider
│ └── UserManagement.tsx # List users, change roles
1.2 Package Dependencies
{
"name": "ai-weekly-synth-frontend",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"test:ui": "vitest --ui",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"solid-js": "^1.9.0",
"@solidjs/router": "^0.15.0",
"lucide-solid": "^0.475.0",
"date-fns": "^4.1.0",
"jspdf": "^2.5.2",
"jspdf-autotable": "^3.8.4"
},
"devDependencies": {
"typescript": "~5.8.0",
"vite": "^6.2.0",
"vite-plugin-solid": "^2.11.0",
"@tailwindcss/vite": "^4.1.0",
"tailwindcss": "^4.1.0",
"vitest": "^3.0.0",
"@solidjs/testing-library": "^0.8.0",
"@testing-library/jest-dom": "^6.6.0",
"jsdom": "^25.0.0"
}
}
Dependency justifications:
| Package | Why |
|---|---|
solid-js |
Core UI framework (per project decisions) |
@solidjs/router |
Official SolidJS router with nested routes, guards, lazy loading |
lucide-solid |
SolidJS-specific icon library -- same icon set as current lucide-react, preserves visual continuity |
date-fns |
Framework-agnostic date formatting. Reuses current French locale formatting patterns |
jspdf + jspdf-autotable |
Client-side PDF generation for synthesis export. No server round-trip needed |
vite-plugin-solid |
Vite plugin for SolidJS JSX compilation |
@tailwindcss/vite |
Tailwind CSS v4 Vite integration (replaces PostCSS plugin) |
@solidjs/testing-library |
Official testing utilities for SolidJS components |
vitest |
Vite-native test runner, zero config with Vite projects |
Notable exclusions:
- No
@google/genai-- LLM calls move to the Rust backend. - No
firebase-- replaced by REST API + session cookies. - No
react,react-dom,react-router-dom-- replaced by SolidJS equivalents. - No
turnstile-reactor similar -- Cloudflare Turnstile is loaded via script tag and used imperatively. - No state management library (e.g., zustand) -- SolidJS signals and stores are sufficient.
1.3 Vite Configuration
// vite.config.ts
import { defineConfig } from 'vite';
import solid from 'vite-plugin-solid';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [
solid(),
tailwindcss(),
],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
build: {
target: 'esnext',
outDir: 'dist',
},
});
Key points:
vite-plugin-solidhandles SolidJS JSX transformation (converts JSX tocreateComponent/templatecalls).@tailwindcss/vitereplaces the old PostCSS-based Tailwind pipeline.- Dev proxy maps
/api/*to the Rust backend atlocalhost:8080, avoiding CORS issues during development. - Production: the frontend is served as static files by the Rust backend (or an nginx container in Docker Compose).
1.4 TypeScript Configuration
// tsconfig.json
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "preserve",
"jsxImportSource": "solid-js",
"strict": true,
"noEmit": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules"]
}
Critical SolidJS setting: "jsxImportSource": "solid-js" tells TypeScript to use SolidJS's JSX types instead of React's. This ensures class (not className), SolidJS event types, and SolidJS component return types.
2. Component Architecture
2.1 Layout Components
src/App.tsx -- Root Component
Purpose: Wire up providers (Auth, Toast, i18n) and the router. Replaces the current App.tsx which nests AuthProvider > Router > Routes.
SolidJS pattern: Router from @solidjs/router wraps the entire app. Providers are nested above the router.
// src/App.tsx
import { Component } from 'solid-js';
import { Router, Route } from '@solidjs/router';
import { AuthProvider } from '~/context/AuthContext';
import { ToastProvider } from '~/context/ToastContext';
import { I18nProvider } from '~/i18n';
// Lazy-loaded pages
import { lazy } from 'solid-js';
const Home = lazy(() => import('~/pages/Home'));
const Login = lazy(() => import('~/pages/auth/Login'));
const Register = lazy(() => import('~/pages/auth/Register'));
const MagicLinkVerify = lazy(() => import('~/pages/auth/MagicLinkVerify'));
const GenerateSynthesis = lazy(() => import('~/pages/GenerateSynthesis'));
const SynthesisDetail = lazy(() => import('~/pages/SynthesisDetail'));
const Sources = lazy(() => import('~/pages/Sources'));
const Settings = lazy(() => import('~/pages/Settings'));
const ProviderCatalog = lazy(() => import('~/pages/admin/ProviderCatalog'));
const RateLimitConfig = lazy(() => import('~/pages/admin/RateLimitConfig'));
const UserManagement = lazy(() => import('~/pages/admin/UserManagement'));
const App: Component = () => {
return (
<I18nProvider locale="fr">
<ToastProvider>
<AuthProvider>
<Router>
{/* Public routes */}
<Route path="/login" component={Login} />
<Route path="/register" component={Register} />
<Route path="/auth/verify" component={MagicLinkVerify} />
{/* Protected routes wrapped in AppShell */}
<Route path="/" component={ProtectedAppShell}>
<Route path="/" component={Home} />
<Route path="/generate" component={GenerateSynthesis} />
<Route path="/synthesis/:id" component={SynthesisDetail} />
<Route path="/sources" component={Sources} />
<Route path="/settings" component={Settings} />
</Route>
{/* Admin routes wrapped in AdminLayout */}
<Route path="/admin" component={ProtectedAdminShell}>
<Route path="/providers" component={ProviderCatalog} />
<Route path="/rate-limits" component={RateLimitConfig} />
<Route path="/users" component={UserManagement} />
</Route>
</Router>
</AuthProvider>
</ToastProvider>
</I18nProvider>
);
};
export default App;
React pattern replaced: BrowserRouter > Routes > Route with per-route <ProtectedRoute><Layout> wrappers. SolidJS uses nested <Route> with layout components directly in the hierarchy.
src/components/layout/AppShell.tsx -- Main Layout
Purpose: Wraps all authenticated (non-admin) pages. Renders Navbar + main content area.
Props/Signals:
- Reads
userfromAuthContext - Reads current route from
useLocation()for active nav highlighting mobileMenuOpen: Signal<boolean>for hamburger toggle
SolidJS patterns: <Show> for conditional rendering, children via props.children (not {children} destructure -- SolidJS requires props.children to maintain reactivity).
import { Component, Show, createSignal } from 'solid-js';
import { useAuth } from '~/context/AuthContext';
import Navbar from './Navbar';
import MobileMenu from './MobileMenu';
const AppShell: Component = (props) => {
const { user } = useAuth();
const [mobileMenuOpen, setMobileMenuOpen] = createSignal(false);
return (
<div class="min-h-screen bg-gray-50">
<Navbar
user={user()}
onToggleMobile={() => setMobileMenuOpen(!mobileMenuOpen())}
/>
<Show when={mobileMenuOpen()}>
<MobileMenu onClose={() => setMobileMenuOpen(false)} />
</Show>
<main>
{props.children}
</main>
</div>
);
};
Key SolidJS difference: In React, {children} is destructured from props. In SolidJS, always access as props.children to preserve reactivity tracking. Destructuring breaks the reactive subscription.
src/components/layout/Navbar.tsx -- Navigation Bar
Purpose: Top navigation bar with logo, nav links, user info, settings icon, admin dropdown (if admin), logout. Includes mobile hamburger button.
Props:
interface NavbarProps {
user: User | null;
onToggleMobile: () => void;
}
SolidJS patterns:
useLocation()from@solidjs/routerfor active route detection<A>component from@solidjs/router(replaces React Router's<Link>)<Show when={user()?.role === 'admin'}>for admin nav itemsclassattribute (notclassName)
Active route indicator: Compute active class with:
const location = useLocation();
const isActive = (path: string) =>
location.pathname === path
? 'border-indigo-500 text-gray-900'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700';
React pattern replaced: Current Layout component in App.tsx. The current app has no active route indicator and no mobile menu -- both are added in this rewrite.
src/components/layout/MobileMenu.tsx -- Mobile Navigation
Purpose: Slide-out mobile menu (hamburger), fixing the current app's missing mobile navigation. Renders nav links + user info + logout.
Props:
interface MobileMenuProps {
onClose: () => void;
}
SolidJS patterns: onCleanup to add/remove click-outside handler. <For> to iterate nav items.
src/components/layout/AdminLayout.tsx -- Admin Section Layout
Purpose: Layout for /admin/* routes. Includes sidebar with links to provider catalog, rate limits, user management. Wraps admin pages.
SolidJS patterns: Nested <Route> children via props.children. Active sidebar link via useLocation().
src/components/auth/ProtectedRoute.tsx -- Route Guard
Purpose: Checks auth state. If unauthenticated, redirects to /login. If loading, shows full-page spinner.
SolidJS patterns:
- Reads
userandloadingfromAuthContext - Uses
<Show>withfallbackfor the loading state - Uses
<Navigate>from@solidjs/routerfor redirect
import { Component, Show } from 'solid-js';
import { Navigate } from '@solidjs/router';
import { useAuth } from '~/context/AuthContext';
import Spinner from '~/components/ui/Spinner';
const ProtectedRoute: Component = (props) => {
const { user, loading } = useAuth();
return (
<Show
when={!loading()}
fallback={<Spinner fullPage />}
>
<Show
when={user()}
fallback={<Navigate href="/login" />}
>
{props.children}
</Show>
</Show>
);
};
React pattern replaced: Current ProtectedRoute uses if (loading) return ... and if (!user) return <Navigate>. SolidJS uses nested <Show> components instead of early returns.
2.2 Auth Pages
src/pages/auth/Login.tsx -- Magic Link Login
Purpose: Email field + Cloudflare Turnstile captcha + submit to request magic link. Shows confirmation message after submit with resend button.
Signals:
const [email, setEmail] = createSignal('');
const [turnstileToken, setTurnstileToken] = createSignal<string | null>(null);
const [submitted, setSubmitted] = createSignal(false);
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [resendCooldown, setResendCooldown] = createSignal(0);
API calls:
POST /api/v1/auth/loginwith{ email, captcha_token }
SolidJS patterns:
onMountto load the Turnstile script if not already loadedcreateEffectto manage resend cooldown timer (countdown from 60s)onCleanupto clear the timer interval<Show when={submitted()}>to toggle between form view and confirmation view
Turnstile integration: Load via <script> tag. Render into a <div ref={turnstileRef}> using window.turnstile.render(). Callback sets the turnstileToken signal.
let turnstileRef: HTMLDivElement | undefined;
onMount(() => {
if (!document.getElementById('cf-turnstile-script')) {
const script = document.createElement('script');
script.id = 'cf-turnstile-script';
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
script.onload = () => renderTurnstile();
document.head.appendChild(script);
} else {
renderTurnstile();
}
});
const renderTurnstile = () => {
if (turnstileRef && window.turnstile) {
window.turnstile.render(turnstileRef, {
sitekey: import.meta.env.VITE_TURNSTILE_SITE_KEY,
callback: (token: string) => setTurnstileToken(token),
});
}
};
React pattern replaced: Current Login component uses a single Google sign-in button. The new version is a form-based flow with captcha.
src/pages/auth/Register.tsx -- Account Creation
Purpose: Email + optional display name + Turnstile captcha. Submit sends registration request, then shows confirmation to check email.
Signals: Similar to Login, plus displayName: Signal<string>.
API calls:
POST /api/v1/auth/registerwith{ email, display_name, captcha_token }
SolidJS patterns: Same as Login. Shared Turnstile loading logic can be extracted to lib/turnstile.ts helper.
src/pages/auth/MagicLinkVerify.tsx -- Token Verification
Purpose: User arrives via magic link email (/auth/verify?token=...). Extracts token from URL, calls backend to verify, then redirects to Home on success.
Signals:
const [status, setStatus] = createSignal<'verifying' | 'success' | 'error'>('verifying');
const [errorMessage, setErrorMessage] = createSignal('');
API calls:
GET /api/v1/auth/verify?token=<token>-- sets session cookie in response
SolidJS patterns:
useSearchParams()from@solidjs/routerto extract thetokenquery parameteronMountto trigger the verification call immediatelyuseNavigate()to redirect to/on success after a brief delay
const MagicLinkVerify: Component = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [status, setStatus] = createSignal<'verifying' | 'success' | 'error'>('verifying');
onMount(async () => {
const token = searchParams.token;
if (!token) {
setStatus('error');
return;
}
try {
await authApi.verify(token as string);
setStatus('success');
setTimeout(() => navigate('/', { replace: true }), 1500);
} catch (e) {
setStatus('error');
}
});
return (
<div class="min-h-screen flex items-center justify-center bg-gray-50">
<Switch>
<Match when={status() === 'verifying'}>
<Spinner fullPage />
</Match>
<Match when={status() === 'success'}>
<SuccessBanner message={t('auth.verifySuccess')} />
</Match>
<Match when={status() === 'error'}>
<ErrorBanner message={t('auth.verifyError')} />
</Match>
</Switch>
</div>
);
};
2.3 Main Pages
src/pages/Home.tsx -- Synthesis List (Dashboard)
Purpose: Display a grid of synthesis cards, ordered by creation date. Show "New Synthesis" button. Show in-progress banner if a generation is running.
Signals:
const [syntheses] = createResource(fetchSyntheses);
const [deletingId, setDeletingId] = createSignal<string | null>(null);
const [activeJob, setActiveJob] = createSignal<GenerationJob | null>(null);
API calls:
GET /api/v1/synthesesviacreateResourcefor initial load- SSE connection for real-time list updates (new synthesis appears, deletion reflects)
DELETE /api/v1/syntheses/:id- Check for in-progress generation jobs on mount
SolidJS patterns:
createResourcereplacesuseState+useEffectfor async data fetching. It provides.loading,.error, and.latestproperties.<For each={syntheses()}>replaces{array.map(...)}.<For>is optimized for keyed list rendering without re-creating DOM nodes.<Show when={syntheses()?.length === 0}>for empty state.<Show when={activeJob()}>for in-progress generation banner.- SSE listener via
createEffect+onCleanupto mutate the resource.
const Home: Component = () => {
const { t } = useI18n();
const [syntheses, { mutate, refetch }] = createResource(
() => synthesesApi.list()
);
const [deletingId, setDeletingId] = createSignal<string | null>(null);
// SSE for real-time updates
createEffect(() => {
const eventSource = createSSEConnection('/api/v1/syntheses/events');
eventSource.on('created', (data) => {
mutate((prev) => prev ? [data, ...prev] : [data]);
});
eventSource.on('deleted', (data) => {
mutate((prev) => prev?.filter((s) => s.id !== data.id) ?? []);
});
onCleanup(() => eventSource.close());
});
const handleDelete = async (id: string) => {
// Show confirm dialog, then call API
await synthesesApi.delete(id);
mutate((prev) => prev?.filter((s) => s.id !== id) ?? []);
};
return (
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header with title + New Synthesis button */}
{/* In-progress banner */}
<Show when={!syntheses.loading} fallback={<Spinner />}>
<Show
when={syntheses()?.length}
fallback={<EmptyState ... />}
>
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<For each={syntheses()}>
{(synth) => (
<SynthesisCard
synthesis={synth}
isDeleting={deletingId() === synth.id}
onDelete={() => handleDelete(synth.id)}
/>
)}
</For>
</div>
</Show>
</Show>
</div>
);
};
React pattern replaced:
useState+useEffectwith FirestoreonSnapshot-->createResource+ SSE viacreateEffect{array.map()}--><For each={...}>className-->class
src/pages/GenerateSynthesis.tsx -- Generation with SSE Progress
Purpose: Show generation parameters (from user settings). Trigger generation. Display real-time SSE progress (step list + progress bar). Handle navigation-away (generation continues in background).
Signals:
const [settings] = createResource(settingsApi.get);
const [jobId, setJobId] = createSignal<string | null>(null);
const [progress, setProgress] = createSignal<SSEProgressEvent | null>(null);
const [status, setStatus] = createSignal<'idle' | 'generating' | 'complete' | 'error'>('idle');
const [errorMessage, setErrorMessage] = createSignal<string | null>(null);
API calls:
GET /api/v1/settingsviacreateResource(to show "theme" and "maxAgeDays" in the trigger confirmation text)POST /api/v1/syntheses/generateto start generation (returnsjob_id)- SSE:
GET /api/v1/syntheses/generate/:job_id/progressfor progress events
SolidJS patterns:
createEffectto open SSE connection whenjobId()is setonCleanupto close SSE on unmount (but generation continues server-side)<Switch>/<Match>for status-based rendering (idle/generating/complete/error)<Show>for conditional progress display
const handleGenerate = async () => {
setStatus('generating');
try {
const { job_id } = await synthesesApi.generate();
setJobId(job_id);
} catch (e) {
setStatus('error');
setErrorMessage(parseApiError(e));
}
};
// SSE progress tracking
createEffect(() => {
const id = jobId();
if (!id) return;
const sse = createSSEConnection(`/api/v1/syntheses/generate/${id}/progress`);
sse.on('progress', (data) => setProgress(data));
sse.on('complete', (data) => {
setStatus('complete');
setTimeout(() => navigate(`/synthesis/${data.synthesis_id}`), 1500);
});
sse.on('error', (data) => {
setStatus('error');
setErrorMessage(data.message);
});
onCleanup(() => sse.close());
});
Progress UI: The SSEProgressIndicator component renders a progress bar and step list:
// SSEProgressIndicator props
interface SSEProgressIndicatorProps {
progress: SSEProgressEvent | null;
}
// SSEProgressEvent type
interface SSEProgressEvent {
step: 'search' | 'scraping' | 'rewrite' | 'saving';
message: string;
percent: number;
}
React pattern replaced: Current GenerateSynthesis calls generateWeeklySynthesis() directly from the browser (Gemini API). The new version triggers a backend job and monitors via SSE. The single spinner is replaced by a multi-step progress indicator.
src/pages/SynthesisDetail.tsx -- Reading View + Email + Export + Delete
Purpose: Display full synthesis (sections with news items). Provide email sending, PDF export, Markdown export, and deletion.
Signals:
const [synthesis] = createResource(
() => params.id,
(id) => synthesesApi.get(id)
);
const [email, setEmail] = createSignal('');
const [sendingEmail, setSendingEmail] = createSignal(false);
const [emailSuccess, setEmailSuccess] = createSignal(false);
const [emailError, setEmailError] = createSignal<string | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = createSignal(false);
const [isDeleting, setIsDeleting] = createSignal(false);
API calls:
GET /api/v1/syntheses/:idviacreateResourcewithparams.idas sourcePOST /api/v1/syntheses/:id/emailwith{ recipient_email }DELETE /api/v1/syntheses/:id
SolidJS patterns:
createResourcewith a source signal (params.id) -- re-fetches when the param changesuseParams()from@solidjs/router<For each={synthesis()?.sections}>to render sections<Show when={showDeleteConfirm()}>for delete confirmation (standardizedConfirmDialog)
Export actions:
- "Export PDF" button calls
generatePdf(synthesis())fromlib/export.ts - "Export Markdown" button calls
generateMarkdown(synthesis())fromlib/export.ts - Email pre-fills with user's own email from
AuthContext
React pattern replaced:
useParams<{ id: string }>()-->useParams()(SolidJS version returns a reactive proxy, not a plain object)useEffect([id])with FirestoreonSnapshot-->createResourcewithparams.idas reactive source- Gmail API email sending -->
POST /api/v1/syntheses/:id/emailbackend call - Legacy data fallback rendering removed (per decisions: clean
sections[]only)
src/pages/Sources.tsx -- Sources Management
Purpose: CRUD for custom sources. Single add form, CSV import/export, bulk text import. List of existing sources with delete.
Signals:
const [sources, { mutate, refetch }] = createResource(sourcesApi.list);
const [newTitle, setNewTitle] = createSignal('');
const [newUrl, setNewUrl] = createSignal('');
const [adding, setAdding] = createSignal(false);
const [bulkText, setBulkText] = createSignal('');
const [importing, setImporting] = createSignal(false);
const [importError, setImportError] = createSignal<string | null>(null);
API calls:
GET /api/v1/sourcesviacreateResourcePOST /api/v1/sourcesto add a single sourceDELETE /api/v1/sources/:idto deletePOST /api/v1/sources/bulkfor bulk import (JSON array)POST /api/v1/sources/import-csvfor CSV file import (multipart)GET /api/v1/sources/export-csvfor CSV download
SolidJS patterns:
<For each={sources()}>for the source list- Optimistic UI:
mutate()to add/remove from local state immediately,refetch()on error to resync <Show when={importError()}>for error display- Form
onSubmithandlers withpreventDefault()
Deletion pattern change: Current app has no delete confirmation on sources. The new version uses the standardized ConfirmDialog component for consistency.
React pattern replaced:
useEffectwith FirestoreonSnapshot-->createResourcewith SSE for real-time (or refetch after mutations)e: React.FormEvent--> nativeSubmitEvent(SolidJS does not have synthetic events)e: React.ChangeEvent<HTMLInputElement>--> nativeEventwith(e.target as HTMLInputElement).files
src/pages/Settings.tsx -- User Settings
Purpose: Configure theme, search window, categories (dynamic list), AI provider/model/API key, search agent behavior. Import/export JSON.
Signals:
const [settings, { mutate }] = createResource(settingsApi.get);
const [localSettings, setLocalSettings] = createSignal<UserSettings | null>(null);
const [saving, setSaving] = createSignal(false);
const [message, setMessage] = createSignal<{ type: 'success' | 'error'; text: string } | null>(null);
const [providers] = createResource(configApi.getProviders);
API calls:
GET /api/v1/settingsviacreateResourcePUT /api/v1/settingsto saveGET /api/v1/settings/exportfor JSON downloadPOST /api/v1/settings/importfor JSON uploadGET /api/v1/config/providersto get available providers and models (admin-curated list)
SolidJS patterns:
createEffectto initializelocalSettingsfromsettings()when loadedcreateStorefor form state (deep nested updates for categories array)<For each={localSettings()?.categories}>for the dynamic category list<Show when={providers()?.length > 1}>to conditionally show provider dropdown- Cascading dropdowns: when provider changes, model list updates
New fields compared to current app:
- Provider dropdown:
<Select>populated fromGET /api/v1/config/providers. Hidden if only one provider configured. - Model dropdown: Dynamically populated based on selected provider.
- API Key field: Masked input with show/hide toggle. User provides their own key.
Category management:
const [settingsStore, setSettingsStore] = createStore<UserSettings>(DEFAULT_SETTINGS);
// Add category
const addCategory = () => {
if (settingsStore.categories.length >= 20) return;
setSettingsStore('categories', settingsStore.categories.length, t('settings.newCategory'));
};
// Remove category
const removeCategory = (index: number) => {
if (settingsStore.categories.length <= 1) return;
setSettingsStore('categories', (cats) => cats.filter((_, i) => i !== index));
};
// Update category
const updateCategory = (index: number, value: string) => {
setSettingsStore('categories', index, value);
};
React pattern replaced:
useState<AppSettings>with spread-based updates -->createStore<UserSettings>with path-based updates- Hardcoded AI model
<option>values --> dynamic model list from backendGET /api/v1/config/providers - No provider selection --> two-level provider + model selection
2.4 Admin Pages
src/pages/admin/ProviderCatalog.tsx -- LLM Provider Management
Purpose: Admin configures available LLM providers. For each provider: name, list of available models, default model, enabled/disabled toggle.
Note: Per decisions, users bring their own API keys. The admin only curates which providers/models are available.
Signals:
const [providers, { refetch }] = createResource(adminApi.getProviders);
const [editingProvider, setEditingProvider] = createSignal<ProviderConfig | null>(null);
const [saving, setSaving] = createSignal(false);
API calls:
GET /api/v1/admin/providersPOST /api/v1/admin/providers(add/update)DELETE /api/v1/admin/providers/:id
SolidJS patterns:
<For each={providers()}>for provider cards/tabs<Show when={editingProvider()}>for edit formcreateStorefor the edit form state (nested model array)
src/pages/admin/RateLimitConfig.tsx -- Rate Limit Configuration
Purpose: Configure global rate limits per provider (requests/minute). Show current defaults.
Signals:
const [rateLimits, { refetch }] = createResource(adminApi.getRateLimits);
const [localLimits, setLocalLimits] = createStore<Record<string, RateLimitConfig>>({});
const [saving, setSaving] = createSignal(false);
API calls:
GET /api/v1/admin/rate-limitsPUT /api/v1/admin/rate-limits/:provider_id
src/pages/admin/UserManagement.tsx -- User List and Roles
Purpose: List all registered users. Allow admin to promote/demote user roles.
Signals:
const [users] = createResource(adminApi.listUsers);
API calls:
GET /api/v1/admin/usersPUT /api/v1/admin/users/:id/role
2.5 Shared UI Components
src/components/ui/Button.tsx
import { Component, JSX, Show, splitProps } from 'solid-js';
import { Loader2 } from 'lucide-solid';
interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger';
loading?: boolean;
icon?: Component<{ class?: string }>;
}
const variantClasses = {
primary: 'text-white bg-indigo-600 hover:bg-indigo-700 border-transparent focus:ring-indigo-500',
secondary: 'text-gray-700 bg-white hover:bg-gray-50 border-gray-300 focus:ring-indigo-500',
danger: 'text-white bg-red-600 hover:bg-red-700 border-transparent focus:ring-red-500',
};
const Button: Component<ButtonProps> = (allProps) => {
const [props, rest] = splitProps(allProps, ['variant', 'loading', 'icon', 'children']);
const variant = () => props.variant ?? 'primary';
return (
<button
{...rest}
disabled={rest.disabled || props.loading}
class={`inline-flex items-center justify-center px-4 py-2 border shadow-sm text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${variantClasses[variant()]} ${rest.class ?? ''}`}
>
<Show when={props.loading} fallback={
<Show when={props.icon}>
{(Icon) => <Icon class="-ml-1 mr-2 h-5 w-5" />}
</Show>
}>
<Loader2 class="animate-spin -ml-1 mr-2 h-5 w-5" />
</Show>
{props.children}
</button>
);
};
SolidJS pattern: splitProps separates custom props from native HTML attributes, allowing safe spreading of rest onto the DOM element. This replaces the React pattern of destructuring plus ...rest.
src/components/ui/ConfirmDialog.tsx
interface ConfirmDialogProps {
open: boolean;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: 'danger' | 'warning';
loading?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
SolidJS patterns:
<Show when={props.open}>to mount/unmount<Portal>fromsolid-js/webto render at document root (proper overlay stacking)- Focus trap and keyboard handling via
createEffect+onCleanup
src/components/ui/Spinner.tsx
interface SpinnerProps {
fullPage?: boolean;
size?: 'sm' | 'md' | 'lg';
}
Preserves the current indigo spinner style: animate-spin rounded-full border-b-2 border-indigo-600.
src/components/ui/SSEProgressIndicator.tsx
interface SSEProgressIndicatorProps {
progress: SSEProgressEvent | null;
steps: Array<{ key: string; label: string }>;
}
Renders:
- A progress bar (0-100%) with indigo fill
- A step list with status indicators (done checkmark, in-progress spinner, pending gray)
- Current step description text
src/components/ui/Toast.tsx and src/context/ToastContext.tsx
Global toast notification system. Replaces the scattered success/error inline messages with a unified notification system.
// Toast types
interface Toast {
id: string;
type: 'success' | 'error' | 'info';
message: string;
duration?: number; // ms, default 5000
}
// Context
interface ToastContextType {
addToast: (toast: Omit<Toast, 'id'>) => void;
removeToast: (id: string) => void;
}
SolidJS patterns:
createContext+useContextfor global accesscreateStore<Toast[]>for the toast queue<For each={toasts}>to render active toasts<Portal>to render at document root (top-right corner)- Auto-dismiss via
setTimeoutincreateEffect, cleaned up withonCleanup
src/components/ui/ErrorBanner.tsx and src/components/ui/SuccessBanner.tsx
Inline feedback components preserving the current visual style:
- Error: red background, left border, AlertCircle icon
- Success: green background, left border, CheckCircle2 icon
interface BannerProps {
message: string;
}
src/components/ui/EmptyState.tsx
Reusable empty state placeholder with icon, title, description, and optional CTA button.
interface EmptyStateProps {
icon: Component;
title: string;
description: string;
actionLabel?: string;
actionHref?: string;
onAction?: () => void;
}
src/components/synthesis/SynthesisCard.tsx
Extracted from current Home.tsx inline card rendering. Displays week badge, creation date, first section preview, "read" link, delete button.
interface SynthesisCardProps {
synthesis: Synthesis;
onDelete: () => void;
}
src/components/synthesis/SynthesisSection.tsx
Extracted from current SynthesisDetail.tsx Section component. Renders a section title with border and list of NewsItemCards.
interface SynthesisSectionProps {
title: string;
items: NewsItem[];
}
src/components/synthesis/NewsItemCard.tsx
Individual news item: title as external link, summary paragraph.
interface NewsItemCardProps {
item: NewsItem;
}
3. State Management
3.1 Auth State -- src/context/AuthContext.tsx
The auth context is the most critical global state. It replaces Firebase's onAuthStateChanged with a session check on app load.
import { createContext, useContext, createSignal, onMount, ParentComponent } from 'solid-js';
import { authApi } from '~/api/auth';
import type { User } from '~/types/auth';
interface AuthContextType {
user: () => User | null;
loading: () => boolean;
isAdmin: () => boolean;
login: (email: string, captchaToken: string) => Promise<void>;
register: (email: string, displayName: string, captchaToken: string) => Promise<void>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType>();
export const AuthProvider: ParentComponent = (props) => {
const [user, setUser] = createSignal<User | null>(null);
const [loading, setLoading] = createSignal(true);
const isAdmin = () => user()?.role === 'admin';
// Check session on app load
onMount(async () => {
try {
const currentUser = await authApi.me();
setUser(currentUser);
} catch {
setUser(null);
} finally {
setLoading(false);
}
});
const login = async (email: string, captchaToken: string) => {
await authApi.login({ email, captcha_token: captchaToken });
// Does not set user -- magic link flow. User will be set after verify.
};
const register = async (email: string, displayName: string, captchaToken: string) => {
await authApi.register({ email, display_name: displayName, captcha_token: captchaToken });
};
const logout = async () => {
await authApi.logout();
setUser(null);
};
const refreshUser = async () => {
try {
const currentUser = await authApi.me();
setUser(currentUser);
} catch {
setUser(null);
}
};
return (
<AuthContext.Provider value={{ user, loading, isAdmin, login, register, logout, refreshUser }}>
{props.children}
</AuthContext.Provider>
);
};
export const useAuth = (): AuthContextType => {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
};
Key difference from React: The user and loading are accessor functions (SolidJS signals), not plain values. Consumers call user() and loading() to read them. This enables fine-grained reactivity -- only the specific DOM nodes that read user() will update when it changes, not the entire component tree.
3.2 When to Use Each State Primitive
| Primitive | When to use | Example |
|---|---|---|
createSignal<T> |
Simple, local component state. Single value. | const [email, setEmail] = createSignal('') |
createStore<T> |
Complex, nested objects or arrays. When you need path-based updates. | Settings form, categories list, admin provider config |
createResource<T> |
Async data fetching tied to a reactive source. Provides .loading, .error. |
API calls: fetch syntheses, fetch settings, etc. |
createContext |
Global state shared across many components. Rarely changes (auth, i18n, theme). | AuthContext, ToastContext, I18nContext |
createEffect |
Side effects that react to signal changes. Replaces useEffect. |
SSE connections, timers, DOM manipulation |
createMemo |
Derived computed values (cached). | Filtered/sorted lists, computed display strings |
3.3 Global vs. Local State
Global (via Context):
- Auth state (user, loading, isAdmin) -- needed in Navbar, ProtectedRoute, every page
- Toast notifications -- triggered from any component
- i18n translations -- accessed in every component
Local (via createSignal/createStore):
- Form state (Settings, Sources add form, Login form)
- Page-specific loading/error state
- UI state (mobileMenuOpen, showDeleteConfirm, deletingId)
- SSE progress state (only relevant to GenerateSynthesis page)
3.4 API Client Abstraction -- src/api/client.ts
A centralized fetch wrapper that handles session cookies, CSRF headers, error parsing, and 401 redirect.
// src/api/client.ts
const API_BASE = '/api/v1';
interface ApiError {
status: number;
message: string;
fieldErrors?: Record<string, string>;
}
class ApiClient {
private async request<T>(
method: string,
path: string,
options?: {
body?: unknown;
headers?: Record<string, string>;
signal?: AbortSignal;
}
): Promise<T> {
const url = `${API_BASE}${path}`;
const headers: Record<string, string> = {
'X-Requested-With': 'XMLHttpRequest', // CSRF protection
...options?.headers,
};
if (options?.body && !(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
}
const response = await fetch(url, {
method,
headers,
body: options?.body instanceof FormData
? options.body
: options?.body
? JSON.stringify(options.body)
: undefined,
credentials: 'same-origin', // Send session cookie
signal: options?.signal,
});
if (!response.ok) {
if (response.status === 401) {
// Session expired -- redirect to login
window.location.href = '/login';
throw new Error('Session expired');
}
const errorBody = await response.json().catch(() => ({ error: 'Unknown error' }));
const apiError: ApiError = {
status: response.status,
message: errorBody.error || `HTTP ${response.status}`,
fieldErrors: errorBody.field_errors,
};
throw apiError;
}
// Handle empty responses (204 No Content)
if (response.status === 204) {
return undefined as T;
}
return response.json();
}
get<T>(path: string, signal?: AbortSignal): Promise<T> {
return this.request<T>('GET', path, { signal });
}
post<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>('POST', path, { body });
}
put<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>('PUT', path, { body });
}
delete<T>(path: string): Promise<T> {
return this.request<T>('DELETE', path);
}
upload<T>(path: string, formData: FormData): Promise<T> {
return this.request<T>('POST', path, { body: formData });
}
downloadBlob(path: string): Promise<Blob> {
return fetch(`${API_BASE}${path}`, {
headers: { 'X-Requested-With': 'XMLHttpRequest' },
credentials: 'same-origin',
}).then((r) => {
if (!r.ok) throw new Error(`Download failed: ${r.status}`);
return r.blob();
});
}
}
export const api = new ApiClient();
CSRF protection: The X-Requested-With: XMLHttpRequest header is sent with every request. Combined with SameSite=Lax cookies, this prevents CSRF attacks (per the project's decision to use the Architect's simpler CSRF approach).
401 handling: On any 401 response, the client redirects to /login. This handles session expiry gracefully.
Domain-specific API modules (e.g., src/api/syntheses.ts) wrap the client:
// src/api/syntheses.ts
import { api } from './client';
import type { Synthesis, GenerateResponse } from '~/types/synthesis';
export const synthesesApi = {
list: () => api.get<Synthesis[]>('/syntheses'),
get: (id: string) => api.get<Synthesis>(`/syntheses/${id}`),
generate: () => api.post<GenerateResponse>('/syntheses/generate'),
delete: (id: string) => api.delete(`/syntheses/${id}`),
sendEmail: (id: string, recipientEmail: string) =>
api.post(`/syntheses/${id}/email`, { recipient_email: recipientEmail }),
};
4. Routing
4.1 Router Configuration
Using @solidjs/router (file-based or config-based). The configuration is in App.tsx (shown in Section 2.1).
4.2 Route Definitions
| Path | Component | Guard | Description |
|---|---|---|---|
/login |
Login |
Public (redirect to / if logged in) |
Magic link request |
/register |
Register |
Public (redirect to / if logged in) |
Account creation |
/auth/verify |
MagicLinkVerify |
Public | Token verification from magic link |
/ |
Home |
Authenticated | Synthesis list dashboard |
/generate |
GenerateSynthesis |
Authenticated | Trigger generation |
/synthesis/:id |
SynthesisDetail |
Authenticated | Read synthesis detail |
/sources |
Sources |
Authenticated | Manage custom sources |
/settings |
Settings |
Authenticated | User settings |
/admin/providers |
ProviderCatalog |
Admin | LLM provider config |
/admin/rate-limits |
RateLimitConfig |
Admin | Rate limit settings |
/admin/users |
UserManagement |
Admin | User management |
4.3 Route Guards
Authenticated guard (ProtectedRoute): Wraps authenticated route groups as a layout. Checks useAuth().user(). Redirects to /login if null after loading.
Admin guard (ProtectedAdminShell): Same as authenticated plus checks useAuth().isAdmin(). Redirects to / if not admin.
// ProtectedAppShell -- authenticated routes layout
const ProtectedAppShell: Component = (props) => {
const { user, loading } = useAuth();
return (
<Show when={!loading()} fallback={<Spinner fullPage />}>
<Show when={user()} fallback={<Navigate href="/login" />}>
<AppShell>{props.children}</AppShell>
</Show>
</Show>
);
};
// ProtectedAdminShell -- admin routes layout
const ProtectedAdminShell: Component = (props) => {
const { user, loading, isAdmin } = useAuth();
return (
<Show when={!loading()} fallback={<Spinner fullPage />}>
<Show when={user() && isAdmin()} fallback={<Navigate href="/" />}>
<AdminLayout>{props.children}</AdminLayout>
</Show>
</Show>
);
};
4.4 Navigation Patterns
Programmatic navigation:
const navigate = useNavigate();
// After successful generation
navigate(`/synthesis/${data.synthesis_id}`);
// After deletion
navigate('/');
// After login form submit (no navigation -- user checks email)
Link components: Use <A> from @solidjs/router (not <Link>):
import { A } from '@solidjs/router';
<A href="/">Syntheses</A>
<A href={`/synthesis/${synth.id}`}>Lire la synthese</A>
4.5 Magic Link Verification Redirect
- User clicks magic link in email:
https://app.example.com/auth/verify?token=abc123 MagicLinkVerifypage loads, extractstokenfromuseSearchParams()- Calls
GET /api/v1/auth/verify?token=abc123 - Backend verifies token, creates session, sets
Set-Cookieheader in response - Frontend receives success, calls
refreshUser()on AuthContext to load the user - Navigates to
/withreplace: true(replaces history entry so back button does not re-verify)
5. i18n Architecture
5.1 Structure
i18n is implemented as a SolidJS context with typed translation keys and a simple lookup function. No external i18n library is needed for the MVP (French-only), but the structure supports adding languages later.
5.2 Translation File Format
// src/i18n/locales/fr.ts
const fr = {
// Navigation
'nav.syntheses': 'Syntheses',
'nav.sources': 'Sources personnalisees',
'nav.settings': 'Parametres',
'nav.admin': 'Administration',
'nav.logout': 'Deconnexion',
// Auth
'auth.loginTitle': 'AI Weekly Synth',
'auth.loginSubtitle': 'Votre synthese hebdomadaire des actualites IA',
'auth.emailPlaceholder': 'adresse@email.com',
'auth.requestLink': 'Recevoir un lien de connexion',
'auth.noAccount': 'Pas encore de compte ?',
'auth.createAccount': 'Creer un compte',
'auth.hasAccount': 'Deja un compte ?',
'auth.signIn': 'Se connecter',
'auth.linkSent': 'Un lien de connexion vous a ete envoye a {email}.',
'auth.resendLink': 'Renvoyer le lien',
'auth.resendIn': 'Renvoyer dans {seconds}s',
'auth.verifySuccess': 'Connexion reussie ! Redirection...',
'auth.verifyError': 'Le lien est invalide ou a expire.',
'auth.sessionExpired': 'Votre session a expire. Veuillez vous reconnecter.',
// Home
'home.title': "Syntheses d'Actualites par IA",
'home.subtitle': 'Retrouvez ici toutes vos syntheses hebdomadaires generees automatiquement.',
'home.newSynthesis': 'Nouvelle Synthese',
'home.empty.title': 'Aucune synthese',
'home.empty.description': 'Commencez par generer votre premiere synthese hebdomadaire.',
'home.empty.action': 'Generer',
'home.weekLabel': 'Semaine {week}',
'home.readSynthesis': 'Lire la synthese',
'home.generationInProgress': 'Une generation est en cours...',
'home.viewProgress': 'Voir la progression',
// Generate
'generate.title': 'Generer la Synthese Hebdomadaire',
'generate.description': "Cette action va lancer l'analyse des actualites des {days} derniers jours sur le theme \"{theme}\" via {provider} ({model}).",
'generate.note': 'Note : La generation peut prendre jusqu\'a une minute.',
'generate.noWebSearch': "Note : Le fournisseur selectionne ne dispose pas de la recherche web integree. Les resultats seront bases sur les connaissances du modele uniquement.",
'generate.start': 'Lancer la generation',
'generate.canLeave': 'Vous pouvez quitter cette page. La generation continuera en arriere-plan.',
'generate.success': 'Synthese generee avec succes ! Redirection...',
'generate.steps.search': "Recherche d'actualites",
'generate.steps.scraping': 'Verification des sources',
'generate.steps.rewrite': 'Redaction des resumes',
'generate.steps.saving': 'Sauvegarde',
// Synthesis Detail
'detail.backToList': 'Retour aux syntheses',
'detail.weekTitle': 'Synthese de la Semaine {week}',
'detail.generatedOn': 'Generee le {date}',
'detail.sendEmail': 'Envoyer par email',
'detail.sending': 'Envoi en cours...',
'detail.emailSent': "L'email a ete envoye avec succes !",
'detail.exportPdf': 'Exporter en PDF',
'detail.exportMarkdown': 'Exporter en Markdown',
'detail.delete': 'Supprimer',
'detail.deleteConfirmTitle': 'Supprimer cette synthese ?',
'detail.deleteConfirmMessage': 'Etes-vous sur de vouloir supprimer cette synthese definitivement ?',
'detail.deleteConfirm': 'Confirmer la suppression',
'detail.cancel': 'Annuler',
// Sources
'sources.title': 'Sources Personnalisees',
'sources.subtitle': "Ajoutez des sites web ou des blogs que l'IA devra obligatoirement consulter lors de la generation de vos syntheses.",
'sources.addTitle': 'Ajouter une source',
'sources.titlePlaceholder': 'Nom de la source (ex: Blog de Yann LeCun)',
'sources.urlPlaceholder': 'https://...',
'sources.add': 'Ajouter',
'sources.csvSection': 'Import / Export CSV',
'sources.csvDescription': 'Sauvegardez vos sources ou importez-en de nouvelles depuis un fichier CSV.',
'sources.exportCsv': 'Exporter en CSV',
'sources.importCsv': 'Importer depuis un CSV',
'sources.bulkSection': 'Import en masse',
'sources.bulkDescription': "Ajoutez plusieurs sources d'un coup. Une source par ligne, au format : Nom de la source;URL",
'sources.bulkPlaceholder': 'Blog IA;https://blog.ia.com\nNews Tech;https://tech.news.fr',
'sources.bulkImport': 'Importer les sources',
'sources.importing': 'Importation...',
'sources.empty': 'Aucune source personnalisee pour le moment.',
'sources.deleteConfirmTitle': 'Supprimer cette source ?',
'sources.deleteConfirmMessage': 'Cette source ne sera plus consultee lors des prochaines generations.',
// Settings
'settings.title': 'Parametres de generation',
'settings.export': 'Exporter',
'settings.import': 'Importer',
'settings.theme': 'Theme de la recherche',
'settings.themeHelp': "Le sujet principal pour la recherche d'actualites.",
'settings.maxAgeDays': 'Anciennete maximum (jours)',
'settings.maxItems': 'Actualites max par categorie',
'settings.searchBehavior': "Comportement de l'agent de recherche",
'settings.searchBehaviorHelp': "Personnalisez les instructions donnees a l'IA concernant sa methode de recherche.",
'settings.provider': "Fournisseur d'IA",
'settings.model': 'Modele',
'settings.apiKey': 'Cle API',
'settings.apiKeyPlaceholder': 'Entrez votre cle API pour ce fournisseur',
'settings.categories': "Categories d'actualite",
'settings.addCategory': 'Ajouter',
'settings.newCategory': 'Nouvelle categorie',
'settings.save': 'Enregistrer les parametres',
'settings.saved': 'Parametres enregistres avec succes.',
'settings.saveError': "Erreur lors de l'enregistrement des parametres.",
'settings.importSuccess': "Configuration importee avec succes. N'oubliez pas d'enregistrer.",
'settings.importError': "Erreur lors de l'importation du fichier JSON.",
'settings.providerWarning': "Le fournisseur que vous utilisiez n'est plus disponible. Veuillez en selectionner un autre.",
// Admin
'admin.providers.title': 'Configuration des fournisseurs LLM',
'admin.providers.add': 'Ajouter un fournisseur',
'admin.providers.name': 'Nom du fournisseur',
'admin.providers.models': 'Modeles disponibles',
'admin.providers.defaultModel': 'Modele par defaut',
'admin.providers.enabled': 'Active',
'admin.providers.save': 'Enregistrer',
'admin.rateLimits.title': "Configuration des limites d'usage",
'admin.rateLimits.globalSection': 'Limites globales',
'admin.rateLimits.perProvider': 'Par fournisseur',
'admin.rateLimits.requestsPerMinute': 'Requetes max / minute',
'admin.rateLimits.save': 'Enregistrer',
'admin.users.title': 'Gestion des utilisateurs',
'admin.users.email': 'Email',
'admin.users.role': 'Role',
'admin.users.createdAt': "Date d'inscription",
// Common
'common.loading': 'Chargement...',
'common.error': 'Une erreur est survenue.',
'common.retry': 'Reessayer',
'common.confirm': 'Confirmer',
'common.cancel': 'Annuler',
'common.delete': 'Supprimer',
'common.save': 'Enregistrer',
} as const;
export type TranslationKey = keyof typeof fr;
export default fr;
5.3 i18n Context and Hook
// src/i18n/index.ts
import { createContext, useContext, ParentComponent, createMemo } from 'solid-js';
import frTranslations, { type TranslationKey } from './locales/fr';
type Translations = Record<TranslationKey, string>;
interface I18nContextType {
t: (key: TranslationKey, params?: Record<string, string | number>) => string;
locale: () => string;
}
const translations: Record<string, Translations> = {
fr: frTranslations,
};
const I18nContext = createContext<I18nContextType>();
export const I18nProvider: ParentComponent<{ locale: string }> = (props) => {
const locale = () => props.locale;
const t = (key: TranslationKey, params?: Record<string, string | number>): string => {
let value = translations[locale()]?.[key] ?? key;
if (params) {
Object.entries(params).forEach(([k, v]) => {
value = value.replace(`{${k}}`, String(v));
});
}
return value;
};
return (
<I18nContext.Provider value={{ t, locale }}>
{props.children}
</I18nContext.Provider>
);
};
export const useI18n = (): I18nContextType => {
const ctx = useContext(I18nContext);
if (!ctx) throw new Error('useI18n must be used within I18nProvider');
return ctx;
};
5.4 Adding a New Language Later
- Create
src/i18n/locales/en.tswith the same key structure. - Import it in
src/i18n/index.tsand add to thetranslationsmap. - Make
localea signal (e.g., from user preferences or browser language). - No changes to any component -- all text comes from
t('key').
6. SSE Integration
6.1 EventSource Wrapper -- src/lib/sse.ts
A SolidJS-friendly wrapper around EventSource that maps server events to signal-based callbacks with reconnection logic.
// src/lib/sse.ts
interface SSEOptions {
maxRetries?: number; // Default: 3
retryDelay?: number; // Default: 1000ms, doubles each retry
onOpen?: () => void;
onError?: (error: Event) => void;
}
interface SSEConnection {
on: (eventType: string, handler: (data: any) => void) => void;
close: () => void;
isConnected: () => boolean;
}
export function createSSEConnection(url: string, options: SSEOptions = {}): SSEConnection {
const maxRetries = options.maxRetries ?? 3;
const baseDelay = options.retryDelay ?? 1000;
let eventSource: EventSource | null = null;
let retryCount = 0;
let closed = false;
const handlers = new Map<string, Array<(data: any) => void>>();
const connect = () => {
if (closed) return;
eventSource = new EventSource(url, { withCredentials: true });
eventSource.onopen = () => {
retryCount = 0;
options.onOpen?.();
};
eventSource.onerror = (event) => {
options.onError?.(event);
eventSource?.close();
if (!closed && retryCount < maxRetries) {
const delay = baseDelay * Math.pow(2, retryCount);
retryCount++;
setTimeout(connect, delay);
}
};
// Register all known event handlers
handlers.forEach((handlerList, eventType) => {
handlerList.forEach((handler) => {
eventSource!.addEventListener(eventType, (event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
handler(data);
} catch {
handler(event.data);
}
});
});
});
};
const on = (eventType: string, handler: (data: any) => void) => {
if (!handlers.has(eventType)) {
handlers.set(eventType, []);
}
handlers.get(eventType)!.push(handler);
// If already connected, add listener to existing eventSource
if (eventSource && eventSource.readyState !== EventSource.CLOSED) {
eventSource.addEventListener(eventType, (event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
handler(data);
} catch {
handler(event.data);
}
});
}
};
const close = () => {
closed = true;
eventSource?.close();
eventSource = null;
};
const isConnected = () =>
eventSource !== null && eventSource.readyState === EventSource.OPEN;
// Start connection
connect();
return { on, close, isConnected };
}
6.2 Generation Progress Display
Used in GenerateSynthesis.tsx:
// In GenerateSynthesis.tsx
createEffect(() => {
const id = jobId();
if (!id) return;
const sse = createSSEConnection(
`/api/v1/syntheses/generate/${id}/progress`
);
sse.on('progress', (data: SSEProgressEvent) => {
setProgress(data);
});
sse.on('complete', (data: { synthesis_id: string }) => {
setStatus('complete');
setTimeout(() => navigate(`/synthesis/${data.synthesis_id}`), 1500);
});
sse.on('error', (data: { message: string }) => {
setStatus('error');
setErrorMessage(data.message);
});
onCleanup(() => sse.close());
});
6.3 Real-Time List Updates
For the Home page (synthesis list) and Sources page, SSE provides real-time updates when data changes server-side.
Pattern: After initial createResource fetch, open an SSE connection to an events endpoint. Use mutate() from createResource to update the local data.
// Home page SSE for list updates
createEffect(() => {
const sse = createSSEConnection('/api/v1/syntheses/events');
sse.on('created', (synthesis: Synthesis) => {
mutate((prev) => prev ? [synthesis, ...prev] : [synthesis]);
});
sse.on('deleted', (data: { id: string }) => {
mutate((prev) => prev?.filter((s) => s.id !== data.id) ?? []);
});
sse.on('updated', (synthesis: Synthesis) => {
mutate((prev) =>
prev?.map((s) => (s.id === synthesis.id ? synthesis : s)) ?? []
);
});
onCleanup(() => sse.close());
});
6.4 Reconnection Strategy
- Exponential backoff: Start at 1s, double each retry, max 3 retries.
- On reconnect: The client re-opens the SSE connection. The backend should replay any missed events since the last event ID (using
Last-Event-Idheader). - On permanent failure (after max retries): Fall back to polling via
setInterval+refetch()every 10 seconds. - On 401: Do not retry. Redirect to login (session expired).
7. Tailwind CSS
7.1 Replicating the Current Visual Design
The current app uses a consistent indigo-based design system via Tailwind utility classes. Since Tailwind is framework-agnostic, the same utility classes transfer directly to SolidJS JSX with one systematic change: className becomes class.
Color scheme to preserve:
- Primary:
indigo-600(buttons, links, badges, focus rings),indigo-700(hover) - Background:
gray-50(page),white(cards) - Text:
gray-900(headings),gray-700(body),gray-500(secondary),gray-400(icons) - Borders:
gray-200(cards),gray-300(inputs),indigo-300(hover accent) - Success:
green-50/green-400/green-600/green-800 - Error:
red-50/red-400/red-600/red-800
Card patterns:
<!-- Standard card -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<!-- Hoverable card (synthesis list) -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 hover:shadow-md hover:border-indigo-300 transition-all duration-200">
Button patterns:
<!-- Primary -->
<button class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<!-- Secondary -->
<button class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<!-- Danger -->
<button class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700">
Form input patterns:
<input class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md py-2 px-3 border">
7.2 Tailwind v4 Configuration
Tailwind v4 uses CSS-based configuration. The index.css file:
/* src/index.css */
@import "tailwindcss";
/* Custom theme overrides (if needed beyond defaults) */
@theme {
--color-primary: var(--color-indigo-600);
--color-primary-hover: var(--color-indigo-700);
}
Tailwind v4 auto-detects content sources from the Vite plugin, so no explicit content configuration is needed.
7.3 Responsive Design Patterns
The current app uses these breakpoint patterns (to preserve):
| Pattern | Usage |
|---|---|
sm:grid-cols-2 lg:grid-cols-3 |
Synthesis card grid on Home |
sm:flex sm:space-y-0 sm:space-x-4 |
Source add form (stack on mobile, row on desktop) |
sm:px-6 lg:px-8 |
Content area padding |
max-w-5xl |
Home page width |
max-w-4xl |
Sources, SynthesisDetail |
max-w-3xl |
Settings, GenerateSynthesis |
max-w-md |
Login/Register |
New: Mobile navigation fix.
The current app hides nav links on mobile with hidden sm:flex. The new version adds a hamburger menu:
<!-- Mobile hamburger button (visible only on small screens) -->
<button class="sm:hidden p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100">
<Menu class="h-6 w-6" />
</button>
<!-- Desktop nav links (hidden on mobile) -->
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
...
</div>
8. Forms and Validation
8.1 Form Handling Pattern
SolidJS forms use createStore for complex form state (like Settings with nested categories) and createSignal for simple forms (like login email field).
Simple form (Login):
const [email, setEmail] = createSignal('');
const [error, setError] = createSignal<string | null>(null);
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
const validationError = validateEmail(email());
if (validationError) {
setError(validationError);
return;
}
// ... submit
};
Complex form (Settings):
import { createStore, produce } from 'solid-js/store';
const [form, setForm] = createStore<UserSettings>({ ...DEFAULT_SETTINGS });
const [errors, setErrors] = createStore<Record<string, string>>({});
// Path-based update
setForm('theme', 'Cybersecurite');
setForm('categories', 2, 'New category name');
// Array mutations via produce
setForm(produce((draft) => {
draft.categories.splice(index, 1);
}));
8.2 Validation Approach
Client-side validation runs on submit (not on every keystroke, to avoid aggressive UX). Field-level errors display below each field.
// src/lib/validation.ts
export interface ValidationRule {
test: (value: any) => boolean;
message: string;
}
export const required = (fieldName: string): ValidationRule => ({
test: (v) => v !== null && v !== undefined && String(v).trim() !== '',
message: `${fieldName} est requis.`,
});
export const email = (): ValidationRule => ({
test: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(v)),
message: 'Adresse email invalide.',
});
export const url = (): ValidationRule => ({
test: (v) => {
try {
new URL(String(v));
return true;
} catch {
return false;
}
},
message: 'URL invalide.',
});
export const minLength = (min: number): ValidationRule => ({
test: (v) => String(v).length >= min,
message: `Minimum ${min} caracteres.`,
});
export const maxLength = (max: number): ValidationRule => ({
test: (v) => String(v).length <= max,
message: `Maximum ${max} caracteres.`,
});
export const numberRange = (min: number, max: number): ValidationRule => ({
test: (v) => Number(v) >= min && Number(v) <= max,
message: `Doit etre entre ${min} et ${max}.`,
});
export function validate(
value: any,
rules: ValidationRule[]
): string | null {
for (const rule of rules) {
if (!rule.test(value)) return rule.message;
}
return null;
}
export function validateForm(
fields: Record<string, { value: any; rules: ValidationRule[] }>
): Record<string, string> {
const errors: Record<string, string> = {};
for (const [key, { value, rules }] of Object.entries(fields)) {
const error = validate(value, rules);
if (error) errors[key] = error;
}
return errors;
}
8.3 Error Display Pattern
The FormField component wraps inputs with label and error display:
// src/components/forms/FormField.tsx
interface FormFieldProps {
label: string;
error?: string;
helpText?: string;
required?: boolean;
children: JSX.Element;
}
const FormField: Component<FormFieldProps> = (props) => {
return (
<div>
<label class="block text-sm font-medium text-gray-700">
{props.label}
<Show when={props.required}>
<span class="text-red-500 ml-1">*</span>
</Show>
</label>
<div class="mt-1">
{props.children}
</div>
<Show when={props.error}>
<p class="mt-1 text-sm text-red-600">{props.error}</p>
</Show>
<Show when={props.helpText && !props.error}>
<p class="mt-1 text-sm text-gray-500">{props.helpText}</p>
</Show>
</div>
);
};
9. PDF/Markdown Export
9.1 Client-Side PDF Generation
Using jspdf + jspdf-autotable for PDF generation. No server round-trip needed.
// src/lib/export.ts
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import { format } from 'date-fns';
import { fr } from 'date-fns/locale';
import type { Synthesis, NewsSection } from '~/types/synthesis';
export function generatePdf(synthesis: Synthesis): void {
const doc = new jsPDF();
const weekNum = synthesis.week.split('-W')[1];
// Title
doc.setFontSize(20);
doc.text(`Synthese de la Semaine ${weekNum}`, 14, 22);
// Date
doc.setFontSize(10);
doc.setTextColor(100);
doc.text(
`Generee le ${format(new Date(synthesis.created_at), 'dd MMMM yyyy', { locale: fr })}`,
14,
30
);
let yOffset = 40;
for (const section of synthesis.sections) {
// Section title
doc.setFontSize(14);
doc.setTextColor(0);
doc.text(section.title, 14, yOffset);
yOffset += 8;
// Table of items
autoTable(doc, {
startY: yOffset,
head: [['Titre', 'Resume']],
body: section.items.map((item) => [item.title, item.summary]),
styles: { fontSize: 9, cellPadding: 4 },
headStyles: { fillColor: [79, 70, 229] }, // indigo-600
columnStyles: {
0: { cellWidth: 60 },
1: { cellWidth: 'auto' },
},
didDrawPage: (data) => {
yOffset = data.cursor?.y ?? yOffset;
},
});
yOffset = (doc as any).lastAutoTable.finalY + 12;
// Add new page if needed
if (yOffset > 270) {
doc.addPage();
yOffset = 20;
}
}
doc.save(`synthese-semaine-${weekNum}.pdf`);
}
9.2 Markdown Generation
export function generateMarkdown(synthesis: Synthesis): void {
const weekNum = synthesis.week.split('-W')[1];
const date = format(new Date(synthesis.created_at), 'dd MMMM yyyy', { locale: fr });
let md = `# Synthese de la Semaine ${weekNum}\n\n`;
md += `*Generee le ${date}*\n\n`;
for (const section of synthesis.sections) {
md += `## ${section.title}\n\n`;
for (const item of section.items) {
md += `### [${item.title}](${item.url})\n\n`;
md += `${item.summary}\n\n`;
}
}
// Trigger download
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `synthese-semaine-${weekNum}.md`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
9.3 Integration in SynthesisDetail
// In SynthesisDetail.tsx action bar
<div class="flex gap-3">
<Button variant="secondary" icon={FileDown} onClick={() => generatePdf(synthesis()!)}>
{t('detail.exportPdf')}
</Button>
<Button variant="secondary" icon={FileText} onClick={() => generateMarkdown(synthesis()!)}>
{t('detail.exportMarkdown')}
</Button>
</div>
10. React-to-SolidJS Migration Map
10.1 File Mapping Table
| React File | SolidJS File | Key Differences | Gotchas |
|---|---|---|---|
src/App.tsx (Login) |
src/pages/auth/Login.tsx |
Google SSO button replaced by email+Turnstile form. Magic link flow. | Turnstile requires imperative script loading in onMount |
src/App.tsx (ProtectedRoute) |
src/components/auth/ProtectedRoute.tsx |
if/return pattern replaced by nested <Show>. useAuth() returns accessor functions, not values. |
Must call user() not just user to read the signal |
src/App.tsx (Layout) |
src/components/layout/AppShell.tsx + Navbar.tsx + MobileMenu.tsx |
Split into 3 components. Added mobile menu. Added active route indicator. className -> class. |
props.children not {children} destructure |
src/App.tsx (Router) |
src/App.tsx |
BrowserRouter/Routes/Route -> Router/Route from @solidjs/router. Nested routes replace per-route <ProtectedRoute> wrapping. |
Route nesting is structural (layout-based), not wrapping |
src/components/AuthContext.tsx |
src/context/AuthContext.tsx |
useState/useEffect/onAuthStateChanged -> createSignal/onMount/GET /api/v1/auth/me. Firebase auth removed entirely. |
Signal accessors: user() is a function call |
src/pages/Home.tsx |
src/pages/Home.tsx |
useState+useEffect+onSnapshot -> createResource+SSE. {array.map()} -> <For each={}>. className -> class. Firestore Timestamp -> ISO date string. |
<For> callback receives item as argument, not destructured. format(synth.createdAt.toDate(), ...) becomes format(new Date(synth.created_at), ...) |
src/pages/GenerateSynthesis.tsx |
src/pages/GenerateSynthesis.tsx |
Client-side Gemini call replaced by POST /api/v1/syntheses/generate + SSE progress. Single spinner replaced by multi-step progress indicator. |
SSE connection must be cleaned up with onCleanup. Job continues server-side if user navigates away. |
src/pages/SynthesisDetail.tsx |
src/pages/SynthesisDetail.tsx |
onSnapshot -> createResource. Gmail OAuth email -> POST /api/v1/syntheses/:id/email. Added PDF/Markdown export buttons. Legacy data rendering removed. Hardcoded email removed. |
useParams() returns a reactive proxy -- do not destructure. Use params.id directly. |
src/pages/Sources.tsx |
src/pages/Sources.tsx |
onSnapshot -> createResource + SSE. React.FormEvent -> SubmitEvent. React.ChangeEvent<HTMLInputElement> -> Event. CSV import as multipart to backend endpoint. Added delete confirmation. |
File input onChange types differ -- use (e.target as HTMLInputElement).files |
src/pages/Settings.tsx |
src/pages/Settings.tsx |
useState<AppSettings> -> createStore<UserSettings>. Hardcoded model options -> dynamic from GET /api/v1/config/providers. Added provider dropdown and API key field. |
createStore uses path-based setters, not spread-based. Category array updates need produce() or index-based setters. |
src/types.ts |
src/types/ (split into multiple files) |
Firestore Timestamp removed. SynthesisData legacy fields removed. New types for auth, admin, providers. camelCase -> snake_case for API fields. |
Ensure frontend types match backend JSON field names (snake_case) |
src/index.css |
src/index.css |
Identical: @import "tailwindcss" |
No change needed |
src/firebase.ts |
Removed entirely | Replaced by src/api/client.ts + domain-specific API modules |
-- |
src/services/geminiService.ts |
Removed entirely | All LLM logic moves to Rust backend | -- |
10.2 Pattern Mapping Reference
| React Pattern | SolidJS Equivalent | Notes |
|---|---|---|
useState(initialValue) |
createSignal(initialValue) |
Returns [getter, setter] where getter is a function |
useState({...complex}) |
createStore({...complex}) |
For nested objects. Use path-based setters. |
useEffect(() => {}, [deps]) |
createEffect(() => {}) |
Auto-tracks dependencies. No dep array needed. |
useEffect(() => { return cleanup }, []) |
onMount(() => {}); onCleanup(() => {}) |
Separate mount and cleanup |
useRef() |
let ref: HTMLElement | undefined |
Direct variable assignment. Pass via ref={el => ref = el} |
useMemo(() => computed, [deps]) |
createMemo(() => computed) |
Auto-tracks. Returns accessor function. |
useCallback(() => fn, [deps]) |
Just use the function directly | SolidJS does not re-render, so memoizing callbacks is unnecessary |
React.FC<Props> |
Component<Props> (from solid-js) |
-- |
{condition && <Component />} |
<Show when={condition}><Component /></Show> |
<Show> avoids evaluating the false branch |
{condition ? <A /> : <B />} |
<Show when={condition} fallback={<B />}><A /></Show> |
-- |
{items.map(item => <C key={item.id} />)} |
<For each={items}>{(item) => <C />}</For> |
<For> is keyed by reference. Add key via index param if needed. |
<Link to="/"> |
<A href="/"> |
From @solidjs/router |
useParams<{id: string}>() |
useParams() |
Returns reactive proxy. Access params.id, do not destructure. |
useNavigate() |
useNavigate() |
Same API: navigate('/path') |
className="..." |
class="..." |
Systematic find-and-replace. class is standard HTML. |
onChange={(e) => setValue(e.target.value)} |
onInput={(e) => setValue(e.currentTarget.value)} |
onInput fires on every keystroke. onChange fires on blur in SolidJS (matching DOM behavior). Use onInput for real-time input binding. |
e: React.ChangeEvent<HTMLInputElement> |
e: InputEvent & { currentTarget: HTMLInputElement } |
Native DOM event types |
e: React.FormEvent |
e: SubmitEvent |
Native DOM event type |
dangerouslySetInnerHTML |
innerHTML (JSX attribute) |
Both should be avoided when possible |
11. Testing
11.1 Setup
Vitest is configured automatically via the Vite config. Additional setup for SolidJS:
// vitest.config.ts (or in vite.config.ts)
/// <reference types="vitest" />
import { defineConfig } from 'vite';
import solid from 'vite-plugin-solid';
export default defineConfig({
plugins: [solid()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test-setup.ts'],
transformMode: {
web: [/\.[jt]sx?$/],
},
},
});
// src/test-setup.ts
import '@testing-library/jest-dom';
11.2 Component Test Examples
Testing the Spinner component:
// src/components/ui/Spinner.test.tsx
import { render, screen } from '@solidjs/testing-library';
import Spinner from './Spinner';
describe('Spinner', () => {
it('renders a spinner with default size', () => {
render(() => <Spinner />);
const spinner = screen.getByRole('status');
expect(spinner).toBeInTheDocument();
});
it('renders full-page spinner when fullPage is true', () => {
render(() => <Spinner fullPage />);
const container = screen.getByRole('status').parentElement;
expect(container).toHaveClass('h-screen');
});
});
Testing the ProtectedRoute:
// src/components/auth/ProtectedRoute.test.tsx
import { render, screen } from '@solidjs/testing-library';
import { Router } from '@solidjs/router';
import { AuthProvider } from '~/context/AuthContext';
import ProtectedRoute from './ProtectedRoute';
// Mock the auth API
vi.mock('~/api/auth', () => ({
authApi: {
me: vi.fn().mockRejectedValue(new Error('Unauthorized')),
},
}));
describe('ProtectedRoute', () => {
it('shows spinner while loading', () => {
render(() => (
<Router>
<AuthProvider>
<ProtectedRoute>
<div>Protected content</div>
</ProtectedRoute>
</AuthProvider>
</Router>
));
expect(screen.getByRole('status')).toBeInTheDocument();
});
});
Testing the API client:
// src/api/client.test.ts
import { api } from './client';
describe('ApiClient', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('sends X-Requested-With header for CSRF protection', async () => {
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ data: 'test' }), { status: 200 })
);
await api.get('/test');
expect(fetchSpy).toHaveBeenCalledWith(
'/api/v1/test',
expect.objectContaining({
headers: expect.objectContaining({
'X-Requested-With': 'XMLHttpRequest',
}),
})
);
});
it('redirects to /login on 401 response', async () => {
const originalLocation = window.location;
delete (window as any).location;
window.location = { href: '' } as Location;
vi.spyOn(global, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 })
);
await expect(api.get('/protected')).rejects.toThrow('Session expired');
expect(window.location.href).toBe('/login');
window.location = originalLocation;
});
});
11.3 What to Test
| Area | What to test | Priority |
|---|---|---|
| API client | CSRF header sent, 401 redirect, error parsing, JSON serialization | High |
| Auth flow | Session check on mount, login triggers API call, logout clears state, redirect on unauthenticated | High |
| ProtectedRoute | Shows spinner while loading, redirects when unauthenticated, renders children when authenticated | High |
| SSE wrapper | Connection opens, events parsed, reconnection on error, cleanup on close | High |
| Form validation | Email validation, URL validation, required fields, number ranges | Medium |
| SynthesisCard | Renders week number, date, preview items, delete button | Medium |
| Settings form | Category add/remove, provider/model cascading, save triggers API call | Medium |
| PDF export | Generates valid PDF blob (smoke test) | Low |
| i18n | Translation key lookup, parameter interpolation, missing key fallback | Low |
| Toast system | Toast appears, auto-dismisses, multiple toasts stack | Low |
11.4 Integration Test Pattern
For pages that make API calls, mock the API module and test the full page lifecycle:
// src/pages/Home.test.tsx
import { render, screen, waitFor } from '@solidjs/testing-library';
import { Router } from '@solidjs/router';
import Home from './Home';
vi.mock('~/api/syntheses', () => ({
synthesesApi: {
list: vi.fn().mockResolvedValue([
{
id: '1',
week: '2026-W12',
created_at: '2026-03-21T10:00:00Z',
sections: [
{
title: 'Annonces majeures',
items: [{ title: 'Test article', url: 'https://example.com', summary: 'A summary' }],
},
],
},
]),
},
}));
describe('Home', () => {
it('renders synthesis cards after loading', async () => {
render(() => (
<Router>
<Home />
</Router>
));
// Initially shows spinner
expect(screen.getByRole('status')).toBeInTheDocument();
// After loading, shows synthesis card
await waitFor(() => {
expect(screen.getByText('Semaine 12')).toBeInTheDocument();
expect(screen.getByText('Test article')).toBeInTheDocument();
});
});
});
Type Definitions Summary
src/types/auth.ts
export interface User {
id: string;
email: string;
display_name: string | null;
role: 'user' | 'admin';
created_at: string; // ISO 8601
}
export interface RegisterRequest {
email: string;
display_name?: string;
captcha_token: string;
}
export interface LoginRequest {
email: string;
captcha_token: string;
}
export interface AuthMessageResponse {
message: string;
}
src/types/synthesis.ts
export interface NewsItem {
title: string;
url: string;
summary: string;
}
export interface NewsSection {
title: string;
items: NewsItem[];
}
export interface Synthesis {
id: string;
week: string; // e.g., "2026-W12"
created_at: string; // ISO 8601
sections: NewsSection[];
}
export interface GenerateResponse {
job_id: string;
status: 'pending';
}
export interface SSEProgressEvent {
step: 'search' | 'scraping' | 'rewrite' | 'saving';
message: string;
percent: number;
}
export interface SSECompleteEvent {
synthesis_id: string;
}
export interface SSEErrorEvent {
message: string;
}
export type GenerationStatus = 'idle' | 'generating' | 'complete' | 'error';
src/types/source.ts
export interface Source {
id: string;
title: string;
url: string;
created_at: string; // ISO 8601
}
export interface CreateSourceRequest {
title: string;
url: string;
}
export interface BulkImportRequest {
sources: CreateSourceRequest[];
}
src/types/settings.ts
export interface UserSettings {
theme: string;
max_age_days: number;
categories: string[];
max_items_per_category: number;
search_agent_behavior: string;
provider_id: string;
model_name: string;
api_key: string; // User's own API key (sent encrypted by backend)
}
export const DEFAULT_SETTINGS: UserSettings = {
theme: 'Intelligence Artificielle',
max_age_days: 7,
categories: [
'Annonces majeures / importantes',
'Entreprises des secteurs financiers (banques, assurances, etc.)',
'Grandes entreprises des autres secteurs',
'Secteurs publics (Defense, Education, etc.)',
'Grand public / Particuliers',
],
max_items_per_category: 4,
search_agent_behavior:
"Tu peux egalement utiliser d'autres sources pertinentes trouvees via la recherche Google.",
provider_id: '',
model_name: '',
api_key: '',
};
src/types/admin.ts
export interface ProviderConfig {
id: string;
name: string; // e.g., "Google Gemini", "OpenAI", "Anthropic"
slug: string; // e.g., "gemini", "openai", "anthropic"
models: ModelConfig[];
enabled: boolean;
}
export interface ModelConfig {
name: string; // e.g., "gemini-3.1-pro-preview"
display_name: string; // e.g., "Gemini 3.1 Pro (conseille)"
supports_web_search: boolean;
}
export interface RateLimitConfig {
provider_id: string;
provider_name: string;
requests_per_minute: number;
}
export interface AdminUser {
id: string;
email: string;
display_name: string | null;
role: 'user' | 'admin';
created_at: string;
}
src/types/api.ts
export interface ApiError {
status: number;
message: string;
field_errors?: Record<string, string>;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
per_page: number;
}
Appendix: Cloudflare Turnstile TypeScript Declarations
Since Turnstile is loaded via script tag and used imperatively, add type declarations:
// src/types/turnstile.d.ts
declare global {
interface Window {
turnstile: {
render: (
container: HTMLElement,
options: {
sitekey: string;
callback: (token: string) => void;
'expired-callback'?: () => void;
'error-callback'?: () => void;
theme?: 'light' | 'dark' | 'auto';
size?: 'normal' | 'compact';
}
) => string; // Returns widget ID
reset: (widgetId: string) => void;
remove: (widgetId: string) => void;
};
}
}
export {};
Appendix: Environment Variables
// src/vite-env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_TURNSTILE_SITE_KEY: string;
readonly VITE_API_BASE_URL?: string; // Optional override for non-proxy setups
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}