refactor: decompose ThemeSourceList into SourceAddForm + SourceImport

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) <noreply@anthropic.com>
master
oabrivard 2 months ago
parent 3d790e7ce7
commit c6aa1afdc5

@ -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<SourceAddFormProps> = (props) => {
const { t } = useI18n();
const [newTitle, setNewTitle] = createSignal('');
const [newUrl, setNewUrl] = createSignal('');
const [adding, setAdding] = createSignal(false);
const [addError, setAddError] = createSignal<string | null>(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 (
<div class="mb-6">
<h3 class="text-base font-medium text-gray-900 mb-3">
{t('sources.addTitle')}
</h3>
<form
onSubmit={handleAddSource}
class="space-y-4 sm:flex sm:space-y-0 sm:space-x-4"
>
<div class="flex-1">
<label for="source-title" class="sr-only">
{t('sources.titleLabel')}
</label>
<input
type="text"
id="source-title"
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md p-2 border"
placeholder={t('sources.titlePlaceholder')}
value={newTitle()}
onInput={(e) => setNewTitle(e.currentTarget.value)}
/>
</div>
<div class="flex-1">
<label for="source-url" class="sr-only">
{t('sources.urlLabel')}
</label>
<input
type="text"
id="source-url"
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md p-2 border"
placeholder={t('sources.urlPlaceholder')}
value={newUrl()}
onInput={(e) => setNewUrl(e.currentTarget.value)}
/>
</div>
<Button
type="submit"
loading={adding()}
icon={Plus}
>
{t('sources.add')}
</Button>
</form>
<Show when={addError()}>
{(msg) => (
<p class="mt-2 text-sm text-red-600">{msg()}</p>
)}
</Show>
</div>
);
};
export default SourceAddForm;

@ -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<SourceImportProps> = (props) => {
const { t } = useI18n();
const [bulkText, setBulkText] = createSignal('');
const [importing, setImporting] = createSignal(false);
const [importError, setImportError] = createSignal<string | null>(null);
const [csvError, setCsvError] = createSignal<string | null>(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 */}
<div class="mb-6">
<h3 class="text-base font-medium text-gray-900 mb-3">
{t('sources.csvSection')}
</h3>
<p class="text-sm text-gray-500 mb-3">
{t('sources.csvDescription')}
</p>
<div class="flex space-x-4">
<button
onClick={handleExportCsv}
class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<Download class="h-4 w-4 mr-2" />
{t('sources.exportCsv')}
</button>
<label class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 cursor-pointer">
<Upload class="h-4 w-4 mr-2" />
{t('sources.importCsv')}
<input
ref={fileInputRef}
type="file"
class="hidden"
accept=".csv"
onChange={handleImportCsv}
disabled={importing()}
/>
</label>
</div>
<Show when={csvError()}>
{(msg) => (
<p class="mt-2 text-sm text-red-600">{msg()}</p>
)}
</Show>
</div>
{/* Bulk Import */}
<div class="mb-6">
<h3 class="text-base font-medium text-gray-900 mb-3">
{t('sources.bulkSection')}
</h3>
<p class="text-sm text-gray-500 mb-3">
{t('sources.bulkDescription')}{' '}
<strong>{t('sources.bulkFormat')}</strong>
</p>
<form onSubmit={handleBulkImport} class="space-y-4">
<div>
<label for="bulk-import" class="sr-only">
{t('sources.bulkSection')}
</label>
<textarea
id="bulk-import"
rows={5}
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md p-2 border"
placeholder={t('sources.bulkPlaceholder')}
value={bulkText()}
onInput={(e) => setBulkText(e.currentTarget.value)}
/>
</div>
<Show when={importError()}>
{(msg) => (
<p class="text-sm text-red-600">{msg()}</p>
)}
</Show>
<button
type="submit"
disabled={importing() || !bulkText().trim()}
class="inline-flex items-center justify-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{importing()
? t('sources.importing')
: t('sources.bulkImport')}
</button>
</form>
</div>
</>
);
};
export default SourceImport;

