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
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;
|
||||||
Loading…
Reference in New Issue