diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7e6592c..70cadaa 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ const AuthVerify = lazy(() => import('~/pages/AuthVerify')); const Home = lazy(() => import('~/pages/Home')); const Settings = lazy(() => import('~/pages/Settings')); const Sources = lazy(() => import('~/pages/Sources')); +const ThemeManager = lazy(() => import('~/pages/ThemeManager')); const GenerateSynthesis = lazy(() => import('~/pages/GenerateSynthesis')); const SynthesisDetail = lazy(() => import('~/pages/SynthesisDetail')); const ArticleHistory = lazy(() => import('~/pages/ArticleHistory')); @@ -62,7 +63,8 @@ const App: Component = () => { - + + } /> diff --git a/frontend/src/__tests__/i18n.test.ts b/frontend/src/__tests__/i18n.test.ts index 22285b4..1dce2c2 100644 --- a/frontend/src/__tests__/i18n.test.ts +++ b/frontend/src/__tests__/i18n.test.ts @@ -6,7 +6,7 @@ describe('i18n', () => { describe('French translations', () => { it('should have all expected nav keys', () => { expect(frTranslations['nav.syntheses']).toBe('Syntheses'); - expect(frTranslations['nav.sources']).toBe('Sources personnalisees'); + expect(frTranslations['nav.themes']).toBe('Personnaliser les syntheses'); expect(frTranslations['nav.logout']).toBe('Deconnexion'); }); diff --git a/frontend/src/api/sources.ts b/frontend/src/api/sources.ts index 3eeb2ba..d2c3118 100644 --- a/frontend/src/api/sources.ts +++ b/frontend/src/api/sources.ts @@ -9,8 +9,9 @@ import type { /** Custom sources API endpoints (user-curated URLs for the AI to prioritize). */ export const sourcesApi = { - /** GET /sources -- list all sources belonging to the current user. */ - list: (): Promise => api.get('/sources'), + /** GET /sources -- list sources, optionally filtered by theme_id. */ + list: (themeId?: string): Promise => + api.get(`/sources${themeId ? `?theme_id=${themeId}` : ''}`), /** POST /sources -- add a single custom source. */ create: (data: CreateSourceRequest): Promise => @@ -31,8 +32,8 @@ export const sourcesApi = { }, /** GET /sources/export-csv -- download all sources as a CSV file. */ - exportCsv: async (): Promise => { - const response = await fetchFile('/sources/export-csv'); + exportCsv: async (themeId?: string): Promise => { + const response = await fetchFile(`/sources/export-csv${themeId ? `?theme_id=${themeId}` : ''}`); await triggerDownload(response, 'sources.csv'); }, }; diff --git a/frontend/src/api/themes.ts b/frontend/src/api/themes.ts new file mode 100644 index 0000000..159d96b --- /dev/null +++ b/frontend/src/api/themes.ts @@ -0,0 +1,50 @@ +import { api } from './client'; + +// ---- Theme types ---- + +export interface ThemeResponse { + id: string; + name: string; + theme: string; + categories: string[]; + max_items_per_category: number; + max_age_days: number; + summary_length: number; + created_at: string; + updated_at: string; +} + +export interface CreateThemeRequest { + name: string; + theme: string; + categories: string[]; + max_items_per_category?: number; + max_age_days?: number; + summary_length?: number; +} + +export interface UpdateThemeRequest { + name?: string; + theme?: string; + categories?: string[]; + max_items_per_category?: number; + max_age_days?: number; + summary_length?: number; +} + +/** Themes API endpoints (CRUD for user-defined synthesis themes). */ +export const themesApi = { + /** GET /themes -- list all themes belonging to the current user. */ + list: (): Promise => api.get('/themes'), + + /** POST /themes -- create a new theme. */ + create: (data: CreateThemeRequest): Promise => + api.post('/themes', data), + + /** PUT /themes/:id -- update an existing theme. */ + update: (id: string, data: UpdateThemeRequest): Promise => + api.put(`/themes/${id}`, data), + + /** DELETE /themes/:id -- delete a theme by ID. */ + remove: (id: string): Promise => api.delete(`/themes/${id}`), +}; diff --git a/frontend/src/components/MobileMenu.tsx b/frontend/src/components/MobileMenu.tsx index 6b807b0..6c720ca 100644 --- a/frontend/src/components/MobileMenu.tsx +++ b/frontend/src/components/MobileMenu.tsx @@ -71,11 +71,11 @@ const MobileMenu: Component = (props) => { {t('nav.syntheses')} props.onClose()} > - {t('nav.sources')} + {t('nav.themes')} = (props) => { {t('nav.syntheses')} - {t('nav.sources')} + {t('nav.themes')} { + const { t } = useI18n(); + + // ---- Theme list state ---- + const [themes, setThemes] = createSignal([]); + const [loading, setLoading] = createSignal(true); + const [selectedThemeId, setSelectedThemeId] = createSignal(null); + + // ---- Editing state for the selected theme's content settings ---- + const [editName, setEditName] = createSignal(''); + const [editThemeTopic, setEditThemeTopic] = createSignal(''); + const [editCategories, setEditCategories] = createSignal([]); + const [editMaxAge, setEditMaxAge] = createSignal(7); + const [editMaxItems, setEditMaxItems] = createSignal(5); + const [editSummaryLength, setEditSummaryLength] = createSignal(2); + const [newCategory, setNewCategory] = createSignal(''); + const [savingTheme, setSavingTheme] = createSignal(false); + const [themeMessage, setThemeMessage] = createSignal<{ + type: 'success' | 'error'; + text: string; + } | null>(null); + + // ---- Creating a new theme ---- + const [creating, setCreating] = createSignal(false); + + // ---- Delete theme ---- + const [confirmingDeleteTheme, setConfirmingDeleteTheme] = createSignal(false); + let deleteThemeTimer: ReturnType | undefined; + + // ---- Sources state ---- + const [sources, setSources] = createSignal([]); + const [loadingSources, setLoadingSources] = createSignal(false); + const [newTitle, setNewTitle] = createSignal(''); + const [newUrl, setNewUrl] = createSignal(''); + const [adding, setAdding] = createSignal(false); + const [addError, setAddError] = createSignal(null); + const [bulkText, setBulkText] = createSignal(''); + const [importing, setImporting] = createSignal(false); + const [importError, setImportError] = createSignal(null); + const [csvError, setCsvError] = createSignal(null); + const [confirmingDeleteId, setConfirmingDeleteId] = createSignal(null); + + let deleteTimer: ReturnType | undefined; + let fileInputRef: HTMLInputElement | undefined; + + onCleanup(() => { + if (deleteTimer) clearTimeout(deleteTimer); + if (deleteThemeTimer) clearTimeout(deleteThemeTimer); + }); + + // ---- Computed: selected theme from list ---- + const selectedTheme = (): ThemeResponse | null => { + const id = selectedThemeId(); + if (!id) return null; + return themes().find((th) => th.id === id) ?? null; + }; + + // ---- Load themes ---- + const fetchThemes = async () => { + try { + const data = await themesApi.list(); + setThemes(data); + } catch (err) { + console.error('Failed to load themes:', err); + } finally { + setLoading(false); + } + }; + + onMount(fetchThemes); + + // ---- Load sources for selected theme ---- + const fetchSources = async (themeId: string) => { + setLoadingSources(true); + try { + const data = await sourcesApi.list(themeId); + setSources(data); + } catch (err) { + console.error('Failed to load sources:', err); + } finally { + setLoadingSources(false); + } + }; + + // ---- When a theme is selected, populate editing state + load sources ---- + createEffect(() => { + const theme = selectedTheme(); + if (theme) { + setEditName(theme.name); + setEditThemeTopic(theme.theme); + setEditCategories([...theme.categories]); + setEditMaxAge(theme.max_age_days); + setEditMaxItems(theme.max_items_per_category); + setEditSummaryLength(theme.summary_length); + setThemeMessage(null); + setConfirmingDeleteTheme(false); + fetchSources(theme.id); + } else { + setSources([]); + } + }); + + // ---- Create new theme ---- + const handleCreateTheme = async () => { + setCreating(true); + try { + const data: CreateThemeRequest = { + name: t('themes.createTheme'), + theme: '', + categories: [], + }; + const created = await themesApi.create(data); + setThemes((prev) => [...prev, created]); + setSelectedThemeId(created.id); + setThemeMessage({ type: 'success', text: t('themes.created') }); + } catch (err) { + if (isApiError(err)) { + setThemeMessage({ type: 'error', text: err.message }); + } else { + setThemeMessage({ type: 'error', text: t('common.error') }); + } + } finally { + setCreating(false); + } + }; + + // ---- Save theme settings ---- + const handleSaveTheme = async () => { + const themeId = selectedThemeId(); + if (!themeId) return; + + setSavingTheme(true); + setThemeMessage(null); + + try { + const data: UpdateThemeRequest = { + name: editName(), + theme: editThemeTopic(), + categories: editCategories(), + max_age_days: editMaxAge(), + max_items_per_category: editMaxItems(), + summary_length: editSummaryLength(), + }; + const updated = await themesApi.update(themeId, data); + setThemes((prev) => prev.map((th) => (th.id === themeId ? updated : th))); + setThemeMessage({ type: 'success', text: t('themes.saved') }); + } catch (err) { + if (isApiError(err)) { + setThemeMessage({ type: 'error', text: err.message }); + } else { + setThemeMessage({ type: 'error', text: t('common.error') }); + } + } finally { + setSavingTheme(false); + } + }; + + // ---- Delete theme ---- + const handleDeleteThemeClick = () => { + if (confirmingDeleteTheme()) { + performDeleteTheme(); + } else { + setConfirmingDeleteTheme(true); + if (deleteThemeTimer) clearTimeout(deleteThemeTimer); + deleteThemeTimer = setTimeout(() => { + setConfirmingDeleteTheme(false); + }, 5000); + } + }; + + const performDeleteTheme = async () => { + const themeId = selectedThemeId(); + if (!themeId) return; + + if (deleteThemeTimer) clearTimeout(deleteThemeTimer); + setConfirmingDeleteTheme(false); + + try { + await themesApi.remove(themeId); + setThemes((prev) => prev.filter((th) => th.id !== themeId)); + setSelectedThemeId(null); + setThemeMessage({ type: 'success', text: t('themes.deleted') }); + } catch (err) { + if (isApiError(err)) { + setThemeMessage({ type: 'error', text: err.message }); + } else { + setThemeMessage({ type: 'error', text: t('common.error') }); + } + } + }; + + // ---- Category management ---- + const handleAddCategory = () => { + const cat = newCategory().trim(); + if (cat && !editCategories().includes(cat)) { + setEditCategories((prev) => [...prev, cat]); + setNewCategory(''); + } + }; + + const handleRemoveCategory = (cat: string) => { + setEditCategories((prev) => prev.filter((c) => c !== cat)); + }; + + // ---- Summary length helpers ---- + const summaryLengthLabel = (): string => { + const val = editSummaryLength(); + if (val <= 1) return t('settings.summaryShort'); + if (val >= 3) return t('settings.summaryDetailed'); + return t('settings.summaryMedium'); + }; + + // ---- Add a single source ---- + const handleAddSource = async (e: SubmitEvent) => { + e.preventDefault(); + const themeId = selectedThemeId(); + if (!themeId) return; + + setAddError(null); + + const title = newTitle().trim(); + const rawUrl = newUrl().trim(); + + if (!title) { + setAddError(t('sources.titleRequired')); + return; + } + if (!rawUrl) { + setAddError(t('sources.urlRequired')); + return; + } + + const url = normalizeUrl(rawUrl); + if (!isValidUrl(url)) { + setAddError(t('sources.urlInvalid')); + return; + } + + setAdding(true); + try { + await sourcesApi.create({ title, url, theme_id: themeId }); + setNewTitle(''); + setNewUrl(''); + await fetchSources(themeId); + } catch (err) { + if (isApiError(err)) { + setAddError(err.message); + } else { + setAddError(t('sources.addError')); + } + } finally { + setAdding(false); + } + }; + + // ---- Delete source with confirmation ---- + const handleDeleteClick = (id: string) => { + if (confirmingDeleteId() === id) { + performDelete(id); + } else { + setConfirmingDeleteId(id); + if (deleteTimer) clearTimeout(deleteTimer); + deleteTimer = setTimeout(() => { + setConfirmingDeleteId(null); + }, 3000); + } + }; + + const performDelete = async (id: string) => { + if (deleteTimer) clearTimeout(deleteTimer); + setConfirmingDeleteId(null); + + const themeId = selectedThemeId(); + try { + await sourcesApi.remove(id); + if (themeId) await fetchSources(themeId); + } catch (err) { + console.error('Failed to delete source:', err); + } + }; + + // ---- CSV Export ---- + const handleExportCsv = async () => { + setCsvError(null); + try { + await sourcesApi.exportCsv(selectedThemeId() ?? undefined); + } catch { + setCsvError(t('sources.exportError')); + } + }; + + // ---- CSV Import ---- + const handleImportCsv = async (e: Event) => { + const input = e.target as HTMLInputElement; + const file = input.files?.[0]; + if (!file) return; + + const themeId = selectedThemeId(); + if (!themeId) return; + + setImporting(true); + setCsvError(null); + + try { + await sourcesApi.importCsv(file); + await fetchSources(themeId); + } catch (err) { + if (isApiError(err)) { + setCsvError(err.message); + } else { + setCsvError(t('sources.csvImportError')); + } + } finally { + setImporting(false); + input.value = ''; + } + }; + + // ---- Bulk Import ---- + const handleBulkImport = async (e: SubmitEvent) => { + e.preventDefault(); + if (!bulkText().trim()) return; + + const themeId = selectedThemeId(); + if (!themeId) return; + + setImporting(true); + setImportError(null); + + const lines = bulkText() + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.length > 0); + + const validSources: { title: string; url: string; theme_id?: string }[] = []; + + for (const line of lines) { + const parts = line.split(';'); + if (parts.length >= 2) { + const title = parts[0].trim(); + const url = normalizeUrl(parts.slice(1).join(';').trim()); + if (title && url) { + validSources.push({ title, url, theme_id: themeId }); + } + } + } + + if (validSources.length === 0) { + setImportError(t('sources.bulkImportError')); + setImporting(false); + return; + } + + try { + await sourcesApi.bulkImport({ sources: validSources, theme_id: themeId }); + setBulkText(''); + await fetchSources(themeId); + } catch (err) { + if (isApiError(err)) { + setImportError(err.message); + } else { + setImportError(t('sources.bulkImportError')); + } + } finally { + setImporting(false); + } + }; + + // ---- Render ---- + return ( + }> +
+ {/* Page header */} +
+

+ {t('themes.title')} +

+
+ + {/* Message banner (persists above theme selector for create/delete feedback) */} + + {(msg) => ( +
+ {msg().text} +
+ )} +
+ + {/* Theme selector */} +
+ + +
+ + {/* No themes message */} + +
+

{t('themes.noThemes')}

+
+
+ + {/* Selected theme content */} + + {(theme) => ( + <> + {/* ── Content settings card ── */} +
+

+ {t('themes.contentSection')} +

+ +
+ {/* Theme name */} +
+ +
+ setEditName(e.currentTarget.value)} + /> +
+
+ + {/* Search topic */} +
+ +

+ {t('themes.searchTopicHelp')} +

+
+ setEditThemeTopic(e.currentTarget.value)} + placeholder={t('settings.themeHelp')} + /> +
+
+ + {/* Categories */} +
+ +
+ + {(cat) => ( + + {cat} + + + )} + +
+
+ setNewCategory(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddCategory(); + } + }} + /> + +
+
+ + {/* Max age + Max items */} +
+
+ +
+ + setEditMaxAge(parseInt(e.currentTarget.value) || 7) + } + /> +
+
+
+ +
+ + setEditMaxItems(parseInt(e.currentTarget.value) || 5) + } + /> +
+
+
+ + {/* Summary length slider */} +
+ +

+ {t('settings.summaryLengthHelp')} +

+
+ + setEditSummaryLength(parseInt(e.currentTarget.value) || 2) + } + /> + + {summaryLengthLabel()} + +
+
+
+ + {/* Save button */} +
+ +
+
+ + {/* ── Sources card ── */} +
+

+ {t('sources.title')} +

+

+ {t('sources.subtitle')} +

+ + {/* Add a source */} +
+

+ {t('sources.addTitle')} +

+
+
+ + setNewTitle(e.currentTarget.value)} + /> +
+
+ + setNewUrl(e.currentTarget.value)} + /> +
+ +
+ + {(msg) => ( +

{msg()}

+ )} +
+
+ + {/* CSV Import / Export */} +
+

+ {t('sources.csvSection')} +

+

+ {t('sources.csvDescription')} +

+
+ + +
+ + {(msg) => ( +

{msg()}

+ )} +
+
+ + {/* Bulk Import */} +
+

+ {t('sources.bulkSection')} +

+

+ {t('sources.bulkDescription')}{' '} + {t('sources.bulkFormat')} +

+
+
+ +