diff --git a/frontend/src/components/theme/ThemeContentForm.tsx b/frontend/src/components/theme/ThemeContentForm.tsx new file mode 100644 index 0000000..a752384 --- /dev/null +++ b/frontend/src/components/theme/ThemeContentForm.tsx @@ -0,0 +1,298 @@ +import { type Component, createSignal, createEffect, Show, For } from 'solid-js'; +import { Plus, Save } from 'lucide-solid'; +import Button from '~/components/ui/Button'; +import { themesApi } from '~/api/themes'; +import type { ThemeResponse, UpdateThemeRequest } from '~/api/themes'; +import { useI18n } from '~/i18n'; +import { isApiError } from '~/types'; + +interface ThemeContentFormProps { + theme: ThemeResponse; + onSaved: (updated: ThemeResponse) => void; +} + +/** + * Content settings card for a theme: name, search topic, categories, + * max age, max items, summary length slider, and save button. + */ +const ThemeContentForm: Component = (props) => { + const { t } = useI18n(); + + // ---- Editing signals ---- + 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); + + // ---- Populate signals when props.theme changes ---- + createEffect(() => { + const theme = props.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); + }); + + // ---- 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'); + }; + + // ---- Save theme settings ---- + const handleSaveTheme = async () => { + 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(props.theme.id, data); + props.onSaved(updated); + 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); + } + }; + + return ( +
+

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

+ + {/* Save feedback message */} + + {(msg) => ( +
+ {msg().text} +
+ )} +
+ +
+ {/* 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 */} +
+ +
+
+ ); +}; + +export default ThemeContentForm; diff --git a/frontend/src/components/theme/ThemeSourceList.tsx b/frontend/src/components/theme/ThemeSourceList.tsx new file mode 100644 index 0000000..e891bd6 --- /dev/null +++ b/frontend/src/components/theme/ThemeSourceList.tsx @@ -0,0 +1,477 @@ +import { + type Component, + createSignal, + onMount, + onCleanup, + Show, + For, +} from 'solid-js'; +import { + Plus, + Trash2, + Link as LinkIcon, + Download, + Upload, + Star, +} from 'lucide-solid'; +import Button from '~/components/ui/Button'; +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'; + +interface ThemeSourceListProps { + themeId: string; +} + +/** + * Sources card for a theme: add source, CSV import/export, + * bulk import, source list with preferred toggles and delete. + */ +const ThemeSourceList: Component = (props) => { + const { t } = useI18n(); + + // ---- 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); + }); + + // ---- Load sources ---- + const fetchSources = async () => { + setLoadingSources(true); + try { + const data = await sourcesApi.list(props.themeId); + setSources(data); + } catch (err) { + console.error('Failed to load sources:', err); + } finally { + setLoadingSources(false); + } + }; + + onMount(fetchSources); + + // ---- Add a single source ---- + const handleAddSource = async (e: SubmitEvent) => { + e.preventDefault(); + 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: props.themeId }); + setNewTitle(''); + setNewUrl(''); + await fetchSources(); + } 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); + + try { + await sourcesApi.remove(id); + await fetchSources(); + } catch (err) { + console.error('Failed to delete source:', err); + } + }; + + // ---- Toggle preferred source ---- + const handleTogglePreferred = async (sourceId: string) => { + 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, props.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(props.themeId); + } 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; + + setImporting(true); + setCsvError(null); + + try { + await sourcesApi.importCsv(file, props.themeId); + await fetchSources(); + } 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; + + 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: props.themeId }); + } + } + } + + if (validSources.length === 0) { + setImportError(t('sources.bulkImportError')); + setImporting(false); + return; + } + + try { + await sourcesApi.bulkImport({ sources: validSources, theme_id: props.themeId }); + setBulkText(''); + await fetchSources(); + } catch (err) { + if (isApiError(err)) { + setImportError(err.message); + } else { + setImportError(t('sources.bulkImportError')); + } + } finally { + setImporting(false); + } + }; + + return ( +
+

+ {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')} +

+
+
+ +