docs: add JSDoc to all frontend API modules, pages, components, utilities

Add English JSDoc documentation to 32 source files across the frontend:
- API layer (8 files): client CSRF strategy, credential handling, 401 redirect,
  and endpoint-level docs for auth, settings, sources, syntheses, admin, config, apiKeys
- Pages (11 files): Settings export/import, GenerateSynthesis SSE state machine,
  Home delete confirmation timer, Sources bulk import parsing, SynthesisDetail
  email/export flows, Login/Register Turnstile lifecycle, AuthVerify token flow,
  admin Providers/RateLimits/Users
- Components (8 files): ApiKeyManager CRUD, Turnstile polling init, Navbar/MobileMenu
  route detection, Layout/AdminLayout structure, ErrorBoundary retry, Button variants,
  Toast auto-dismiss timer, LoadingSpinner props
- Utilities (2 files): SSE reconnection backoff, dates locale config
- Context (1 file): AuthContext session check, isAdmin derived signal

No logic changes. TypeScript and vitest pass unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
master
oabrivard 3 months ago
parent fa346dc346
commit 6f3ff1e9a2

@ -9,32 +9,43 @@ import type {
UpdateUserRoleRequest, UpdateUserRoleRequest,
} from '~/types'; } from '~/types';
/** Admin AI-provider CRUD endpoints. */
export const adminProvidersApi = { export const adminProvidersApi = {
/** GET /admin/providers -- list all configured AI providers. */
list: (): Promise<AdminProvider[]> => list: (): Promise<AdminProvider[]> =>
api.get<AdminProvider[]>('/admin/providers'), api.get<AdminProvider[]>('/admin/providers'),
/** POST /admin/providers -- register a new AI provider with its models. */
create: (data: CreateProviderRequest): Promise<AdminProvider> => create: (data: CreateProviderRequest): Promise<AdminProvider> =>
api.post<AdminProvider>('/admin/providers', data), api.post<AdminProvider>('/admin/providers', data),
/** PUT /admin/providers/:id -- update display name, models, or enabled state. */
update: (id: string, data: UpdateProviderRequest): Promise<AdminProvider> => update: (id: string, data: UpdateProviderRequest): Promise<AdminProvider> =>
api.put<AdminProvider>(`/admin/providers/${id}`, data), api.put<AdminProvider>(`/admin/providers/${id}`, data),
/** DELETE /admin/providers/:id -- permanently remove a provider. */
delete: (id: string): Promise<void> => delete: (id: string): Promise<void> =>
api.delete<void>(`/admin/providers/${id}`), api.delete<void>(`/admin/providers/${id}`),
}; };
/** Admin rate-limit configuration endpoints. */
export const adminRateLimitsApi = { export const adminRateLimitsApi = {
/** GET /admin/rate-limits -- list per-provider rate-limit configs. */
list: (): Promise<AdminRateLimit[]> => list: (): Promise<AdminRateLimit[]> =>
api.get<AdminRateLimit[]>('/admin/rate-limits'), api.get<AdminRateLimit[]>('/admin/rate-limits'),
/** PUT /admin/rate-limits/:id -- update max requests / time window for a provider. */
update: (id: string, data: UpdateRateLimitRequest): Promise<AdminRateLimit> => update: (id: string, data: UpdateRateLimitRequest): Promise<AdminRateLimit> =>
api.put<AdminRateLimit>(`/admin/rate-limits/${id}`, data), api.put<AdminRateLimit>(`/admin/rate-limits/${id}`, data),
}; };
/** Admin user management endpoints. */
export const adminUsersApi = { export const adminUsersApi = {
/** GET /admin/users -- list all registered users. */
list: (): Promise<AdminUser[]> => list: (): Promise<AdminUser[]> =>
api.get<AdminUser[]>('/admin/users'), api.get<AdminUser[]>('/admin/users'),
/** PUT /admin/users/:id/role -- promote or demote a user (admin/user). */
updateRole: (id: string, data: UpdateUserRoleRequest): Promise<AdminUser> => updateRole: (id: string, data: UpdateUserRoleRequest): Promise<AdminUser> =>
api.put<AdminUser>(`/admin/users/${id}/role`, data), api.put<AdminUser>(`/admin/users/${id}/role`, data),
}; };

@ -1,19 +1,25 @@
import { api } from './client'; import { api } from './client';
import type { UserApiKey, CreateApiKeyRequest, TestApiKeyResponse } from '~/types'; import type { UserApiKey, CreateApiKeyRequest, TestApiKeyResponse } from '~/types';
/** User API-key management endpoints (BYOK -- bring your own key). */
export const apiKeysApi = { export const apiKeysApi = {
/** GET /user/api-keys -- list stored API keys (returns masked prefixes). */
list: (): Promise<UserApiKey[]> => list: (): Promise<UserApiKey[]> =>
api.get<UserApiKey[]>('/user/api-keys'), api.get<UserApiKey[]>('/user/api-keys'),
/** POST /user/api-keys -- store or replace an API key for a provider. */
create: (data: CreateApiKeyRequest): Promise<UserApiKey> => create: (data: CreateApiKeyRequest): Promise<UserApiKey> =>
api.post<UserApiKey>('/user/api-keys', data), api.post<UserApiKey>('/user/api-keys', data),
/** DELETE /user/api-keys/:provider -- remove the stored key for a provider. */
remove: (provider: string): Promise<void> => remove: (provider: string): Promise<void> =>
api.delete<void>(`/user/api-keys/${encodeURIComponent(provider)}`), api.delete<void>(`/user/api-keys/${encodeURIComponent(provider)}`),
/** POST /user/api-keys/:provider/test -- validate the stored key with a live API call. */
test: (provider: string): Promise<TestApiKeyResponse> => test: (provider: string): Promise<TestApiKeyResponse> =>
api.post<TestApiKeyResponse>(`/user/api-keys/${encodeURIComponent(provider)}/test`), api.post<TestApiKeyResponse>(`/user/api-keys/${encodeURIComponent(provider)}/test`),
/** POST /user/api-keys/export -- return all keys in cleartext (for settings export). */
exportKeys: (): Promise<{ provider_name: string; api_key: string }[]> => exportKeys: (): Promise<{ provider_name: string; api_key: string }[]> =>
api.post('/user/api-keys/export'), api.post('/user/api-keys/export'),
}; };

