refactor: redesign settings page with clear section grouping and merged AI section

Reorganize settings into 5 logical sections (Content, Sources, AI,
Performance, Import/Export) with visual cards. Merge AI provider
selection with API key management into one cohesive section.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
master
oabrivard 3 months ago
parent a89c61c5b6
commit 70865a68b3

@ -35,13 +35,13 @@ const ApiKeyManager: Component<ApiKeyManagerProps> = (props) => {
};
return (
<div class="bg-white shadow overflow-hidden sm:rounded-lg border border-gray-200 mt-8">
<div class="px-4 py-5 sm:px-6 border-b border-gray-200">
<div class="overflow-hidden rounded-lg border border-gray-200">
<div class="px-4 py-4 sm:px-5 border-b border-gray-200 bg-gray-50">
<div class="flex items-center">
<Key class="h-5 w-5 text-indigo-600 mr-2" />
<h2 class="text-lg font-medium text-gray-900">
<h3 class="text-sm font-medium text-gray-900">
{t('settings.apiKeys.title')}
</h2>
</h3>
</div>
<p class="mt-1 text-sm text-gray-500">
{t('settings.apiKeys.description')}

@ -20,7 +20,6 @@ const SettingsRateLimit: Component<SettingsRateLimitProps> = (props) => {
return (
<>
<hr class="border-gray-200" />
<div>
<h3 class="text-sm font-medium text-gray-700 mb-4">
{t('settings.rateLimitSection')}

@ -115,6 +115,20 @@ const fr = {
'synthesis.export.downloading': 'Telechargement...',
'synthesis.export.error': 'Erreur lors de l\'export.',
// Settings - Section headings
'settings.section.content': 'Contenu',
'settings.section.contentDesc': 'Definissez le theme, les categories et le format des syntheses.',
'settings.section.sources': 'Sources',
'settings.section.sourcesDesc': 'Configurez l\'extraction des articles depuis vos sources personnalisees.',
'settings.section.ai': 'Intelligence Artificielle',
'settings.section.aiDesc': 'Choisissez le fournisseur, les modeles et configurez vos cles API.',
'settings.section.performance': 'Performance',
'settings.section.performanceDesc': 'Parametres techniques pour optimiser la generation.',
'settings.section.importExport': 'Import / Export',
'settings.section.importExportDesc': 'Sauvegardez ou restaurez votre configuration.',
'settings.aiKeyConfigured': 'Cle configuree',
'settings.aiKeyNotConfigured': 'Cle non configuree',
// Settings
'settings.title': 'Parametres de generation',
'settings.theme': 'Theme de la recherche',

@ -7,6 +7,7 @@ import {
For,
createEffect,
} from 'solid-js';
import { A } from '@solidjs/router';
import { Settings as SettingsIcon, Save, Plus, Trash2, Info, Download, Upload } from 'lucide-solid';
import Button from '~/components/ui/Button';
import { settingsApi } from '~/api/settings';
@ -19,7 +20,6 @@ import LoadingSpinner from '~/components/ui/LoadingSpinner';
import ApiKeyManager from '~/components/ApiKeyManager';
import { getProviderInfoKey, providerSupportsWebSearch } from '~/utils/providers';
import SettingsBraveSearch from '~/components/settings/SettingsBraveSearch';
import SettingsAdvanced from '~/components/settings/SettingsAdvanced';
import SettingsRateLimit from '~/components/settings/SettingsRateLimit';
/**
@ -53,6 +53,7 @@ const Settings: Component = () => {
const [includeApiKeys, setIncludeApiKeys] = createSignal(false);
const [providers] = createResource(() => configApi.listProviders());
const [apiKeys, { refetch: refetchApiKeys }] = createResource(() => apiKeysApi.list());
const [providerWarning, setProviderWarning] = createSignal(false);
let fileInputRef: HTMLInputElement | undefined;
@ -101,6 +102,10 @@ const Settings: Component = () => {
);
};
const getKeyForProvider = (providerName: string) => {
return apiKeys()?.find((k) => k.provider_name === providerName);
};
const handleProviderChange = (providerName: string) => {
setProviderWarning(false);
const provider = providers()?.find(
@ -232,6 +237,7 @@ const Settings: Component = () => {
});
}
}
refetchApiKeys();
}
setMessage({ type: 'success', text: t('settings.importSuccess') });
@ -248,63 +254,12 @@ const Settings: Component = () => {
return (
<Show when={!loading()} fallback={<LoadingSpinner />}>
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex items-center justify-between mb-8">
<div class="flex items-center">
<SettingsIcon class="h-8 w-8 text-indigo-600 mr-3" />
<h1 class="text-3xl font-extrabold text-gray-900">
{t('settings.title')}
</h1>
</div>
<div class="flex items-center gap-2">
{/* Export button */}
<button
type="button"
onClick={handleExport}
class="inline-flex items-center px-3 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"
title={t('settings.export')}
>
<Download class="h-4 w-4 mr-1" />
{t('settings.export')}
</button>
{/* Import button */}
<button
type="button"
onClick={() => fileInputRef?.click()}
class="inline-flex items-center px-3 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"
title={t('settings.import')}
>
<Upload class="h-4 w-4 mr-1" />
{t('settings.import')}
</button>
<input
ref={fileInputRef}
type="file"
accept=".json"
class="hidden"
onChange={(e) => {
const file = e.currentTarget.files?.[0];
if (file) handleImport(file);
}}
/>
</div>
</div>
{/* Export: Include API keys checkbox */}
<div class="mb-6">
<label class="inline-flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={includeApiKeys()}
onChange={(e) => setIncludeApiKeys(e.currentTarget.checked)}
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span class="text-sm text-gray-700">{t('settings.exportIncludeKeys')}</span>
</label>
<Show when={includeApiKeys()}>
<p class="mt-1 text-sm text-amber-600">
{t('settings.exportKeysWarning')}
</p>
</Show>
{/* Page header */}
<div class="flex items-center mb-8">
<SettingsIcon class="h-8 w-8 text-indigo-600 mr-3" />
<h1 class="text-3xl font-extrabold text-gray-900">
{t('settings.title')}
</h1>
</div>
<Show when={message()}>
@ -328,8 +283,11 @@ const Settings: Component = () => {
</div>
</Show>
<div class="bg-white shadow overflow-hidden sm:rounded-lg border border-gray-200">
<div class="px-4 py-5 sm:p-6 space-y-6">
{/* ── Section 1: Contenu ── */}
<div class="mb-8">
<h2 class="text-xl font-semibold text-gray-900 mb-1">{t('settings.section.content')}</h2>
<p class="text-sm text-gray-500 mb-4">{t('settings.section.contentDesc')}</p>
<div class="bg-white shadow-sm rounded-lg border border-gray-200 p-6 space-y-6">
{/* Theme */}
<div>
<label for="theme" class="block text-sm font-medium text-gray-700">
@ -352,6 +310,52 @@ const Settings: Component = () => {
<p class="mt-2 text-sm text-gray-500">{t('settings.themeHelp')}</p>
</div>
{/* Categories */}
<div>
<div class="flex justify-between items-center mb-4">
<label class="block text-sm font-medium text-gray-700">
{t('settings.categories')}
</label>
<button
type="button"
onClick={addCategory}
disabled={settings().categories.length >= 20}
class="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
<Plus class="h-4 w-4 mr-1" />
{t('settings.addCategory')}
</button>
</div>
<div class="space-y-3">
<For each={settings().categories}>
{(category, index) => (
<div class="flex items-center gap-2">
<span class="text-gray-500 font-medium w-6">
{index() + 1}.
</span>
<input
type="text"
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={category}
onInput={(e) =>
handleCategoryChange(index(), e.currentTarget.value)
}
/>
<button
type="button"
onClick={() => removeCategory(index())}
disabled={settings().categories.length <= 1}
class="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
title={t('settings.removeCategory')}
>
<Trash2 class="h-5 w-5" />
</button>
</div>
)}
</For>
</div>
</div>
{/* Max age days + Max items per category */}
<div class="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-2">
<div>
@ -367,7 +371,7 @@ const Settings: Component = () => {
id="maxAgeDays"
min="1"
max="365"
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"
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={settings().max_age_days}
onInput={(e) =>
setSettings((prev) => ({
@ -392,7 +396,7 @@ const Settings: Component = () => {
id="maxItemsPerCategory"
min="1"
max="20"
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"
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={settings().max_items_per_category}
onInput={(e) =>
setSettings((prev) => ({
@ -404,7 +408,51 @@ const Settings: Component = () => {
/>
</div>
</div>
</div>
{/* Summary length slider */}
<div>
<label for="summaryLength" 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">
<span class="text-xs text-gray-500">{t('settings.summaryShort')}</span>
<input
type="range"
id="summaryLength"
min="1"
max="3"
step="1"
class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-indigo-600"
value={settings().summary_length}
onInput={(e) =>
setSettings((prev) => ({
...prev,
summary_length: parseInt(e.currentTarget.value) || 3,
}))
}
/>
<span class="text-xs text-gray-500">{t('settings.summaryDetailed')}</span>
</div>
<div class="text-center text-xs text-gray-500 mt-1">
{settings().summary_length === 1
? t('settings.summaryShort')
: settings().summary_length === 2
? t('settings.summaryMedium')
: t('settings.summaryDetailed')}
</div>
</div>
</div>
</div>
{/* ── Section 2: Sources ── */}
<div class="mb-8">
<h2 class="text-xl font-semibold text-gray-900 mb-1">{t('settings.section.sources')}</h2>
<p class="text-sm text-gray-500 mb-4">{t('settings.section.sourcesDesc')}</p>
<div class="bg-white shadow-sm rounded-lg border border-gray-200 p-6 space-y-6">
{/* Max articles per source + Max links per source */}
<div class="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-2">
<div>
<label
for="maxArticlesPerSource"
@ -418,7 +466,7 @@ const Settings: Component = () => {
id="maxArticlesPerSource"
min="1"
max="10"
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"
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={settings().max_articles_per_source}
onInput={(e) =>
setSettings((prev) => ({
@ -442,7 +490,7 @@ const Settings: Component = () => {
id="maxLinksPerSource"
min="1"
max="30"
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"
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={settings().max_links_per_source}
onInput={(e) =>
setSettings((prev) => ({
@ -454,47 +502,40 @@ const Settings: Component = () => {
</div>
</div>
</div>
{/* Summary length slider */}
<div>
<label for="summaryLength" 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">
<span class="text-xs text-gray-500">{t('settings.summaryShort')}</span>
<input
type="range"
id="summaryLength"
min="1"
max="3"
step="1"
class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-indigo-600"
value={settings().summary_length}
onInput={(e) =>
setSettings((prev) => ({
...prev,
summary_length: parseInt(e.currentTarget.value) || 3,
}))
}
/>
<span class="text-xs text-gray-500">{t('settings.summaryDetailed')}</span>
</div>
<div class="text-center text-xs text-gray-500 mt-1">
{settings().summary_length === 1
? t('settings.summaryShort')
: settings().summary_length === 2
? t('settings.summaryMedium')
: t('settings.summaryDetailed')}
<div>
<label for="sourceExtractionWindow" class="block text-sm font-medium text-gray-700">
{t('settings.sourceExtractionWindow')}
</label>
<p class="text-xs text-gray-500 mb-1">{t('settings.sourceExtractionWindowHelp')}</p>
<div class="mt-1">
<input
type="number"
id="sourceExtractionWindow"
min="1"
max="10"
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={settings().source_extraction_window}
onInput={(e) =>
setSettings((prev) => ({
...prev,
source_extraction_window: parseInt(e.currentTarget.value) || 3,
}))
}
/>
</div>
</div>
</div>
<SettingsAdvanced settings={settings} setSettings={setSettings} />
{/* Brave Search */}
<SettingsBraveSearch settings={settings} setSettings={setSettings} />
</div>
</div>
{/* AI Provider & Model - Dynamic selection */}
{/* ── Section 3: Intelligence Artificielle ── */}
<div class="mb-8">
<h2 class="text-xl font-semibold text-gray-900 mb-1">{t('settings.section.ai')}</h2>
<p class="text-sm text-gray-500 mb-4">{t('settings.section.aiDesc')}</p>
<div class="bg-white shadow-sm rounded-lg border border-gray-200 p-6 space-y-6">
<Show
when={providers() && providers()!.length > 0}
fallback={
@ -508,214 +549,343 @@ const Settings: Component = () => {
</div>
}
>
{/* Provider dropdown - only if multiple providers */}
<Show when={multipleProviders()}>
<div>
<label
for="aiProvider"
class="block text-sm font-medium text-gray-700"
>
{t('settings.provider')}
</label>
<div class="mt-1">
{/* Provider card with integrated key status */}
<div class="border border-gray-200 rounded-lg p-4 bg-gray-50">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
{/* Provider dropdown or single provider name */}
<Show
when={multipleProviders()}
fallback={
<span class="font-medium text-gray-900">
{selectedProvider()?.display_name}
</span>
}
>
<select
id="aiProvider"
class="block pl-3 pr-10 py-1.5 text-sm font-medium border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 rounded-md border bg-white"
value={settings().ai_provider}
onChange={(e) =>
handleProviderChange(e.currentTarget.value)
}
>
<option value="">
{t('settings.providerPlaceholder')}
</option>
<For each={providers()}>
{(provider) => (
<option value={provider.provider_name}>
{provider.display_name}
</option>
)}
</For>
</select>
</Show>
<Show when={selectedProvider()}>
{(provider) => (
<Show
when={providerSupportsWebSearch(provider().provider_name)}
fallback={
<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
{t('settings.provider.noWebSearchBadge')}
</span>
}
>
<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
{t('settings.provider.webSearchBadge')}
</span>
</Show>
)}
</Show>
</div>
{/* API key status badge */}
<Show when={selectedProvider()}>
{(provider) => (
<Show
when={getKeyForProvider(provider().provider_name)}
fallback={
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">
{t('settings.aiKeyNotConfigured')}
</span>
}
>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
{t('settings.aiKeyConfigured')}
</span>
</Show>
)}
</Show>
</div>
{/* Provider info text */}
<Show when={selectedProvider()}>
{(provider) => (
<div class="flex items-start gap-2 mb-3">
<Info class="h-4 w-4 text-gray-400 mt-0.5 flex-shrink-0" />
<p class="text-sm text-gray-500">
{t(getProviderInfoKey(provider().provider_name))}
</p>
</div>
)}
</Show>
{/* Model dropdowns inside the provider card */}
<div class="grid grid-cols-1 gap-y-4 gap-x-4 sm:grid-cols-2">
{/* Research model dropdown */}
<div>
<label
for="aiModel"
class="block text-xs font-medium text-gray-600 mb-1"
>
{t('settings.modelResearch')}
</label>
<select
id="aiProvider"
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md border"
value={settings().ai_provider}
id="aiModel"
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={settings().ai_model}
onChange={(e) =>
handleProviderChange(e.currentTarget.value)
setSettings((prev) => ({
...prev,
ai_model: e.currentTarget.value,
}))
}
disabled={!selectedProvider()}
>
<option value="">
{t('settings.providerPlaceholder')}
</option>
<For each={providers()}>
{(provider) => (
<option value={provider.provider_name}>
{provider.display_name}
<Show when={!selectedProvider()}>
<option value="">
{t('settings.modelPlaceholder')}
</option>
</Show>
<For each={selectedProvider()?.models_scraping ?? []}>
{(model) => (
<option value={model.model_id}>
{model.display_name}
</option>
)}
</For>
</select>
<p class="mt-1 text-xs text-gray-500">
{t('settings.modelResearchHelp')}
</p>
</div>
{/* Websearch model dropdown */}
<div>
<label
for="aiModelWebsearch"
class="block text-xs font-medium text-gray-600 mb-1"
>
{t('settings.modelWebsearch')}
</label>
<select
id="aiModelWebsearch"
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={settings().ai_model_websearch}
onChange={(e) =>
setSettings((prev) => ({
...prev,
ai_model_websearch: e.currentTarget.value,
}))
}
disabled={!selectedProvider()}
>
<option value="">{t('settings.modelPlaceholder')}</option>
<For each={selectedProvider()?.models_websearch ?? []}>
{(model) => (
<option value={model.model_id}>
{model.display_name}
</option>
)}
</For>
</select>
<p class="mt-1 text-xs text-gray-500">
{t('settings.modelWebsearchHelp')}
</p>
</div>
<p class="mt-2 text-sm text-gray-500">
{t('settings.providerHelp')}
</p>
</div>
</Show>
</div>
{/* Research model dropdown */}
{/* Search agent behavior */}
<div>
<label
for="aiModel"
for="searchAgentBehavior"
class="block text-sm font-medium text-gray-700"
>
{t('settings.modelResearch')}
{t('settings.searchBehavior')}
</label>
<div class="mt-1">
<select
id="aiModel"
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md border"
value={settings().ai_model}
onChange={(e) =>
<textarea
id="searchAgentBehavior"
rows={3}
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={settings().search_agent_behavior}
onInput={(e) =>
setSettings((prev) => ({
...prev,
ai_model: e.currentTarget.value,
search_agent_behavior: e.currentTarget.value,
}))
}
disabled={!selectedProvider()}
>
<Show when={!selectedProvider()}>
<option value="">
{t('settings.modelPlaceholder')}
</option>
</Show>
<For each={selectedProvider()?.models_scraping ?? []}>
{(model) => (
<option value={model.model_id}>
{model.display_name}
</option>
)}
</For>
</select>
placeholder={t('settings.searchBehaviorPlaceholder')}
/>
</div>
<p class="mt-2 text-sm text-gray-500">
{t('settings.modelResearchHelp')}
{t('settings.searchBehaviorHelp')}
</p>
</div>
{/* Websearch model dropdown */}
{/* Full API Key Manager */}
<ApiKeyManager providers={providers()!} />
</Show>
</div>
</div>
{/* ── Section 4: Performance ── */}
<div class="mb-8">
<h2 class="text-xl font-semibold text-gray-900 mb-1">{t('settings.section.performance')}</h2>
<p class="text-sm text-gray-500 mb-4">{t('settings.section.performanceDesc')}</p>
<div class="bg-white shadow-sm rounded-lg border border-gray-200 p-6 space-y-6">
<div class="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-2">
<div>
<label
for="aiModelWebsearch"
for="batchSize"
class="block text-sm font-medium text-gray-700"
>
{t('settings.modelWebsearch')}
{t('settings.batchSize')}
</label>
<p class="text-xs text-gray-500 mb-1">{t('settings.batchSizeHelp')}</p>
<div class="mt-1">
<select
id="aiModelWebsearch"
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md border"
value={settings().ai_model_websearch}
onChange={(e) =>
<input
type="number"
id="batchSize"
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={settings().batch_size}
onInput={(e) =>
setSettings((prev) => ({
...prev,
ai_model_websearch: e.currentTarget.value,
batch_size:
parseInt(e.currentTarget.value) || 5,
}))
}
disabled={!selectedProvider()}
>
<option value="">{t('settings.modelPlaceholder')}</option>
<For each={selectedProvider()?.models_websearch ?? []}>
{(model) => (
<option value={model.model_id}>
{model.display_name}
</option>
)}
</For>
</select>
/>
</div>
<p class="mt-2 text-sm text-gray-500">
{t('settings.modelWebsearchHelp')}
</p>
</div>
{/* Provider info text + web search badge */}
<Show when={selectedProvider()}>
{(provider) => (
<div class="mt-3 flex items-start gap-2">
<Info class="h-4 w-4 text-gray-400 mt-0.5 flex-shrink-0" />
<div class="space-y-1">
<p class="text-sm text-gray-500">
{t(getProviderInfoKey(provider().provider_name))}
</p>
<Show
when={providerSupportsWebSearch(provider().provider_name)}
fallback={
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
{t('settings.provider.noWebSearchBadge')}
</span>
}
>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
{t('settings.provider.webSearchBadge')}
</span>
</Show>
</div>
</div>
)}
</Show>
</Show>
{/* Categories */}
<div>
<div class="flex justify-between items-center mb-4">
<label class="block text-sm font-medium text-gray-700">
{t('settings.categories')}
<div>
<label
for="articleHistoryDays"
class="block text-sm font-medium text-gray-700"
>
{t('settings.articleHistoryDays')}
</label>
<div class="mt-1">
<input
type="number"
id="articleHistoryDays"
min="0"
max="365"
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={settings().article_history_days}
onInput={(e) =>
setSettings((prev) => ({
...prev,
article_history_days:
parseInt(e.currentTarget.value) || 90,
}))
}
/>
</div>
<div class="mt-2">
<A href="/article-history" class="text-sm text-indigo-600 hover:text-indigo-800 underline">
{t('articleHistory.viewHistory')}
</A>
</div>
</div>
</div>
<SettingsRateLimit settings={settings} setSettings={setSettings} />
</div>
</div>
{/* ── Section 5: Import / Export (collapsed) ── */}
<div class="mb-8">
<details>
<summary class="cursor-pointer">
<h2 class="text-xl font-semibold text-gray-900 mb-1 inline">{t('settings.section.importExport')}</h2>
<p class="text-sm text-gray-500 mt-1 mb-4">{t('settings.section.importExportDesc')}</p>
</summary>
<div class="bg-white shadow-sm rounded-lg border border-gray-200 p-6 space-y-4">
<div class="flex items-center gap-3">
{/* Export button */}
<button
type="button"
onClick={addCategory}
disabled={settings().categories.length >= 20}
class="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
onClick={handleExport}
class="inline-flex items-center px-3 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"
title={t('settings.export')}
>
<Plus class="h-4 w-4 mr-1" />
{t('settings.addCategory')}
<Download class="h-4 w-4 mr-1" />
{t('settings.export')}
</button>
{/* Import button */}
<button
type="button"
onClick={() => fileInputRef?.click()}
class="inline-flex items-center px-3 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"
title={t('settings.import')}
>
<Upload class="h-4 w-4 mr-1" />
{t('settings.import')}
</button>
<input
ref={fileInputRef}
type="file"
accept=".json"
class="hidden"
onChange={(e) => {
const file = e.currentTarget.files?.[0];
if (file) handleImport(file);
}}
/>
</div>
<div class="space-y-3">
<For each={settings().categories}>
{(category, index) => (
<div class="flex items-center gap-2">
<span class="text-gray-500 font-medium w-6">
{index() + 1}.
</span>
<input
type="text"
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={category}
onInput={(e) =>
handleCategoryChange(index(), e.currentTarget.value)
}
/>
<button
type="button"
onClick={() => removeCategory(index())}
disabled={settings().categories.length <= 1}
class="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
title={t('settings.removeCategory')}
>
<Trash2 class="h-5 w-5" />
</button>
</div>
)}
</For>
{/* Include API keys checkbox */}
<div>
<label class="inline-flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={includeApiKeys()}
onChange={(e) => setIncludeApiKeys(e.currentTarget.checked)}
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span class="text-sm text-gray-700">{t('settings.exportIncludeKeys')}</span>
</label>
<Show when={includeApiKeys()}>
<p class="mt-1 text-sm text-amber-600">
{t('settings.exportKeysWarning')}
</p>
</Show>
</div>
</div>
<SettingsRateLimit settings={settings} setSettings={setSettings} />
</div>
{/* Save button */}
<div class="px-4 py-3 bg-gray-50 text-right sm:px-6">
<Button
onClick={handleSave}
loading={saving()}
icon={Save}
>
{t('settings.save')}
</Button>
</div>
</details>
</div>
{/* API Key Management */}
<Show when={providers() && providers()!.length > 0}>
<ApiKeyManager providers={providers()!} />
</Show>
{/* Sticky save button */}
<div class="sticky bottom-0 bg-white border-t border-gray-200 px-6 py-3 -mx-4 sm:-mx-6 lg:-mx-8 mt-8 flex justify-end">
<Button
onClick={handleSave}
loading={saving()}
icon={Save}
>
{t('settings.save')}
</Button>
</div>
</div>
</Show>
);
};
export default Settings;

Loading…
Cancel
Save