import { type Component, createSignal, createEffect, onMount, onCleanup, Show, For, } from 'solid-js'; import { Plus, Trash2, Link as LinkIcon, Download, Upload, Save, Star, } from 'lucide-solid'; import Button from '~/components/ui/Button'; import { themesApi } from '~/api/themes'; import type { ThemeResponse, CreateThemeRequest, UpdateThemeRequest } from '~/api/themes'; import { sourcesApi } from '~/api/sources'; import { normalizeUrl, isValidUrl } from '~/utils/url'; import { useI18n } from '~/i18n'; import { isApiError } from '~/types'; import type { Source } from '~/types'; import LoadingSpinner from '~/components/ui/LoadingSpinner'; import SettingsSchedule from '~/components/settings/SettingsSchedule'; /** * Theme management page ("Personnaliser les syntheses"). * * Replaces the standalone Sources page by grouping sources under user-defined * themes. Each theme carries its own content settings (search topic, * categories, age, item count, summary length) and an associated set of * custom sources. */ const ThemeManager: Component = () => { 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: t('themes.createTheme'), categories: [t('themes.defaultCategory')], }; 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); } }; // ---- Toggle preferred source ---- const handleTogglePreferred = async (sourceId: string) => { const themeId = selectedThemeId(); if (!themeId) return; const current = sources(); const toggled = current.map((s) => s.id === sourceId ? { ...s, is_preferred: !s.is_preferred } : s, ); const newPreferredIds = toggled.filter((s) => s.is_preferred).map((s) => s.id); // Optimistically update local state setSources(toggled); try { await sourcesApi.updatePreferred(newPreferredIds, themeId); } catch (err) { // Revert on error setSources(current); console.error('Failed to update preferred sources:', err); } }; const preferredCount = (): number => sources().filter((s) => s.is_preferred).length; // ---- 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, themeId); 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')}