@ -8,17 +8,23 @@ import type {
VerifyResponse, VerifyResponse,
} from '~/types'; } from '~/types';
/** Authentication API endpoints. */
export const authApi = { export const authApi = {
/** POST /auth/register -- create a new account and send a verification email. */
register: (data: RegisterRequest): Promise<RegisterResponse> => register: (data: RegisterRequest): Promise<RegisterResponse> =>
api.post<RegisterResponse>('/auth/register', data), api.post<RegisterResponse>('/auth/register', data),
/** POST /auth/login -- request a magic-link email for passwordless sign-in. */
login: (data: LoginRequest): Promise<LoginResponse> => login: (data: LoginRequest): Promise<LoginResponse> =>
api.post<LoginResponse>('/auth/login', data), api.post<LoginResponse>('/auth/login', data),
/** POST /auth/verify -- exchange a magic-link token for a session cookie. */
verify: (token: string): Promise<VerifyResponse> => verify: (token: string): Promise<VerifyResponse> =>
api.post<VerifyResponse>('/auth/verify', { token }), api.post<VerifyResponse>('/auth/verify', { token }),
/** POST /auth/logout -- invalidate the current session. */
logout: (): Promise<void> => api.post<void>('/auth/logout'), logout: (): Promise<void> => api.post<void>('/auth/logout'),
/** GET /auth/me -- return the currently authenticated user. */
me: (): Promise<User> => api.get<User>('/auth/me'), me: (): Promise<User> => api.get<User>('/auth/me'),
}; };

@ -2,7 +2,26 @@ import type { ApiError } from '~/types';
const API_BASE = '/api/v1'; const API_BASE = '/api/v1';
/**
* Centralized HTTP client for all API communication.
*
* Every request includes the `X-Requested-With: XMLHttpRequest` header as a
* lightweight CSRF mitigation: the backend rejects requests that lack this
* header, which browsers won't add on cross-origin form submissions.
*
* Credentials are sent with `same-origin` policy so the session cookie is
* attached automatically without exposing it to third-party origins.
*
* On a 401 response the client redirects the browser to `/login`, ensuring
* expired sessions are handled uniformly across the application.
*/
class ApiClient { class ApiClient {
/**
* Execute an HTTP request and return the parsed JSON response.
*
* Handles JSON serialization, FormData pass-through, 401 redirects,
* and 204 (no-content) responses.
*/
private async request<T>( private async request<T>(
method: string, method: string,
path: string, path: string,
@ -59,21 +78,26 @@ class ApiClient {
return response.json(); return response.json();
} }
/** Send a GET request. Accepts an optional `AbortSignal` for cancellation. */
get<T>(path: string, signal?: AbortSignal): Promise<T> { get<T>(path: string, signal?: AbortSignal): Promise<T> {
return this.request<T>('GET', path, { signal }); return this.request<T>('GET', path, { signal });
} }
/** Send a POST request with an optional JSON or FormData body. */
post<T>(path: string, body?: unknown): Promise<T> { post<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>('POST', path, { body }); return this.request<T>('POST', path, { body });
} }
/** Send a PUT request with an optional JSON body. */
put<T>(path: string, body?: unknown): Promise<T> { put<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>('PUT', path, { body }); return this.request<T>('PUT', path, { body });
} }
/** Send a DELETE request. */
delete<T>(path: string): Promise<T> { delete<T>(path: string): Promise<T> {
return this.request<T>('DELETE', path); return this.request<T>('DELETE', path);
} }
} }
/** Singleton API client instance used by all API modules. */
export const api = new ApiClient(); export const api = new ApiClient();

@ -1,7 +1,9 @@
import { api } from './client'; import { api } from './client';
import type { ProviderConfig } from '~/types'; import type { ProviderConfig } from '~/types';
/** Public configuration endpoints (no admin role required). */
export const configApi = { export const configApi = {
/** GET /config/providers -- list enabled AI providers and their models visible to users. */
listProviders: (): Promise<ProviderConfig[]> => listProviders: (): Promise<ProviderConfig[]> =>
api.get<ProviderConfig[]>('/config/providers'), api.get<ProviderConfig[]>('/config/providers'),
}; };

@ -1,9 +1,12 @@
import { api } from './client'; import { api } from './client';
import type { UserSettings } from '~/types'; import type { UserSettings } from '~/types';
/** User settings API endpoints. */
export const settingsApi = { export const settingsApi = {
/** GET /settings -- retrieve the current user's settings (404 if none saved yet). */
get: (): Promise<UserSettings> => api.get<UserSettings>('/settings'), get: (): Promise<UserSettings> => api.get<UserSettings>('/settings'),
/** PUT /settings -- create or fully replace the current user's settings. */
update: (settings: UserSettings): Promise<UserSettings> => update: (settings: UserSettings): Promise<UserSettings> =>
api.put<UserSettings>('/settings', settings), api.put<UserSettings>('/settings', settings),
}; };

