From c6aa1afdc5ddaa0a22ac43eee4d19999a88561ef Mon Sep 17 00:00:00 2001 From: oabrivard Date: Wed, 1 Apr 2026 00:17:40 +0200 Subject: [PATCH] refactor: decompose ThemeSourceList into SourceAddForm + SourceImport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ThemeSourceList: 477 → 222 lines (source list + preferred + delete) SourceAddForm: 114 lines (title + URL form) SourceImport: 186 lines (CSV import/export + bulk text import) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/theme/SourceAddForm.tsx | 114 ++++++++ .../src/components/theme/SourceImport.tsx | 186 ++++++++++++ .../src/components/theme/ThemeSourceList.tsx | 271 +----------------- 3 files changed, 308 insertions(+), 263 deletions(-) create mode 100644 frontend/src/components/theme/SourceAddForm.tsx create mode 100644 frontend/src/components/theme/SourceImport.tsx diff --git a/frontend/src/components/theme/SourceAddForm.tsx b/frontend/src/components/theme/SourceAddForm.tsx new file mode 100644 index 0000000..a304c9c --- /dev/null +++ b/frontend/src/components/theme/SourceAddForm.tsx @@ -0,0 +1,114 @@ +import { type Component, createSignal, Show } from 'solid-js'; +import { Plus } 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'; + +interface SourceAddFormProps { + themeId: string; + onAdded: () => void; +} + +/** Simple form to add a single source (title + URL). */ +const SourceAddForm: Component = (props) => { + const { t } = useI18n(); + + const [newTitle, setNewTitle] = createSignal(''); + const [newUrl, setNewUrl] = createSignal(''); + const [adding, setAdding] = createSignal(false); + const [addError, setAddError] = createSignal(null); + + 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(''); + props.onAdded(); + } catch (err) { + if (isApiError(err)) { + setAddError(err.message); + } else { + setAddError(t('sources.addError')); + } + } finally { + setAdding(false); + } + }; + + return ( +
+

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

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

{msg()}

+ )} +
+
+ ); +}; + +export default SourceAddForm; diff --git a/frontend/src/components/theme/SourceImport.tsx b/frontend/src/components/theme/SourceImport.tsx new file mode 100644 index 0000000..e5a16f0 --- /dev/null +++ b/frontend/src/components/theme/SourceImport.tsx @@ -0,0 +1,186 @@ +import { type Component, createSignal, Show } from 'solid-js'; +import { Download, Upload } from 'lucide-solid'; +import { sourcesApi } from '~/api/sources'; +import { normalizeUrl } from '~/utils/url'; +import { useI18n } from '~/i18n'; +import { isApiError } from '~/types'; + +interface SourceImportProps { + themeId: string; + onImported: () => void; +} + +/** CSV import/export + bulk text import for sources. */ +const SourceImport: Component = (props) => { + const { t } = useI18n(); + + const [bulkText, setBulkText] = createSignal(''); + const [importing, setImporting] = createSignal(false); + const [importError, setImportError] = createSignal(null); + const [csvError, setCsvError] = createSignal(null); + + let fileInputRef: HTMLInputElement | undefined; + + // ---- 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); + props.onImported(); + } 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(''); + props.onImported(); + } catch (err) { + if (isApiError(err)) { + setImportError(err.message); + } else { + setImportError(t('sources.bulkImportError')); + } + } finally { + setImporting(false); + } + }; + + return ( + <> + {/* CSV Import / Export */} +
+

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

+

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

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

{msg()}

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

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

+

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

+
+
+ +