@ -7,47 +7,33 @@ import {
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';
import SourceAddForm from '~/components/theme/SourceAddForm';
import SourceImport from '~/components/theme/SourceImport';
interface ThemeSourceListProps {
themeId: string;
}
/**
* Sources card for a theme: add source, CSV import/export,
* bulk import, source list with preferred toggles and delete.
* Sources card for a theme: delegates add and import to sub-components,
* manages the source list with preferred toggles and delete confirmation.
*/
const ThemeSourceList: Component<ThemeSourceListProps> = (props) => {
const { t } = useI18n();
// ---- Sources state ----
const [sources, setSources] = createSignal<Source[]>([]);
const [loadingSources, setLoadingSources] = createSignal(false);
const [newTitle, setNewTitle] = createSignal('');
const [newUrl, setNewUrl] = createSignal('');
const [adding, setAdding] = createSignal(false);
const [addError, setAddError] = createSignal<string | null>(null);
const [bulkText, setBulkText] = createSignal('');
const [importing, setImporting] = createSignal(false);
const [importError, setImportError] = createSignal<string | null>(null);
const [csvError, setCsvError] = createSignal<string | null>(null);
const [confirmingDeleteId, setConfirmingDeleteId] = createSignal<string | null>(null);
let deleteTimer: ReturnType<typeof setTimeout> | undefined;
let fileInputRef: HTMLInputElement | undefined;
onCleanup(() => {
if (deleteTimer) clearTimeout(deleteTimer);
@ -68,46 +54,6 @@ const ThemeSourceList: Component<ThemeSourceListProps> = (props) => {
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) {
@ -155,87 +101,6 @@ const ThemeSourceList: Component<ThemeSourceListProps> = (props) => {
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 (
<div class="mb-8 bg-white shadow-sm rounded-lg border border-gray-200 p-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">
@ -245,131 +110,11 @@ const ThemeSourceList: Component<ThemeSourceListProps> = (props) => {
{t('sources.subtitle')}
</p>
{/* Add a source */}
<div class="mb-6">
<h3 class="text-base font-medium text-gray-900 mb-3">
{t('sources.addTitle')}
</h3>
<form
onSubmit={handleAddSource}
class="space-y-4 sm:flex sm:space-y-0 sm:space-x-4"
>
<div class="flex-1">
<label for="source-title" class="sr-only">
{t('sources.titleLabel')}
</label>
<input
type="text"
id="source-title"
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md p-2 border"
placeholder={t('sources.titlePlaceholder')}
value={newTitle()}
onInput={(e) => setNewTitle(e.currentTarget.value)}
/>
</div>
<div class="flex-1">
<label for="source-url" class="sr-only">
{t('sources.urlLabel')}
</label>
<input
type="text"
id="source-url"
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md p-2 border"
placeholder={t('sources.urlPlaceholder')}
value={newUrl()}
onInput={(e) => setNewUrl(e.currentTarget.value)}
/>
</div>
<Button
type="submit"
loading={adding()}
icon={Plus}
>
{t('sources.add')}
</Button>
</form>
<Show when={addError()}>
{(msg) => (
<p class="mt-2 text-sm text-red-600">{msg()}</p>
)}
</Show>
</div>
{/* CSV Import / Export */}
<div class="mb-6">
<h3 class="text-base font-medium text-gray-900 mb-3">
{t('sources.csvSection')}
</h3>
<p class="text-sm text-gray-500 mb-3">
{t('sources.csvDescription')}
</p>
<div class="flex space-x-4">
<button
onClick={handleExportCsv}
class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<Download class="h-4 w-4 mr-2" />
{t('sources.exportCsv')}
</button>
<label class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 cursor-pointer">
<Upload class="h-4 w-4 mr-2" />
{t('sources.importCsv')}
<input
ref={fileInputRef}
type="file"
class="hidden"
accept=".csv"
onChange={handleImportCsv}
disabled={importing()}
/>
</label>
</div>
<Show when={csvError()}>
{(msg) => (
<p class="mt-2 text-sm text-red-600">{msg()}</p>
)}
</Show>
</div>
{/* Add a single source */}
<SourceAddForm themeId={props.themeId} onAdded={fetchSources} />
{/* Bulk Import */}
<div class="mb-6">
<h3 class="text-base font-medium text-gray-900 mb-3">
{t('sources.bulkSection')}
</h3>
<p class="text-sm text-gray-500 mb-3">
{t('sources.bulkDescription')}{' '}
<strong>{t('sources.bulkFormat')}</strong>
</p>
<form onSubmit={handleBulkImport} class="space-y-4">
<div>
<label for="bulk-import" class="sr-only">
{t('sources.bulkSection')}
</label>
<textarea
id="bulk-import"
rows={5}
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md p-2 border"
placeholder={t('sources.bulkPlaceholder')}
value={bulkText()}
onInput={(e) => setBulkText(e.currentTarget.value)}
/>
</div>
<Show when={importError()}>
{(msg) => (
<p class="text-sm text-red-600">{msg()}</p>
)}
</Show>
<button
type="submit"
disabled={importing() || !bulkText().trim()}
class="inline-flex items-center justify-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{importing()
? t('sources.importing')
: t('sources.bulkImport')}
</button>
</form>
</div>
{/* CSV + Bulk import */}
<SourceImport themeId={props.themeId} onImported={fetchSources} />
{/* Source list */}
<Show when={loadingSources()}>

Loading…
Cancel
Save