@ -7,23 +7,30 @@ import type {
BulkImportResponse, BulkImportResponse,
} from '~/types'; } from '~/types';
/** Custom sources API endpoints (user-curated URLs for the AI to prioritize). */
export const sourcesApi = { export const sourcesApi = {
/** GET /sources -- list all sources belonging to the current user. */
list: (): Promise<Source[]> => api.get<Source[]>('/sources'), list: (): Promise<Source[]> => api.get<Source[]>('/sources'),
/** POST /sources -- add a single custom source. */
create: (data: CreateSourceRequest): Promise<Source> => create: (data: CreateSourceRequest): Promise<Source> =>
api.post<Source>('/sources', data), api.post<Source>('/sources', data),
/** DELETE /sources/:id -- remove a source by ID. */
remove: (id: string): Promise<void> => api.delete<void>(`/sources/${id}`), remove: (id: string): Promise<void> => api.delete<void>(`/sources/${id}`),
/** POST /sources/bulk -- import multiple sources from a JSON array. */
bulkImport: (data: BulkImportRequest): Promise<BulkImportResponse> => bulkImport: (data: BulkImportRequest): Promise<BulkImportResponse> =>
api.post<BulkImportResponse>('/sources/bulk', data), api.post<BulkImportResponse>('/sources/bulk', data),
/** POST /sources/import-csv -- import sources from an uploaded CSV file. */
importCsv: async (file: File): Promise<BulkImportResponse> => { importCsv: async (file: File): Promise<BulkImportResponse> => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
return api.post<BulkImportResponse>('/sources/import-csv', formData); return api.post<BulkImportResponse>('/sources/import-csv', formData);
}, },
/** GET /sources/export-csv -- download all sources as a CSV file. */
exportCsv: async (): Promise<void> => { exportCsv: async (): Promise<void> => {
const response = await fetchFile('/sources/export-csv'); const response = await fetchFile('/sources/export-csv');
await triggerDownload(response, 'sources.csv'); await triggerDownload(response, 'sources.csv');

@ -57,30 +57,39 @@ export async function fetchFile(path: string): Promise<Response> {
return response; return response;
} }
/** Synthesis API endpoints (CRUD, generation, export, email). */
export const synthesesApi = { export const synthesesApi = {
/** GET /syntheses -- paginated list of the user's syntheses. */
list: (limit = 50, offset = 0): Promise<SynthesisListItem[]> => list: (limit = 50, offset = 0): Promise<SynthesisListItem[]> =>
api.get<SynthesisListItem[]>(`/syntheses?limit=${limit}&offset=${offset}`), api.get<SynthesisListItem[]>(`/syntheses?limit=${limit}&offset=${offset}`),
/** GET /syntheses/:id -- fetch a single synthesis with full content. */
get: (id: string): Promise<Synthesis> => get: (id: string): Promise<Synthesis> =>
api.get<Synthesis>(`/syntheses/${id}`), api.get<Synthesis>(`/syntheses/${id}`),
/** DELETE /syntheses/:id -- permanently delete a synthesis. */
remove: (id: string): Promise<void> => remove: (id: string): Promise<void> =>
api.delete<void>(`/syntheses/${id}`), api.delete<void>(`/syntheses/${id}`),
/** POST /syntheses/generate -- kick off an async generation job, returns a job ID. */
generate: (): Promise<GenerateResponse> => generate: (): Promise<GenerateResponse> =>
api.post<GenerateResponse>('/syntheses/generate'), api.post<GenerateResponse>('/syntheses/generate'),
/** Build the SSE endpoint URL for streaming generation progress. */
progressUrl: (jobId: string): string => progressUrl: (jobId: string): string =>
`${API_BASE}/syntheses/generate/${jobId}/progress`, `${API_BASE}/syntheses/generate/${jobId}/progress`,
/** POST /syntheses/:id/send-email -- email the synthesis to the given address. */
sendEmail: (id: string, email: string): Promise<void> => sendEmail: (id: string, email: string): Promise<void> =>
api.post<void>(`/syntheses/${id}/send-email`, { email } satisfies SendEmailRequest), api.post<void>(`/syntheses/${id}/send-email`, { email } satisfies SendEmailRequest),
/** Download the synthesis as a Markdown file via {@link fetchFile} + {@link triggerDownload}. */
exportMarkdown: async (id: string): Promise<void> => { exportMarkdown: async (id: string): Promise<void> => {
const response = await fetchFile(`/syntheses/${id}/export/markdown`); const response = await fetchFile(`/syntheses/${id}/export/markdown`);
await triggerDownload(response, `synthese-${id}.md`); await triggerDownload(response, `synthese-${id}.md`);
}, },
/** Download the synthesis as a PDF file via {@link fetchFile} + {@link triggerDownload}. */
exportPdf: async (id: string): Promise<void> => { exportPdf: async (id: string): Promise<void> => {
const response = await fetchFile(`/syntheses/${id}/export/pdf`); const response = await fetchFile(`/syntheses/${id}/export/pdf`);
await triggerDownload(response, `synthese-${id}.pdf`); await triggerDownload(response, `synthese-${id}.pdf`);

@ -4,6 +4,13 @@ import { BrainCircuit, Shield, Server, Gauge, Users, LogOut, Menu, X } from 'luc
import { useAuth } from '~/contexts/AuthContext'; import { useAuth } from '~/contexts/AuthContext';
import { useI18n } from '~/i18n'; import { useI18n } from '~/i18n';
/**
* Admin area layout with a fixed sidebar navigation and responsive mobile drawer.
*
* The sidebar links (Providers, Rate Limits, Users) use active-route detection
* to highlight the current page. Only users with the `admin` role can reach
* routes using this layout (enforced by the router guard).
*/
const AdminLayout: Component<{ children: any }> = (props) => { const AdminLayout: Component<{ children: any }> = (props) => {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const location = useLocation(); const location = useLocation();

@ -16,6 +16,14 @@ interface ApiKeyManagerProps {
providers: ProviderConfig[]; providers: ProviderConfig[];
} }
/**
* Manages per-provider API keys (BYOK) displayed on the Settings page.
*
* Renders one {@link ProviderKeyCard} per configured provider. The card
* supports creating, testing, and deleting keys, as well as toggling
* key visibility (show/hide). The `test` button makes a live validation
* call to verify the key works.
*/
const ApiKeyManager: Component<ApiKeyManagerProps> = (props) => { const ApiKeyManager: Component<ApiKeyManagerProps> = (props) => {
const { t } = useI18n(); const { t } = useI18n();
const { addToast } = useToast(); const { addToast } = useToast();
@ -61,6 +69,7 @@ interface ProviderKeyCardProps {
onKeyChanged: () => void; onKeyChanged: () => void;
} }
/** Individual card for a single provider's API key with CRUD and test actions. */
const ProviderKeyCard: Component<ProviderKeyCardProps> = (props) => { const ProviderKeyCard: Component<ProviderKeyCardProps> = (props) => {
const { t } = useI18n(); const { t } = useI18n();
const { addToast } = useToast(); const { addToast } = useToast();

@ -3,6 +3,13 @@ import type { ParentComponent } from 'solid-js';
import { AlertTriangle } from 'lucide-solid'; import { AlertTriangle } from 'lucide-solid';
import { useI18n } from '~/i18n'; import { useI18n } from '~/i18n';
/**
* Application-level error boundary wrapping `solid-js` `ErrorBoundary`.
*
* Catches any unhandled exception in the component tree and renders a
* full-page error screen with the error message. The "Retry" button calls
* Solid's `reset()` to re-render the children from scratch.
*/
const AppErrorBoundary: ParentComponent = (props) => { const AppErrorBoundary: ParentComponent = (props) => {
const { t } = useI18n(); const { t } = useI18n();

@ -2,6 +2,10 @@ import { type Component, createSignal, Show } from 'solid-js';
import Navbar from './Navbar'; import Navbar from './Navbar';
import MobileMenu from './MobileMenu'; import MobileMenu from './MobileMenu';
/**
* Top-level page layout: renders the {@link Navbar}, conditionally shows the
* {@link MobileMenu}, and wraps page content in a `<main>` element.
*/
const Layout: Component<{ children: any }> = (props) => { const Layout: Component<{ children: any }> = (props) => {
const [mobileMenuOpen, setMobileMenuOpen] = createSignal(false); const [mobileMenuOpen, setMobileMenuOpen] = createSignal(false);

@ -8,6 +8,15 @@ interface MobileMenuProps {
onClose: () => void; onClose: () => void;
} }
/**
* Slide-in mobile navigation menu (visible only on small screens).
*
* - **Escape key handler**: A global `keydown` listener closes the menu
* when Escape is pressed; the listener is registered on mount and removed
* on cleanup.
* - **Backdrop click**: Clicking the semi-transparent overlay behind the
* panel also triggers `onClose`.
*/
const MobileMenu: Component<MobileMenuProps> = (props) => { const MobileMenu: Component<MobileMenuProps> = (props) => {
const { user, isAdmin, logout } = useAuth(); const { user, isAdmin, logout } = useAuth();
const location = useLocation(); const location = useLocation();

@ -8,6 +8,15 @@ interface NavbarProps {
onToggleMobile: () => void; onToggleMobile: () => void;
} }
/**
* Top navigation bar visible on all authenticated pages.
*
* - **Active route detection**: The current pathname is compared against each
* link's `href` to apply a highlighted bottom-border style.
* - **Admin link visibility**: The admin navigation link is only rendered
* when `isAdmin()` is true, using a prefix match on `/admin` for the
* active state so all admin sub-routes highlight the same tab.
*/
const Navbar: Component<NavbarProps> = (props) => { const Navbar: Component<NavbarProps> = (props) => {
const { user, isAdmin, logout } = useAuth(); const { user, isAdmin, logout } = useAuth();
const location = useLocation(); const location = useLocation();

@ -26,8 +26,21 @@ interface TurnstileProps {
onError?: () => void; onError?: () => void;
} }
/** Turnstile site key loaded from env, with a Cloudflare testing key as fallback. */
const SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY ?? '1x00000000000000000000AA'; const SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY ?? '1x00000000000000000000AA';
/**
* Cloudflare Turnstile CAPTCHA widget wrapper.
*
* **Widget lifecycle**:
* 1. On mount, if `window.turnstile` is available (script already loaded),
* the widget is rendered immediately.
* 2. Otherwise, a 100ms polling interval waits for the external Turnstile
* script to load and expose the global API, then renders.
* 3. On cleanup (component unmount), the widget is removed via
* `turnstile.remove()` and any polling interval is cleared to prevent
* memory leaks.
*/
const Turnstile: Component<TurnstileProps> = (props) => { const Turnstile: Component<TurnstileProps> = (props) => {
let containerRef: HTMLDivElement | undefined; let containerRef: HTMLDivElement | undefined;
let widgetId: string | undefined; let widgetId: string | undefined;
@ -76,6 +89,7 @@ const Turnstile: Component<TurnstileProps> = (props) => {
export default Turnstile; export default Turnstile;
/** Reset an existing Turnstile widget so the user can re-verify. */
export function resetTurnstile(widgetId: string): void { export function resetTurnstile(widgetId: string): void {
if (window.turnstile) { if (window.turnstile) {
window.turnstile.reset(widgetId); window.turnstile.reset(widgetId);

@ -1,5 +1,14 @@
import { type Component, type JSX, Show, splitProps } from 'solid-js'; import { type Component, type JSX, Show, splitProps } from 'solid-js';
/**
* Props for the reusable Button component.
*
* - `primary` (default): indigo background, white text.
* - `secondary`: white background, gray border, dark text.
* - `danger`: red background, white text.
*
* When `loading` is true, the button is disabled and a spinner replaces the icon.
*/
interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> { interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger'; variant?: 'primary' | 'secondary' | 'danger';
loading?: boolean; loading?: boolean;
@ -15,6 +24,7 @@ const variantClasses: Record<string, string> = {
'text-white bg-red-600 hover:bg-red-700 border-transparent focus:ring-red-500', 'text-white bg-red-600 hover:bg-red-700 border-transparent focus:ring-red-500',
}; };
/** Styled button with variant theming, optional leading icon, and loading state. */
const Button: Component<ButtonProps> = (allProps) => { const Button: Component<ButtonProps> = (allProps) => {
const [props, rest] = splitProps(allProps, [ const [props, rest] = splitProps(allProps, [
'variant', 'variant',

@ -1,5 +1,12 @@
import type { Component } from 'solid-js'; import type { Component } from 'solid-js';
/**
* Props for the LoadingSpinner component.
* @property fullPage - When true, the spinner is vertically centered to fill
* the entire viewport height; otherwise it uses a fixed 16rem container.
* @property size - Controls the spinner diameter: `sm` (1rem), `md` (2rem),
* or `lg` (3rem, default).
*/
interface LoadingSpinnerProps { interface LoadingSpinnerProps {
fullPage?: boolean; fullPage?: boolean;
size?: 'sm' | 'md' | 'lg'; size?: 'sm' | 'md' | 'lg';
@ -11,6 +18,7 @@ const sizeClasses = {
lg: 'h-12 w-12', lg: 'h-12 w-12',
}; };
/** Animated indigo spinner for loading states. */
const LoadingSpinner: Component<LoadingSpinnerProps> = (props) => { const LoadingSpinner: Component<LoadingSpinnerProps> = (props) => {
const sizeClass = () => sizeClasses[props.size ?? 'lg']; const sizeClass = () => sizeClasses[props.size ?? 'lg'];

@ -10,6 +10,7 @@ import { Portal } from 'solid-js/web';
import { CheckCircle, XCircle, Info, X } from 'lucide-solid'; import { CheckCircle, XCircle, Info, X } from 'lucide-solid';
import { useI18n } from '~/i18n'; import { useI18n } from '~/i18n';
/** A single toast notification. */
interface Toast { interface Toast {
id: string; id: string;
type: 'success' | 'error' | 'info'; type: 'success' | 'error' | 'info';
@ -17,6 +18,7 @@ interface Toast {
duration: number; duration: number;
} }
/** Public API exposed by the toast context. */
interface ToastContextType { interface ToastContextType {
addToast: (toast: Omit<Toast, 'id'>) => void; addToast: (toast: Omit<Toast, 'id'>) => void;
removeToast: (id: string) => void; removeToast: (id: string) => void;
@ -25,8 +27,16 @@ interface ToastContextType {
const ToastContext = createContext<ToastContextType>(); const ToastContext = createContext<ToastContextType>();
let nextId = 0; let nextId = 0;
/** Tracks active auto-dismiss timers so they can be cleared on manual dismiss. */
const activeTimers = new Map<string, ReturnType<typeof setTimeout>>(); const activeTimers = new Map<string, ReturnType<typeof setTimeout>>();
/**
* Context provider that manages a stack of toast notifications.
*
* Toasts are rendered into a portal at the top-right corner. Each toast has
* an auto-dismiss timer (default 5 s) that removes it after `duration` ms.
* Manually dismissing a toast clears its timer to avoid double-removal.
*/
export const ToastProvider: ParentComponent = (props) => { export const ToastProvider: ParentComponent = (props) => {
const [toasts, setToasts] = createSignal<Toast[]>([]); const [toasts, setToasts] = createSignal<Toast[]>([]);
@ -110,6 +120,7 @@ const ToastItem: Component<{
); );
}; };
/** Access the toast context. Must be called within a {@link ToastProvider}. */
export const useToast = (): ToastContextType => { export const useToast = (): ToastContextType => {
const ctx = useContext(ToastContext); const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within ToastProvider'); if (!ctx) throw new Error('useToast must be used within ToastProvider');

@ -3,10 +3,12 @@ import type { ParentComponent } from 'solid-js';
import { authApi } from '~/api/auth'; import { authApi } from '~/api/auth';
import type { User } from '~/types'; import type { User } from '~/types';
/** Public API surface of the authentication context. */
export interface AuthContextType { export interface AuthContextType {
user: () => User | null; user: () => User | null;
loading: () => boolean; loading: () => boolean;
isAuthenticated: () => boolean; isAuthenticated: () => boolean;
/** Derived signal: true when the user's role is `"admin"`. */
isAdmin: () => boolean; isAdmin: () => boolean;
logout: () => Promise<void>; logout: () => Promise<void>;
refreshUser: () => Promise<void>; refreshUser: () => Promise<void>;
@ -14,6 +16,16 @@ export interface AuthContextType {
const AuthContext = createContext<AuthContextType>(); const AuthContext = createContext<AuthContextType>();
/**
* Global authentication context provider.
*
* On mount, a GET /auth/me call checks for an existing session cookie.
* If the call succeeds the user signal is populated; on 401 (handled by
* the API client) the user remains `null` and `loading` becomes `false`.
*
* `isAdmin` is a derived signal computed from `user()?.role === 'admin'`
* so it stays reactive without extra subscriptions.
*/
export const AuthProvider: ParentComponent = (props) => { export const AuthProvider: ParentComponent = (props) => {
const [user, setUser] = createSignal<User | null>(null); const [user, setUser] = createSignal<User | null>(null);
const [loading, setLoading] = createSignal(true); const [loading, setLoading] = createSignal(true);
@ -60,6 +72,7 @@ export const AuthProvider: ParentComponent = (props) => {
); );
}; };
/** Access the auth context. Must be called within an {@link AuthProvider}. */
export const useAuth = (): AuthContextType => { export const useAuth = (): AuthContextType => {
const ctx = useContext(AuthContext); const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider'); if (!ctx) throw new Error('useAuth must be used within AuthProvider');

@ -6,6 +6,15 @@ import { useAuth } from '~/contexts/AuthContext';
import { useI18n } from '~/i18n'; import { useI18n } from '~/i18n';
import LoadingSpinner from '~/components/ui/LoadingSpinner'; import LoadingSpinner from '~/components/ui/LoadingSpinner';
/**
* Magic-link verification page reached via `/auth/verify?token=...`.
*
* On mount, the `token` query parameter is extracted from the URL. If present,
* it is POSTed to the backend's verify endpoint which sets a session cookie.
* On success the auth context is refreshed and the user is redirected home
* after a 1.5-second delay. Missing or invalid tokens display an error with
* a link back to the login page.
*/
const AuthVerify: Component = () => { const AuthVerify: Component = () => {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();

@ -18,11 +18,13 @@ import { createSSEConnection, type SSEConnection, type SSEStatus } from '~/utils
import { providerSupportsWebSearch } from '~/utils/providers'; import { providerSupportsWebSearch } from '~/utils/providers';
import LoadingSpinner from '~/components/ui/LoadingSpinner'; import LoadingSpinner from '~/components/ui/LoadingSpinner';
/** Metadata for a single generation pipeline step. */
interface StepInfo { interface StepInfo {
key: string; key: string;
label: string; label: string;
} }
/** Ordered pipeline steps displayed as a checklist during generation. */
const STEPS: StepInfo[] = [ const STEPS: StepInfo[] = [
{ key: 'search', label: 'generate.step.search' }, { key: 'search', label: 'generate.step.search' },
{ key: 'scraping', label: 'generate.step.scraping' }, { key: 'scraping', label: 'generate.step.scraping' },
@ -30,6 +32,20 @@ const STEPS: StepInfo[] = [
{ key: 'saving', label: 'generate.step.saving' }, { key: 'saving', label: 'generate.step.saving' },
]; ];
/**
* Synthesis generation page with real-time progress tracking.
*
* SSE state machine:
* idle -> connecting -> connected (progress events) -> complete | error
*
* 1. User clicks "Generate": a POST creates a job and returns a `job_id`.
* 2. An SSE connection is opened to the progress endpoint for that job.
* 3. `progress` events drive the progress bar and step checklist.
* 4. A `complete` event carries the `synthesis_id`; the page auto-redirects
* after a short delay. An `error` event surfaces the message and stops.
* 5. On connection loss, exponential backoff retries up to 3 times (see
* {@link createSSEConnection}).
*/
const GenerateSynthesis: Component = () => { const GenerateSynthesis: Component = () => {
const { t } = useI18n(); const { t } = useI18n();
const navigate = useNavigate(); const navigate = useNavigate();

@ -14,6 +14,15 @@ import type { SynthesisListItem } from '~/types';
import { extractWeekNumber, formatDate } from '~/utils/dates'; import { extractWeekNumber, formatDate } from '~/utils/dates';
import LoadingSpinner from '~/components/ui/LoadingSpinner'; import LoadingSpinner from '~/components/ui/LoadingSpinner';
/**
* Home / dashboard page listing the user's past syntheses.
*
* Delete confirmation uses a two-click pattern with a 3-second auto-cancel
* timer: the first click on the trash icon puts the card in "confirm" state
* (visually highlighted). A second click within 3 seconds actually deletes.
* If no second click occurs, the state resets automatically via `setTimeout`.
* Timers are stored per-synthesis-id so multiple cards can be independent.
*/
const Home: Component = () => { const Home: Component = () => {
const { t } = useI18n(); const { t } = useI18n();

@ -14,6 +14,16 @@ import { isApiError } from '~/types';
import Turnstile from '~/components/Turnstile'; import Turnstile from '~/components/Turnstile';
import Button from '~/components/ui/Button'; import Button from '~/components/ui/Button';
/**
* Passwordless login page using magic-link email authentication.
*
* - **Turnstile lifecycle**: The Cloudflare Turnstile widget provides a
* bot-protection token that must be obtained before the form can submit.
* Token expiry or error resets the token signal to `null`.
* - **Resend cooldown**: After a successful login request, a 60-second
* cooldown prevents spamming. The countdown is driven by a `setInterval`
* that decrements every second and self-clears at zero.
*/
const Login: Component = () => { const Login: Component = () => {
const { isAuthenticated, loading: authLoading } = useAuth(); const { isAuthenticated, loading: authLoading } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();

@ -14,6 +14,15 @@ import { isApiError } from '~/types';
import Turnstile from '~/components/Turnstile'; import Turnstile from '~/components/Turnstile';
import Button from '~/components/ui/Button'; import Button from '~/components/ui/Button';
/**
* Account registration page with magic-link email verification.
*
* - **Turnstile lifecycle**: Identical to the Login page -- a Cloudflare
* Turnstile token is required before submission. Expiry or error resets
* the token to `null`, disabling the submit button.
* - **Resend cooldown**: A 60-second countdown prevents repeated
* registration attempts for the same email.
*/
const Register: Component = () => { const Register: Component = () => {
const { isAuthenticated, loading: authLoading } = useAuth(); const { isAuthenticated, loading: authLoading } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();

@ -18,6 +18,22 @@ import LoadingSpinner from '~/components/ui/LoadingSpinner';
import ApiKeyManager from '~/components/ApiKeyManager'; import ApiKeyManager from '~/components/ApiKeyManager';
import { getProviderInfoKey, providerSupportsWebSearch } from '~/utils/providers'; import { getProviderInfoKey, providerSupportsWebSearch } from '~/utils/providers';
/**
* Settings page for configuring the user's synthesis preferences.
*
* Key behaviors:
* - **Export/Import**: Settings can be exported as JSON (optionally including
* cleartext API keys). On import, missing fields are merged with
* `DEFAULT_SETTINGS` so partial files are safe.
* - **Provider auto-detection**: When only one provider is configured, the
* provider dropdown is hidden and that provider is auto-selected.
* - **Rate limit null handling**: The `rate_limit_max_requests` and
* `rate_limit_time_window_seconds` fields accept `null` (meaning "use
* server defaults"). Empty inputs are stored as `null`, not zero.
* - **Dual model state**: Research model (`ai_model`) and writing model
* (`ai_model_writing`) are independently selectable from the same
* provider's model list.
*/
const Settings: Component = () => { const Settings: Component = () => {
const { t } = useI18n(); const { t } = useI18n();
@ -730,3 +746,4 @@ const Settings: Component = () => {
}; };
export default Settings; export default Settings;

@ -49,6 +49,19 @@ export function isValidUrl(url: string): boolean {
} }
} }
/**
* Sources management page for adding, deleting, and bulk-importing custom URLs.
*
* Key behaviors:
* - **Bulk import parsing**: The textarea accepts one source per line in
* `title;url` format. Lines are split on the first semicolon; the URL
* portion is normalized via {@link normalizeUrl}.
* - **CSV flow**: Export downloads a CSV via the backend; import uploads a
* `.csv` file as `multipart/form-data`. The file input is reset after
* each import so the same file can be re-selected.
* - **URL normalization**: {@link normalizeUrl} prepends `https://` when no
* scheme is present. {@link isValidUrl} then validates the result.
*/
const Sources: Component = () => { const Sources: Component = () => {
const { t } = useI18n(); const { t } = useI18n();

@ -15,6 +15,7 @@ import type { Synthesis, NewsItem as NewsItemType } from '~/types';
import { extractWeekNumber, formatDateLong } from '~/utils/dates'; import { extractWeekNumber, formatDateLong } from '~/utils/dates';
import LoadingSpinner from '~/components/ui/LoadingSpinner'; import LoadingSpinner from '~/components/ui/LoadingSpinner';
/** Renders a single news article with its title (linked) and summary. */
const NewsItemCard: Component<{ item: NewsItemType }> = (props) => { const NewsItemCard: Component<{ item: NewsItemType }> = (props) => {
return ( return (
<div class="bg-white rounded-lg shadow-sm border border-gray-100 p-6 hover:shadow-md transition-shadow"> <div class="bg-white rounded-lg shadow-sm border border-gray-100 p-6 hover:shadow-md transition-shadow">
@ -36,6 +37,7 @@ const NewsItemCard: Component<{ item: NewsItemType }> = (props) => {
); );
}; };
/** Renders a titled category section containing a list of {@link NewsItemCard}s. */
const Section: Component<{ title: string; items: NewsItemType[] }> = (props) => { const Section: Component<{ title: string; items: NewsItemType[] }> = (props) => {
return ( return (
<Show when={props.items && props.items.length > 0}> <Show when={props.items && props.items.length > 0}>
@ -53,6 +55,16 @@ const Section: Component<{ title: string; items: NewsItemType[] }> = (props) =>
); );
}; };
/**
* Detail view for a single synthesis, with email send and export capabilities.
*
* - **Email send flow**: The email input is pre-filled from the authenticated
* user's address. On send, a POST is made to the backend; a success banner
* auto-dismisses after 5 seconds.
* - **Export download**: Markdown and PDF exports call {@link fetchFile} to
* obtain a binary Response, then {@link triggerDownload} to create a
* temporary `<a>` element that triggers the browser download dialog.
*/
const SynthesisDetail: Component = () => { const SynthesisDetail: Component = () => {
const { t } = useI18n(); const { t } = useI18n();
const { user } = useAuth(); const { user } = useAuth();

@ -14,18 +14,29 @@ import type { AdminProvider, AdminProviderModel } from '~/types';
import LoadingSpinner from '~/components/ui/LoadingSpinner'; import LoadingSpinner from '~/components/ui/LoadingSpinner';
import Button from '~/components/ui/Button'; import Button from '~/components/ui/Button';
/** Local editable state for an existing provider card. */
interface ProviderFormState { interface ProviderFormState {
display_name: string; display_name: string;
models: AdminProviderModel[]; models: AdminProviderModel[];
is_enabled: boolean; is_enabled: boolean;
} }
/** Factory for a blank model row in the form. */
const emptyModel = (): AdminProviderModel => ({ const emptyModel = (): AdminProviderModel => ({
model_id: '', model_id: '',
display_name: '', display_name: '',
is_default: false, is_default: false,
}); });
/**
* Admin page for managing AI providers and their models (CRUD).
*
* Each provider is rendered as an editable card with inline model management.
* Local edits are tracked in a `Record<id, ProviderFormState>` map so unsaved
* changes are preserved while switching between cards. Saving writes to the
* backend and refetches the canonical list. Deletion requires a two-click
* confirmation within the same card footer.
*/
const Providers: Component = () => { const Providers: Component = () => {
const { t } = useI18n(); const { t } = useI18n();
const { addToast } = useToast(); const { addToast } = useToast();

@ -15,6 +15,7 @@ import type { AdminRateLimit } from '~/types';
import LoadingSpinner from '~/components/ui/LoadingSpinner'; import LoadingSpinner from '~/components/ui/LoadingSpinner';
import Button from '~/components/ui/Button'; import Button from '~/components/ui/Button';
/** Local mutable copy of a rate-limit row for editing without modifying the resource. */
interface LocalRateLimit { interface LocalRateLimit {
id: string; id: string;
provider_name: string; provider_name: string;
@ -22,6 +23,12 @@ interface LocalRateLimit {
time_window_seconds: number; time_window_seconds: number;
} }
/**
* Admin page for viewing and updating per-provider API rate-limit configuration.
*
* Fetched rate limits are copied into local signals so form inputs can mutate
* freely. Saving PUTs the updated values to the backend and refetches.
*/
const RateLimits: Component = () => { const RateLimits: Component = () => {
const { t } = useI18n(); const { t } = useI18n();
const { addToast } = useToast(); const { addToast } = useToast();

@ -15,6 +15,15 @@ import type { AdminUser } from '~/types';
import LoadingSpinner from '~/components/ui/LoadingSpinner'; import LoadingSpinner from '~/components/ui/LoadingSpinner';
import Button from '~/components/ui/Button'; import Button from '~/components/ui/Button';
/**
* Admin user management page with role promotion/demotion.
*
* - **Role management**: Each user row shows a promote/demote button that
* opens a confirmation banner before applying.
* - **Self-demotion guard**: The currently authenticated admin cannot change
* their own role -- the button is replaced with an informational label to
* prevent accidental lock-out.
*/
const Users: Component = () => { const Users: Component = () => {
const { t } = useI18n(); const { t } = useI18n();
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();

@ -1,3 +1,9 @@
/**
* Date formatting utilities using `date-fns` with the French (`fr`) locale.
*
* All formatters accept ISO 8601 strings and gracefully return the raw input
* on parse failure.
*/
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { fr } from 'date-fns/locale'; import { fr } from 'date-fns/locale';

@ -1,25 +1,31 @@
import { createSignal, onCleanup } from 'solid-js'; import { createSignal, onCleanup } from 'solid-js';
import type { ProgressEvent } from '~/types'; import type { ProgressEvent } from '~/types';
/** Lifecycle states of an SSE connection. */
export type SSEStatus = 'idle' | 'connecting' | 'connected' | 'complete' | 'error'; export type SSEStatus = 'idle' | 'connecting' | 'connected' | 'complete' | 'error';
/** Server-sent event indicating the job finished successfully. */
export interface SSECompleteEvent { export interface SSECompleteEvent {
type: 'complete'; type: 'complete';
synthesis_id: string; synthesis_id: string;
} }
/** Server-sent event indicating the job failed. */
export interface SSEErrorEvent { export interface SSEErrorEvent {
type: 'error'; type: 'error';
message: string; message: string;
} }
/** Server-sent event carrying generation progress data. */
export interface SSEProgressEvent { export interface SSEProgressEvent {
type: 'progress'; type: 'progress';
data: ProgressEvent; data: ProgressEvent;
} }
/** Union of all SSE event types emitted during synthesis generation. */
export type SSEEvent = SSEProgressEvent | SSECompleteEvent | SSEErrorEvent; export type SSEEvent = SSEProgressEvent | SSECompleteEvent | SSEErrorEvent;
/** Reactive handle returned by {@link createSSEConnection}. */
export interface SSEConnection { export interface SSEConnection {
events: () => SSEEvent[]; events: () => SSEEvent[];
status: () => SSEStatus; status: () => SSEStatus;
@ -29,9 +35,26 @@ export interface SSEConnection {
close: () => void; close: () => void;
} }
/** Maximum reconnection attempts before giving up. */
const MAX_RETRIES = 3; const MAX_RETRIES = 3;
/** Initial retry delay in ms; doubled on each subsequent attempt (exponential backoff). */
const BASE_RETRY_DELAY = 1000; const BASE_RETRY_DELAY = 1000;
/**
* Open an EventSource connection and expose its state as Solid signals.
*
* **Reconnection backoff**: On connection loss (not a server-sent `error`
* event), the client retries up to {@link MAX_RETRIES} times with exponential
* backoff starting at {@link BASE_RETRY_DELAY} ms (1s, 2s, 4s).
*
* **Event parsing**: The server emits named events (`progress`, `complete`,
* `error`). Each is JSON-parsed and pushed into the `events` signal array.
* Malformed payloads are silently ignored.
*
* **Cleanup**: The EventSource is closed on component unmount (via
* `onCleanup`) or when `close()` is called manually. Any pending retry
* timeout is also cleared.
*/
export function createSSEConnection(url: string): SSEConnection { export function createSSEConnection(url: string): SSEConnection {
const [events, setEvents] = createSignal<SSEEvent[]>([]); const [events, setEvents] = createSignal<SSEEvent[]>([]);
const [status, setStatus] = createSignal<SSEStatus>('idle'); const [status, setStatus] = createSignal<SSEStatus>('idle');

Loading…
Cancel
Save