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
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 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
|
|
|
|
```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;
|
|
}
|
|
```
|