You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

2655 lines
89 KiB
Markdown

# 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
1. [Project Structure](#1-project-structure)
2. [Component Architecture](#2-component-architecture)
3. [State Management](#3-state-management)
4. [Routing](#4-routing)
5. [i18n Architecture](#5-i18n-architecture)
6. [SSE Integration](#6-sse-integration)
7. [Tailwind CSS](#7-tailwind-css)
8. [Forms and Validation](#8-forms-and-validation)
9. [PDF/Markdown Export](#9-pdfmarkdown-export)
10. [React-to-SolidJS Migration Map](#10-react-to-solidjs-migration-map)
11. [Testing](#11-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
```json
{
"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-react` or 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
```typescript
// 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-solid` handles SolidJS JSX transformation (converts JSX to `createComponent` / `template` calls).
- `@tailwindcss/vite` replaces the old PostCSS-based Tailwind pipeline.
- Dev proxy maps `/api/*` to the Rust backend at `localhost: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
```json
// 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.
```typescript
// 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 `user` from `AuthContext`
- 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).
```typescript
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:**
```typescript
interface NavbarProps {
user: User | null;
onToggleMobile: () => void;
}
```
**SolidJS patterns:**
- `useLocation()` from `@solidjs/router` for active route detection
- `<A>` component from `@solidjs/router` (replaces React Router's `<Link>`)
- `<Show when={user()?.role === 'admin'}>` for admin nav items
- `class` attribute (not `className`)
**Active route indicator:** Compute active class with:
```typescript
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:**
```typescript
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 `user` and `loading` from `AuthContext`
- Uses `<Show>` with `fallback` for the loading state
- Uses `<Navigate>` from `@solidjs/router` for redirect
```typescript
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:**
```typescript
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/login` with `{ email, captcha_token }`
**SolidJS patterns:**
- `onMount` to load the Turnstile script if not already loaded
- `createEffect` to manage resend cooldown timer (countdown from 60s)
- `onCleanup` to 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.
```typescript
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/register` with `{ 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:**
```typescript
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/router` to extract the `token` query parameter
- `onMount` to trigger the verification call immediately
- `useNavigate()` to redirect to `/` on success after a brief delay
```typescript
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:**
```typescript
const [syntheses] = createResource(fetchSyntheses);
const [deletingId, setDeletingId] = createSignal<string | null>(null);
const [activeJob, setActiveJob] = createSignal<GenerationJob | null>(null);
```
**API calls:**
- `GET /api/v1/syntheses` via `createResource` for 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:**
- `createResource` replaces `useState` + `useEffect` for async data fetching. It provides `.loading`, `.error`, and `.latest` properties.
- `<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` + `onCleanup` to mutate the resource.
```typescript
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` + `useEffect` with Firestore `onSnapshot` --> `createResource` + SSE via `createEffect`
- `{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:**
```typescript
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/settings` via `createResource` (to show "theme" and "maxAgeDays" in the trigger confirmation text)
- `POST /api/v1/syntheses/generate` to start generation (returns `job_id`)
- SSE: `GET /api/v1/syntheses/generate/:job_id/progress` for progress events
**SolidJS patterns:**
- `createEffect` to open SSE connection when `jobId()` is set
- `onCleanup` to close SSE on unmount (but generation continues server-side)
- `<Switch>/<Match>` for status-based rendering (idle/generating/complete/error)
- `<Show>` for conditional progress display
```typescript
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:
```typescript
// 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:**
```typescript
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/:id` via `createResource` with `params.id` as source
- `POST /api/v1/syntheses/:id/email` with `{ recipient_email }`
- `DELETE /api/v1/syntheses/:id`
**SolidJS patterns:**
- `createResource` with a source signal (`params.id`) -- re-fetches when the param changes
- `useParams()` from `@solidjs/router`
- `<For each={synthesis()?.sections}>` to render sections
- `<Show when={showDeleteConfirm()}>` for delete confirmation (standardized `ConfirmDialog`)
**Export actions:**
- "Export PDF" button calls `generatePdf(synthesis())` from `lib/export.ts`
- "Export Markdown" button calls `generateMarkdown(synthesis())` from `lib/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 Firestore `onSnapshot` --> `createResource` with `params.id` as reactive source
- Gmail API email sending --> `POST /api/v1/syntheses/:id/email` backend 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:**
```typescript
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/sources` via `createResource`
- `POST /api/v1/sources` to add a single source
- `DELETE /api/v1/sources/:id` to delete
- `POST /api/v1/sources/bulk` for bulk import (JSON array)
- `POST /api/v1/sources/import-csv` for CSV file import (multipart)
- `GET /api/v1/sources/export-csv` for 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 `onSubmit` handlers with `preventDefault()`
**Deletion pattern change:** Current app has no delete confirmation on sources. The new version uses the standardized `ConfirmDialog` component for consistency.
**React pattern replaced:**
- `useEffect` with Firestore `onSnapshot` --> `createResource` with SSE for real-time (or refetch after mutations)
- `e: React.FormEvent` --> native `SubmitEvent` (SolidJS does not have synthetic events)
- `e: React.ChangeEvent<HTMLInputElement>` --> native `Event` with `(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:**
```typescript
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/settings` via `createResource`
- `PUT /api/v1/settings` to save
- `GET /api/v1/settings/export` for JSON download
- `POST /api/v1/settings/import` for JSON upload
- `GET /api/v1/config/providers` to get available providers and models (admin-curated list)
**SolidJS patterns:**
- `createEffect` to initialize `localSettings` from `settings()` when loaded
- `createStore` for 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 from `GET /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:**
```typescript
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 backend `GET /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:**
```typescript
const [providers, { refetch }] = createResource(adminApi.getProviders);
const [editingProvider, setEditingProvider] = createSignal<ProviderConfig | null>(null);
const [saving, setSaving] = createSignal(false);
```
**API calls:**
- `GET /api/v1/admin/providers`
- `POST /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 form
- `createStore` for 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:**
```typescript
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-limits`
- `PUT /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:**
```typescript
const [users] = createResource(adminApi.listUsers);
```
**API calls:**
- `GET /api/v1/admin/users`
- `PUT /api/v1/admin/users/:id/role`
---
### 2.5 Shared UI Components
#### `src/components/ui/Button.tsx`
```typescript
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`
```typescript
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>` from `solid-js/web` to render at document root (proper overlay stacking)
- Focus trap and keyboard handling via `createEffect` + `onCleanup`
---
#### `src/components/ui/Spinner.tsx`
```typescript
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`
```typescript
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.
```typescript
// 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` + `useContext` for global access
- `createStore<Toast[]>` for the toast queue
- `<For each={toasts}>` to render active toasts
- `<Portal>` to render at document root (top-right corner)
- Auto-dismiss via `setTimeout` in `createEffect`, cleaned up with `onCleanup`
---
#### `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
```typescript
interface BannerProps {
message: string;
}
```
---
#### `src/components/ui/EmptyState.tsx`
Reusable empty state placeholder with icon, title, description, and optional CTA button.
```typescript
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.
```typescript
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 `NewsItemCard`s.
```typescript
interface SynthesisSectionProps {
title: string;
items: NewsItem[];
}
```
---
#### `src/components/synthesis/NewsItemCard.tsx`
Individual news item: title as external link, summary paragraph.
```typescript
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.
```typescript
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.
```typescript
// 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:
```typescript
// 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.
```typescript
// 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:**
```typescript
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>`):
```typescript
import { A } from '@solidjs/router';
<A href="/">Syntheses</A>
<A href={`/synthesis/${synth.id}`}>Lire la synthese</A>
```
### 4.5 Magic Link Verification Redirect
1. User clicks magic link in email: `https://app.example.com/auth/verify?token=abc123`
2. `MagicLinkVerify` page loads, extracts `token` from `useSearchParams()`
3. Calls `GET /api/v1/auth/verify?token=abc123`
4. Backend verifies token, creates session, sets `Set-Cookie` header in response
5. Frontend receives success, calls `refreshUser()` on AuthContext to load the user
6. Navigates to `/` with `replace: 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
```typescript
// 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 10 minutes.',
'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
```typescript
// 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
1. Create `src/i18n/locales/en.ts` with the same key structure.
2. Import it in `src/i18n/index.ts` and add to the `translations` map.
3. Make `locale` a signal (e.g., from user preferences or browser language).
4. 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.
```typescript
// 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`:
```typescript
// 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.
```typescript
// 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-Id` header).
- **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:**
```html
<!-- 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:**
```html
<!-- 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:**
```html
<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:
```css
/* 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:
```html
<!-- 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):**
```typescript
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):**
```typescript
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.
```typescript
// 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:
```typescript
// 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.
```typescript
// 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
```typescript
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
```typescript
// 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:
```typescript
// 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?$/],
},
},
});
```
```typescript
// src/test-setup.ts
import '@testing-library/jest-dom';
```
### 11.2 Component Test Examples
**Testing the Spinner component:**
```typescript
// 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:**
```typescript
// 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:**
```typescript
// 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:
```typescript
// 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`
```typescript
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`
```typescript
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`
```typescript
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`
```typescript
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`
```typescript
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`
```typescript
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:
```typescript
// 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
```typescript
// 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;
}
```