feat: Phase 2 — theme management page with content settings + per-theme sources
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>master
parent
196005a27b
commit
d9bfed7ff5
@ -0,0 +1,50 @@
|
||||
import { api } from './client';
|
||||
|
||||
// ---- Theme types ----
|
||||
|
||||
export interface ThemeResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
theme: string;
|
||||
categories: string[];
|
||||
max_items_per_category: number;
|
||||
max_age_days: number;
|
||||
summary_length: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateThemeRequest {
|
||||
name: string;
|
||||
theme: string;
|
||||
categories: string[];
|
||||
max_items_per_category?: number;
|
||||
max_age_days?: number;
|
||||
summary_length?: number;
|
||||
}
|
||||
|
||||
export interface UpdateThemeRequest {
|
||||
name?: string;
|
||||
theme?: string;
|
||||
categories?: string[];
|
||||
max_items_per_category?: number;
|
||||
max_age_days?: number;
|
||||
summary_length?: number;
|
||||
}
|
||||
|
||||
/** Themes API endpoints (CRUD for user-defined synthesis themes). */
|
||||
export const themesApi = {
|
||||
/** GET /themes -- list all themes belonging to the current user. */
|
||||
list: (): Promise<ThemeResponse[]> => api.get<ThemeResponse[]>('/themes'),
|
||||
|
||||
/** POST /themes -- create a new theme. */
|
||||
create: (data: CreateThemeRequest): Promise<ThemeResponse> =>
|
||||
api.post<ThemeResponse>('/themes', data),
|
||||
|
||||
/** PUT /themes/:id -- update an existing theme. */
|
||||
update: (id: string, data: UpdateThemeRequest): Promise<ThemeResponse> =>
|
||||
api.put<ThemeResponse>(`/themes/${id}`, data),
|
||||
|
||||
/** DELETE /themes/:id -- delete a theme by ID. */
|
||||
remove: (id: string): Promise<void> => api.delete<void>(`/themes/${id}`),
|
||||
};
|
||||
@ -0,0 +1,883 @@
|
||||
import {
|
||||
type Component,
|
||||
createSignal,
|
||||
createEffect,
|
||||
onMount,
|
||||
onCleanup,
|
||||
Show,
|
||||
For,
|
||||
} from 'solid-js';
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
Link as LinkIcon,
|
||||
Download,
|
||||
Upload,
|
||||
Save,
|
||||
} 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 '~/pages/Sources';
|
||||
import { useI18n } from '~/i18n';
|
||||
import { isApiError } from '~/types';
|
||||
import type { Source } from '~/types';
|
||||
import LoadingSpinner from '~/components/ui/LoadingSpinner';
|
||||
|
||||
/**
|
||||
* 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: '',
|
||||
categories: [],
|
||||
};
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 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);
|
||||
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')}
|
||||
>
|
||||
×
|
||||
</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">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* ── 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;
|
||||
Loading…
Reference in New Issue