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 ( return (
<div class="bg-white shadow overflow-hidden sm:rounded-lg border border-gray-200 mt-8"> <div class="overflow-hidden rounded-lg border border-gray-200">
<div class="px-4 py-5 sm:px-6 border-b 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"> <div class="flex items-center">
<Key class="h-5 w-5 text-indigo-600 mr-2" /> <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')} {t('settings.apiKeys.title')}
</h2> </h3>
</div> </div>
<p class="mt-1 text-sm text-gray-500"> <p class="mt-1 text-sm text-gray-500">
{t('settings.apiKeys.description')} {t('settings.apiKeys.description')}

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

@ -115,6 +115,20 @@ const fr = {
'synthesis.export.downloading': 'Telechargement...', 'synthesis.export.downloading': 'Telechargement...',
'synthesis.export.error': 'Erreur lors de l\'export.', '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
'settings.title': 'Parametres de generation', 'settings.title': 'Parametres de generation',
'settings.theme': 'Theme de la recherche', 'settings.theme': 'Theme de la recherche',

@ -7,6 +7,7 @@ import {
For, For,
createEffect, createEffect,
} from 'solid-js'; } from 'solid-js';
import { A } from '@solidjs/router';
import { Settings as SettingsIcon, Save, Plus, Trash2, Info, Download, Upload } from 'lucide-solid'; import { Settings as SettingsIcon, Save, Plus, Trash2, Info, Download, Upload } from 'lucide-solid';
import Button from '~/components/ui/Button'; import Button from '~/components/ui/Button';
import { settingsApi } from '~/api/settings'; import { settingsApi } from '~/api/settings';
@ -19,7 +20,6 @@ import LoadingSpinner from '~/components/ui/LoadingSpinner';
import ApiKeyManager from '~/components/ApiKeyManager'; import ApiKeyManager from '~/components/ApiKeyManager';
import { getProviderInfoKey, providerSupportsWebSearch } from '~/utils/providers'; import { getProviderInfoKey, providerSupportsWebSearch } from '~/utils/providers';
import SettingsBraveSearch from '~/components/settings/SettingsBraveSearch'; import SettingsBraveSearch from '~/components/settings/SettingsBraveSearch';
import SettingsAdvanced from '~/components/settings/SettingsAdvanced';
import SettingsRateLimit from '~/components/settings/SettingsRateLimit'; import SettingsRateLimit from '~/components/settings/SettingsRateLimit';
/** /**
@ -53,6 +53,7 @@ const Settings: Component = () => {
const [includeApiKeys, setIncludeApiKeys] = createSignal(false); const [includeApiKeys, setIncludeApiKeys] = createSignal(false);
const [providers] = createResource(() => configApi.listProviders()); const [providers] = createResource(() => configApi.listProviders());
const [apiKeys, { refetch: refetchApiKeys }] = createResource(() => apiKeysApi.list());
const [providerWarning, setProviderWarning] = createSignal(false); const [providerWarning, setProviderWarning] = createSignal(false);
let fileInputRef: HTMLInputElement | undefined; 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) => { const handleProviderChange = (providerName: string) => {
setProviderWarning(false); setProviderWarning(false);
const provider = providers()?.find( const provider = providers()?.find(
@ -232,6 +237,7 @@ const Settings: Component = () => {
}); });
} }
} }
refetchApiKeys();
} }
setMessage({ type: 'success', text: t('settings.importSuccess') }); setMessage({ type: 'success', text: t('settings.importSuccess') });
@ -248,64 +254,13 @@ const Settings: Component = () => {
return ( return (
<Show when={!loading()} fallback={<LoadingSpinner />}> <Show when={!loading()} fallback={<LoadingSpinner />}>
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <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"> {/* Page header */}
<div class="flex items-center"> <div class="flex items-center mb-8">
<SettingsIcon class="h-8 w-8 text-indigo-600 mr-3" /> <SettingsIcon class="h-8 w-8 text-indigo-600 mr-3" />
<h1 class="text-3xl font-extrabold text-gray-900"> <h1 class="text-3xl font-extrabold text-gray-900">
{t('settings.title')} {t('settings.title')}
</h1> </h1>
</div> </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>
</div>
<Show when={message()}> <Show when={message()}>
{(msg) => ( {(msg) => (
@ -328,8 +283,11 @@ const Settings: Component = () => {
</div> </div>
</Show> </Show>
<div class="bg-white shadow overflow-hidden sm:rounded-lg border border-gray-200"> {/* ── Section 1: Contenu ── */}
<div class="px-4 py-5 sm:p-6 space-y-6"> <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 */} {/* Theme */}
<div> <div>
<label for="theme" class="block text-sm font-medium text-gray-700"> <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> <p class="mt-2 text-sm text-gray-500">{t('settings.themeHelp')}</p>
</div> </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 */} {/* Max age days + Max items per category */}
<div class="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-2">
<div> <div>
@ -367,7 +371,7 @@ const Settings: Component = () => {
id="maxAgeDays" id="maxAgeDays"
min="1" min="1"
max="365" 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} value={settings().max_age_days}
onInput={(e) => onInput={(e) =>
setSettings((prev) => ({ setSettings((prev) => ({
@ -392,7 +396,7 @@ const Settings: Component = () => {
id="maxItemsPerCategory" id="maxItemsPerCategory"
min="1" min="1"
max="20" 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} value={settings().max_items_per_category}
onInput={(e) => onInput={(e) =>
setSettings((prev) => ({ setSettings((prev) => ({
@ -404,7 +408,51 @@ const Settings: Component = () => {
/> />
</div> </div>
</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> <div>
<label <label
for="maxArticlesPerSource" for="maxArticlesPerSource"
@ -418,7 +466,7 @@ const Settings: Component = () => {
id="maxArticlesPerSource" id="maxArticlesPerSource"
min="1" min="1"
max="10" 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} value={settings().max_articles_per_source}
onInput={(e) => onInput={(e) =>
setSettings((prev) => ({ setSettings((prev) => ({
@ -442,7 +490,7 @@ const Settings: Component = () => {
id="maxLinksPerSource" id="maxLinksPerSource"
min="1" min="1"
max="30" 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} value={settings().max_links_per_source}
onInput={(e) => onInput={(e) =>
setSettings((prev) => ({ setSettings((prev) => ({
@ -454,47 +502,40 @@ const Settings: Component = () => {
</div> </div>
</div> </div>
</div>
{/* Summary length slider */}
<div> <div>
<label for="summaryLength" class="block text-sm font-medium text-gray-700"> <label for="sourceExtractionWindow" class="block text-sm font-medium text-gray-700">
{t('settings.summaryLength')} {t('settings.sourceExtractionWindow')}
</label> </label>
<p class="text-xs text-gray-500 mb-2">{t('settings.summaryLengthHelp')}</p> <p class="text-xs text-gray-500 mb-1">{t('settings.sourceExtractionWindowHelp')}</p>
<div class="flex items-center gap-4"> <div class="mt-1">
<span class="text-xs text-gray-500">{t('settings.summaryShort')}</span>
<input <input
type="range" type="number"
id="summaryLength" id="sourceExtractionWindow"
min="1" min="1"
max="3" max="10"
step="1" 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"
class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-indigo-600" value={settings().source_extraction_window}
value={settings().summary_length}
onInput={(e) => onInput={(e) =>
setSettings((prev) => ({ setSettings((prev) => ({
...prev, ...prev,
summary_length: parseInt(e.currentTarget.value) || 3, source_extraction_window: parseInt(e.currentTarget.value) || 3,
})) }))
} }
/> />
<span class="text-xs text-gray-500">{t('settings.summaryDetailed')}</span>
</div> </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>
<SettingsAdvanced settings={settings} setSettings={setSettings} /> {/* Brave Search */}
<SettingsBraveSearch settings={settings} setSettings={setSettings} /> <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 <Show
when={providers() && providers()!.length > 0} when={providers() && providers()!.length > 0}
fallback={ fallback={
@ -508,19 +549,22 @@ const Settings: Component = () => {
</div> </div>
} }
> >
{/* Provider dropdown - only if multiple providers */} {/* Provider card with integrated key status */}
<Show when={multipleProviders()}> <div class="border border-gray-200 rounded-lg p-4 bg-gray-50">
<div> <div class="flex items-center justify-between mb-3">
<label <div class="flex items-center gap-2">
for="aiProvider" {/* Provider dropdown or single provider name */}
class="block text-sm font-medium text-gray-700" <Show
when={multipleProviders()}
fallback={
<span class="font-medium text-gray-900">
{selectedProvider()?.display_name}
</span>
}
> >
{t('settings.provider')}
</label>
<div class="mt-1">
<select <select
id="aiProvider" 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" 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} value={settings().ai_provider}
onChange={(e) => onChange={(e) =>
handleProviderChange(e.currentTarget.value) handleProviderChange(e.currentTarget.value)
@ -537,25 +581,70 @@ const Settings: Component = () => {
)} )}
</For> </For>
</select> </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> </div>
<p class="mt-2 text-sm text-gray-500">
{t('settings.providerHelp')} {/* 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> </p>
</div> </div>
)}
</Show> </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 */} {/* Research model dropdown */}
<div> <div>
<label <label
for="aiModel" for="aiModel"
class="block text-sm font-medium text-gray-700" class="block text-xs font-medium text-gray-600 mb-1"
> >
{t('settings.modelResearch')} {t('settings.modelResearch')}
</label> </label>
<div class="mt-1">
<select <select
id="aiModel" 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" 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} value={settings().ai_model}
onChange={(e) => onChange={(e) =>
setSettings((prev) => ({ setSettings((prev) => ({
@ -578,8 +667,7 @@ const Settings: Component = () => {
)} )}
</For> </For>
</select> </select>
</div> <p class="mt-1 text-xs text-gray-500">
<p class="mt-2 text-sm text-gray-500">
{t('settings.modelResearchHelp')} {t('settings.modelResearchHelp')}
</p> </p>
</div> </div>
@ -588,14 +676,13 @@ const Settings: Component = () => {
<div> <div>
<label <label
for="aiModelWebsearch" for="aiModelWebsearch"
class="block text-sm font-medium text-gray-700" class="block text-xs font-medium text-gray-600 mb-1"
> >
{t('settings.modelWebsearch')} {t('settings.modelWebsearch')}
</label> </label>
<div class="mt-1">
<select <select
id="aiModelWebsearch" 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" 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} value={settings().ai_model_websearch}
onChange={(e) => onChange={(e) =>
setSettings((prev) => ({ setSettings((prev) => ({
@ -614,90 +701,180 @@ const Settings: Component = () => {
)} )}
</For> </For>
</select> </select>
</div> <p class="mt-1 text-xs text-gray-500">
<p class="mt-2 text-sm text-gray-500">
{t('settings.modelWebsearchHelp')} {t('settings.modelWebsearchHelp')}
</p> </p>
</div> </div>
</div>
</div>
{/* Provider info text + web search badge */} {/* Search agent behavior */}
<Show when={selectedProvider()}> <div>
{(provider) => ( <label
<div class="mt-3 flex items-start gap-2"> for="searchAgentBehavior"
<Info class="h-4 w-4 text-gray-400 mt-0.5 flex-shrink-0" /> class="block text-sm font-medium text-gray-700"
<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.searchBehavior')}
{t('settings.provider.webSearchBadge')} </label>
</span> <div class="mt-1">
</Show> <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,
search_agent_behavior: e.currentTarget.value,
}))
}
placeholder={t('settings.searchBehaviorPlaceholder')}
/>
</div> </div>
<p class="mt-2 text-sm text-gray-500">
{t('settings.searchBehaviorHelp')}
</p>
</div> </div>
)}
</Show> {/* Full API Key Manager */}
<ApiKeyManager providers={providers()!} />
</Show> </Show>
</div>
</div>
{/* Categories */} {/* ── 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> <div>
<div class="flex justify-between items-center mb-4"> <label
<label class="block text-sm font-medium text-gray-700"> for="batchSize"
{t('settings.categories')} class="block text-sm font-medium text-gray-700"
</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.batchSize')}
{t('settings.addCategory')} </label>
</button> <p class="text-xs text-gray-500 mb-1">{t('settings.batchSizeHelp')}</p>
<div class="mt-1">
<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,
batch_size:
parseInt(e.currentTarget.value) || 5,
}))
}
/>
</div> </div>
<div class="space-y-3"> </div>
<For each={settings().categories}>
{(category, index) => ( <div>
<div class="flex items-center gap-2"> <label
<span class="text-gray-500 font-medium w-6"> for="articleHistoryDays"
{index() + 1}. class="block text-sm font-medium text-gray-700"
</span> >
{t('settings.articleHistoryDays')}
</label>
<div class="mt-1">
<input <input
type="text" type="number"
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" id="articleHistoryDays"
value={category} 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) => onInput={(e) =>
handleCategoryChange(index(), e.currentTarget.value) 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 <button
type="button" type="button"
onClick={() => removeCategory(index())} onClick={handleExport}
disabled={settings().categories.length <= 1} 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"
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.export')}
title={t('settings.removeCategory')}
> >
<Trash2 class="h-5 w-5" /> <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> </button>
<input
ref={fileInputRef}
type="file"
accept=".json"
class="hidden"
onChange={(e) => {
const file = e.currentTarget.files?.[0];
if (file) handleImport(file);
}}
/>
</div> </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>
</div> </div>
</details>
<SettingsRateLimit settings={settings} setSettings={setSettings} />
</div> </div>
{/* Save button */} {/* Sticky save button */}
<div class="px-4 py-3 bg-gray-50 text-right sm:px-6"> <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 <Button
onClick={handleSave} onClick={handleSave}
loading={saving()} loading={saving()}
@ -707,15 +884,8 @@ const Settings: Component = () => {
</Button> </Button>
</div> </div>
</div> </div>
{/* API Key Management */}
<Show when={providers() && providers()!.length > 0}>
<ApiKeyManager providers={providers()!} />
</Show>
</div>
</Show> </Show>
); );
}; };
export default Settings; export default Settings;

Loading…
Cancel
Save