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,63 +254,12 @@ 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 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> </div>
<Show when={message()}> <Show when={message()}>
@ -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> <div>
<label for="sourceExtractionWindow" class="block text-sm font-medium text-gray-700">
{/* Summary length slider */} {t('settings.sourceExtractionWindow')}
<div> </label>
<label for="summaryLength" class="block text-sm font-medium text-gray-700"> <p class="text-xs text-gray-500 mb-1">{t('settings.sourceExtractionWindowHelp')}</p>
{t('settings.summaryLength')} <div class="mt-1">
</label> <input
<p class="text-xs text-gray-500 mb-2">{t('settings.summaryLengthHelp')}</p> type="number"
<div class="flex items-center gap-4"> id="sourceExtractionWindow"
<span class="text-xs text-gray-500">{t('settings.summaryShort')}</span> min="1"
<input max="10"
type="range" 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"
id="summaryLength" value={settings().source_extraction_window}
min="1" onInput={(e) =>
max="3" setSettings((prev) => ({
step="1" ...prev,
class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-indigo-600" source_extraction_window: parseInt(e.currentTarget.value) || 3,
value={settings().summary_length} }))
onInput={(e) => }
setSettings((prev) => ({ />
...prev, </div>
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>
<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,214 +549,343 @@ 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()}
{t('settings.provider')} fallback={
</label> <span class="font-medium text-gray-900">
<div class="mt-1"> {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 <select
id="aiProvider" 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_provider} value={settings().ai_model}
onChange={(e) => onChange={(e) =>
handleProviderChange(e.currentTarget.value) setSettings((prev) => ({
...prev,
ai_model: e.currentTarget.value,
}))
} }
disabled={!selectedProvider()}
> >
<option value=""> <Show when={!selectedProvider()}>
{t('settings.providerPlaceholder')} <option value="">
</option> {t('settings.modelPlaceholder')}
<For each={providers()}> </option>
{(provider) => ( </Show>
<option value={provider.provider_name}> <For each={selectedProvider()?.models_scraping ?? []}>
{provider.display_name} {(model) => (
<option value={model.model_id}>
{model.display_name}
</option> </option>
)} )}
</For> </For>
</select> </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> </div>
<p class="mt-2 text-sm text-gray-500">
{t('settings.providerHelp')}
</p>
</div> </div>
</Show> </div>
{/* Research model dropdown */} {/* Search agent behavior */}
<div> <div>
<label <label
for="aiModel" for="searchAgentBehavior"
class="block text-sm font-medium text-gray-700" class="block text-sm font-medium text-gray-700"
> >
{t('settings.modelResearch')} {t('settings.searchBehavior')}
</label> </label>
<div class="mt-1"> <div class="mt-1">
<select <textarea
id="aiModel" id="searchAgentBehavior"
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" rows={3}
value={settings().ai_model} 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"
onChange={(e) => value={settings().search_agent_behavior}
onInput={(e) =>
setSettings((prev) => ({ setSettings((prev) => ({
...prev, ...prev,
ai_model: e.currentTarget.value, search_agent_behavior: e.currentTarget.value,
})) }))
} }
disabled={!selectedProvider()} placeholder={t('settings.searchBehaviorPlaceholder')}
> />
<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>
</div> </div>
<p class="mt-2 text-sm text-gray-500"> <p class="mt-2 text-sm text-gray-500">
{t('settings.modelResearchHelp')} {t('settings.searchBehaviorHelp')}
</p> </p>
</div> </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> <div>
<label <label
for="aiModelWebsearch" for="batchSize"
class="block text-sm font-medium text-gray-700" class="block text-sm font-medium text-gray-700"
> >
{t('settings.modelWebsearch')} {t('settings.batchSize')}
</label> </label>
<p class="text-xs text-gray-500 mb-1">{t('settings.batchSizeHelp')}</p>
<div class="mt-1"> <div class="mt-1">
<select <input
id="aiModelWebsearch" type="number"
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" id="batchSize"
value={settings().ai_model_websearch} min="1"
onChange={(e) => 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) => ({ setSettings((prev) => ({
...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> </div>
<p class="mt-2 text-sm text-gray-500">
{t('settings.modelWebsearchHelp')}
</p>
</div> </div>
{/* Provider info text + web search badge */} <div>
<Show when={selectedProvider()}> <label
{(provider) => ( for="articleHistoryDays"
<div class="mt-3 flex items-start gap-2"> class="block text-sm font-medium text-gray-700"
<Info class="h-4 w-4 text-gray-400 mt-0.5 flex-shrink-0" /> >
<div class="space-y-1"> {t('settings.articleHistoryDays')}
<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')}
</label> </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 <button
type="button" type="button"
onClick={addCategory} onClick={handleExport}
disabled={settings().categories.length >= 20} 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="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" title={t('settings.export')}
> >
<Plus class="h-4 w-4 mr-1" /> <Download class="h-4 w-4 mr-1" />
{t('settings.addCategory')} {t('settings.export')}
</button> </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>
<div class="space-y-3">
<For each={settings().categories}> {/* Include API keys checkbox */}
{(category, index) => ( <div>
<div class="flex items-center gap-2"> <label class="inline-flex items-center gap-2 cursor-pointer">
<span class="text-gray-500 font-medium w-6"> <input
{index() + 1}. type="checkbox"
</span> checked={includeApiKeys()}
<input onChange={(e) => setIncludeApiKeys(e.currentTarget.checked)}
type="text" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
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} <span class="text-sm text-gray-700">{t('settings.exportIncludeKeys')}</span>
onInput={(e) => </label>
handleCategoryChange(index(), e.currentTarget.value) <Show when={includeApiKeys()}>
} <p class="mt-1 text-sm text-amber-600">
/> {t('settings.exportKeysWarning')}
<button </p>
type="button" </Show>
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>
</div> </div>
</details>
<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>
</div> </div>
{/* API Key Management */} {/* Sticky save button */}
<Show when={providers() && providers()!.length > 0}> <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">
<ApiKeyManager providers={providers()!} /> <Button
</Show> onClick={handleSave}
loading={saving()}
icon={Save}
>
{t('settings.save')}
</Button>
</div>
</div> </div>
</Show> </Show>
); );
}; };
export default Settings; export default Settings;

Loading…
Cancel
Save