You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

939 lines
34 KiB
TypeScript

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<ThemeResponse[]>([]);
const [loading, setLoading] = createSignal(true);
const [selectedThemeId, setSelectedThemeId] = createSignal<string | null>(null);
// ---- Editing state for the selected theme's content settings ----
const [editName, setEditName] = createSignal('');
const [editThemeTopic, setEditThemeTopic] = createSignal('');
const [editCategories, setEditCategories] = createSignal<string[]>([]);
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<typeof setTimeout> | undefined;
// ---- 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);
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 (
<Show when={!loading()} fallback={<LoadingSpinner />}>
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Page header */}
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">
{t('themes.title')}
</h1>
</div>
{/* Message banner (persists above theme selector for create/delete feedback) */}
<Show when={themeMessage()}>
{(msg) => (
<div
class={`mb-6 p-4 rounded-md ${
msg().type === 'success'
? 'bg-green-50 text-green-800 border border-green-200'
: 'bg-red-50 text-red-800 border border-red-200'
}`}
>
{msg().text}
</div>
)}
</Show>
{/* Theme selector */}
<div class="mb-8 flex items-center gap-4">
<select
class="block w-full pl-3 pr-10 py-2 text-sm border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 rounded-md border bg-white"
value={selectedThemeId() ?? ''}
onChange={(e) => {
const val = e.currentTarget.value;
setSelectedThemeId(val || null);
}}
>
<option value="">{t('themes.selectTheme')}</option>
<For each={themes()}>
{(theme) => (
<option value={theme.id}>{theme.name}</option>
)}
</For>
</select>
<Button
onClick={handleCreateTheme}
loading={creating()}
icon={Plus}
>
{t('themes.createTheme')}
</Button>
</div>
{/* No themes message */}
<Show when={themes().length === 0 && !selectedThemeId()}>
<div class="text-center py-12 text-gray-500">
<p>{t('themes.noThemes')}</p>
</div>
</Show>
{/* Selected theme content */}
<Show when={selectedTheme()}>
{(theme) => (
<>
{/* ── Content settings card ── */}
<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">
{t('themes.contentSection')}
</h2>
<div class="space-y-6">
{/* Theme name */}
<div>
<label
for="theme-name"
class="block text-sm font-medium text-gray-700"
>
{t('themes.name')}
</label>
<div class="mt-1">
<input
type="text"
id="theme-name"
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md py-2 px-3 border"
value={editName()}
onInput={(e) => setEditName(e.currentTarget.value)}
/>
</div>
</div>
{/* Search topic */}
<div>
<label
for="theme-topic"
class="block text-sm font-medium text-gray-700"
>
{t('themes.searchTopic')}
</label>
<p class="text-xs text-gray-500 mb-1">
{t('themes.searchTopicHelp')}
</p>
<div class="mt-1">
<input
type="text"
id="theme-topic"
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md py-2 px-3 border"
value={editThemeTopic()}
onInput={(e) => setEditThemeTopic(e.currentTarget.value)}
placeholder={t('settings.themeHelp')}
/>
</div>
</div>
{/* Categories */}
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
{t('settings.categories')}
</label>
<div class="flex flex-wrap gap-2 mb-3">
<For each={editCategories()}>
{(cat) => (
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-indigo-100 text-indigo-800">
{cat}
<button
type="button"
onClick={() => handleRemoveCategory(cat)}
class="ml-1.5 text-indigo-600 hover:text-indigo-900"
title={t('settings.removeCategory')}
>
&times;
</button>
</span>
)}
</For>
</div>
<div class="flex gap-2">
<input
type="text"
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block flex-1 sm:text-sm border-gray-300 rounded-md py-2 px-3 border"
placeholder={t('settings.newCategory')}
value={newCategory()}
onInput={(e) => setNewCategory(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddCategory();
}
}}
/>
<Button
type="button"
variant="secondary"
onClick={handleAddCategory}
icon={Plus}
>
{t('settings.addCategory')}
</Button>
</div>
</div>
{/* Max age + Max items */}
<div class="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-2">
<div>
<label
for="theme-max-age"
class="block text-sm font-medium text-gray-700"
>
{t('settings.maxAgeDays')}
</label>
<div class="mt-1">
<input
type="number"
id="theme-max-age"
min="1"
max="90"
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-24 sm:text-sm border-gray-300 rounded-md py-2 px-3 border"
value={editMaxAge()}
onInput={(e) =>
setEditMaxAge(parseInt(e.currentTarget.value) || 7)
}
/>
</div>
</div>
<div>
<label
for="theme-max-items"
class="block text-sm font-medium text-gray-700"
>
{t('settings.maxItems')}
</label>
<div class="mt-1">
<input
type="number"
id="theme-max-items"
min="1"
max="20"
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-24 sm:text-sm border-gray-300 rounded-md py-2 px-3 border"
value={editMaxItems()}
onInput={(e) =>
setEditMaxItems(parseInt(e.currentTarget.value) || 5)
}
/>
</div>
</div>
</div>
{/* Summary length slider */}
<div>
<label
for="theme-summary-length"
class="block text-sm font-medium text-gray-700"
>
{t('settings.summaryLength')}
</label>
<p class="text-xs text-gray-500 mb-2">
{t('settings.summaryLengthHelp')}
</p>
<div class="flex items-center gap-4">
<input
type="range"
id="theme-summary-length"
min="1"
max="3"
step="1"
class="w-48"
value={editSummaryLength()}
onInput={(e) =>
setEditSummaryLength(parseInt(e.currentTarget.value) || 2)
}
/>
<span class="text-sm font-medium text-gray-700">
{summaryLengthLabel()}
</span>
</div>
</div>
</div>
{/* Save button */}
<div class="mt-6 flex justify-end">
<Button
onClick={handleSaveTheme}
loading={savingTheme()}
icon={Save}
>
{t('common.save')}
</Button>
</div>
</div>
{/* ── Sources card ── */}
<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">
{t('sources.title')}
</h2>
<p class="text-sm text-gray-500 mb-6">
{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>
{/* 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>
{/* Source list */}
<Show when={loadingSources()}>
<LoadingSpinner />
</Show>
<Show when={!loadingSources()}>
<div class="bg-white shadow overflow-hidden sm:rounded-md">
<ul class="divide-y divide-gray-200">
<Show
when={sources().length > 0}
fallback={
<li class="px-4 py-8 text-center text-gray-500">
<p>{t('sources.empty')}</p>
<p class="mt-1 text-xs">{t('sources.emptyHint')}</p>
</li>
}
>
<For each={sources()}>
{(source) => (
<li>
<div class={`px-4 py-4 flex items-center sm:px-6 ${source.is_preferred ? 'bg-amber-50' : ''}`}>
<div class="flex-shrink-0 mr-3">
<button
type="button"
onClick={() => handleTogglePreferred(source.id)}
class={`p-1 rounded transition-colors ${
source.is_preferred
? 'text-amber-500 hover:text-amber-600'
: 'text-gray-300 hover:text-amber-400'
}`}
title={t('sources.preferred')}
>
<Star class={`h-5 w-5 ${source.is_preferred ? 'fill-current' : ''}`} />
</button>
</div>
<div class="min-w-0 flex-1 sm:flex sm:items-center sm:justify-between">
<div class="truncate">
<div class="flex text-sm">
<p class="font-medium text-indigo-600 truncate">
{source.title}
</p>
</div>
<div class="mt-2 flex">
<div class="flex items-center text-sm text-gray-500">
<LinkIcon class="flex-shrink-0 mr-1.5 h-4 w-4 text-gray-400" />
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
class="truncate hover:underline"
>
{source.url}
</a>
</div>
</div>
</div>
</div>
<div class="ml-5 flex-shrink-0">
<button
onClick={() => handleDeleteClick(source.id)}
class={`p-2 transition-colors ${
confirmingDeleteId() === source.id
? 'text-red-600 bg-red-50 rounded-md'
: 'text-gray-400 hover:text-red-600'
}`}
title={
confirmingDeleteId() === source.id
? t('sources.confirmDelete')
: t('sources.deleteTitle')
}
>
<Show
when={confirmingDeleteId() === source.id}
fallback={<Trash2 class="h-5 w-5" />}
>
<span class="text-xs font-medium">
{t('sources.confirmDelete')}
</span>
</Show>
</button>
</div>
</div>
</li>
)}
</For>
</Show>
</ul>
</div>
<Show when={preferredCount() > 0}>
<div class="mt-3 flex items-center gap-2 text-sm text-amber-700">
<Star class="h-4 w-4 fill-current text-amber-500" />
<span>
{t('sources.preferredCount').replace('{count}', String(preferredCount()))}
</span>
</div>
<p class="mt-1 text-xs text-gray-500">
{t('sources.preferredHelp')}
</p>
</Show>
</Show>
</div>
{/* ── Schedule card ── */}
<SettingsSchedule themeId={selectedThemeId()!} />
{/* ── Delete theme ── */}
<div class="mt-8">
<Button
variant="danger"
onClick={handleDeleteThemeClick}
icon={Trash2}
>
{confirmingDeleteTheme()
? t('themes.deleteConfirm')
: t('themes.deleteTheme')}
</Button>
</div>
</>
)}
</Show>
</div>
</Show>
);
};
export default ThemeManager;