|
1 | 1 | 'use client' |
2 | 2 |
|
3 | | -import { useEffect, useState } from 'react' |
4 | | -import { Card, Field, inputCls, btnPrimary, btnSecondary } from '../setup/ui' |
| 3 | +import { useState, useEffect, useCallback } from 'react' |
| 4 | +import { Tabs, Tab } from '../components/Tabs' |
| 5 | +import { useToast } from '../components/Toast' |
5 | 6 |
|
6 | | -type TgStatus = { configured: boolean; connected: boolean; running: boolean; botUsername: string | null; botName: string | null; mode: string | null } |
| 7 | +type NotifKey = 'telegram' | 'email' | 'desktop' |
| 8 | +type Settings = { app_name: string; language: string; notifications: Record<NotifKey, boolean> } |
7 | 9 |
|
8 | | -function TelegramStatusWidget() { |
9 | | - const [s, setS] = useState<TgStatus | null>(null) |
10 | | - useEffect(() => { |
11 | | - const load = () => fetch('/api/telegram/status').then(r => r.json()).then(setS).catch(() => null) |
12 | | - load() |
13 | | - const id = setInterval(load, 15_000) |
14 | | - return () => clearInterval(id) |
15 | | - }, []) |
16 | | - if (!s) return <p className="text-[10px]" style={{ color: 'var(--color-dim)' }}>Verifica stato…</p> |
17 | | - const dot = s.connected ? 'var(--color-green)' : s.configured ? 'var(--color-yellow)' : 'var(--color-dim)' |
18 | | - const label = s.connected ? 'connesso' : s.configured ? 'token non valido' : 'non configurato' |
| 10 | +const DEFAULTS: Settings = { app_name: 'Job Hunter Team', language: 'it', notifications: { telegram: true, email: false, desktop: false } } |
| 11 | + |
| 12 | +const inp: React.CSSProperties = { |
| 13 | + border: '1px solid var(--color-border)', background: 'var(--color-card)', |
| 14 | + color: 'var(--color-bright)', borderRadius: 6, fontSize: 11, |
| 15 | + padding: '6px 10px', outline: 'none', fontFamily: 'var(--font-mono)', width: '100%', |
| 16 | +} |
| 17 | + |
| 18 | +function Row({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { |
19 | 19 | return ( |
20 | | - <div className="flex flex-col gap-1.5 px-3 py-2.5 rounded border text-[11px]" |
21 | | - style={{ borderColor: s.connected ? 'rgba(0,232,122,0.2)' : 'var(--color-border)', background: 'var(--color-card)' }}> |
22 | | - <div className="flex items-center gap-2"> |
23 | | - <span style={{ color: dot, animation: s.connected ? 'pulse-dot 2.5s ease-in-out infinite' : undefined }}>●</span> |
24 | | - <span style={{ color: dot }}>{label}</span> |
25 | | - {s.running && <span className="ml-auto text-[9px] px-1.5 py-0.5 rounded border" style={{ color: 'var(--color-blue)', borderColor: 'rgba(77,159,255,0.25)' }}>tmux attivo</span>} |
26 | | - </div> |
27 | | - {s.botUsername && <p className="font-mono text-[10px]" style={{ color: 'var(--color-muted)' }}>@{s.botUsername} · {s.botName}</p>} |
| 20 | + <div className="flex flex-col gap-1.5"> |
| 21 | + <label className="text-[11px] font-semibold" style={{ color: 'var(--color-muted)' }}>{label}</label> |
| 22 | + {hint && <p className="text-[9px]" style={{ color: 'var(--color-dim)' }}>{hint}</p>} |
| 23 | + {children} |
28 | 24 | </div> |
29 | 25 | ) |
30 | 26 | } |
31 | 27 |
|
32 | | -type Provider = 'claude' | 'openai' | 'minimax' |
33 | | - |
34 | | -interface SettingsForm { |
35 | | - activeProvider: Provider |
36 | | - apiKeys: Record<Provider, string> |
37 | | - telegramEnabled: boolean |
38 | | - botToken: string |
39 | | - chatId: string |
40 | | - cronEnabled: boolean |
| 28 | +function Toggle({ checked, label, onChange }: { checked: boolean; label: string; onChange: (v: boolean) => void }) { |
| 29 | + return ( |
| 30 | + <label className="flex items-center gap-3 cursor-pointer select-none" onClick={() => onChange(!checked)}> |
| 31 | + <div className="relative w-9 h-5 rounded-full transition-colors" |
| 32 | + style={{ background: checked ? 'var(--color-green)' : 'var(--color-border)' }}> |
| 33 | + <div className="absolute top-0.5 w-4 h-4 rounded-full transition-transform" |
| 34 | + style={{ background: 'white', transform: checked ? 'translateX(18px)' : 'translateX(2px)' }} /> |
| 35 | + </div> |
| 36 | + <span className="text-[11px]" style={{ color: 'var(--color-muted)' }}>{label}</span> |
| 37 | + </label> |
| 38 | + ) |
41 | 39 | } |
42 | 40 |
|
43 | | -const EMPTY: SettingsForm = { |
44 | | - activeProvider: 'claude', |
45 | | - apiKeys: { claude: '', openai: '', minimax: '' }, |
46 | | - telegramEnabled: false, |
47 | | - botToken: '', |
48 | | - chatId: '', |
49 | | - cronEnabled: false, |
| 41 | +function SaveBtn({ busy, onClick }: { busy: boolean; onClick: () => void }) { |
| 42 | + return ( |
| 43 | + <button onClick={onClick} disabled={busy} className="self-start px-5 py-2 rounded text-[11px] font-bold cursor-pointer transition-all" |
| 44 | + style={{ background: busy ? 'var(--color-border)' : 'var(--color-green)', color: busy ? 'var(--color-dim)' : 'var(--color-void)', border: 'none' }}> |
| 45 | + {busy ? 'Salvataggio…' : 'Salva'} |
| 46 | + </button> |
| 47 | + ) |
50 | 48 | } |
51 | 49 |
|
52 | | -const PROVIDER_LABELS: Record<Provider, string> = { |
53 | | - claude: 'Claude (Anthropic)', |
54 | | - openai: 'OpenAI', |
55 | | - minimax: 'MiniMax', |
56 | | -} |
| 50 | +type TabId = 'general' | 'notifications' | 'security' | 'danger' |
57 | 51 |
|
58 | 52 | export default function SettingsPage() { |
59 | | - const [form, setForm] = useState<SettingsForm>(EMPTY) |
60 | | - const [status, setStatus] = useState<'idle' | 'loading' | 'saving' | 'ok' | 'error'>('loading') |
61 | | - const [errMsg, setErrMsg] = useState('') |
| 53 | + const [s, setS] = useState<Settings>(DEFAULTS) |
| 54 | + const [loading, setL] = useState(true) |
| 55 | + const [busy, setBusy] = useState(false) |
| 56 | + const [tab, setTab] = useState<TabId>('general') |
| 57 | + const { addToast } = useToast() |
62 | 58 |
|
63 | 59 | useEffect(() => { |
64 | | - fetch('/api/settings') |
65 | | - .then(r => r.json()) |
66 | | - .then(({ config }) => { |
67 | | - if (!config) { setStatus('idle'); return } |
68 | | - setForm({ |
69 | | - activeProvider: config.active_provider ?? 'claude', |
70 | | - apiKeys: { |
71 | | - claude: config.providers?.claude?.api_key ?? '', |
72 | | - openai: config.providers?.openai?.api_key ?? '', |
73 | | - minimax: config.providers?.minimax?.api_key ?? '', |
74 | | - }, |
75 | | - telegramEnabled: !!config.channels?.telegram?.bot_token, |
76 | | - botToken: config.channels?.telegram?.bot_token ?? '', |
77 | | - chatId: config.channels?.telegram?.chat_id ?? '', |
78 | | - cronEnabled: config.cron_enabled ?? false, |
79 | | - }) |
80 | | - setStatus('idle') |
| 60 | + fetch('/api/settings').then(r => r.json()).then(({ config }) => { |
| 61 | + if (config) setS({ |
| 62 | + app_name: config.app?.name ?? DEFAULTS.app_name, |
| 63 | + language: config.app?.language ?? DEFAULTS.language, |
| 64 | + notifications: { ...DEFAULTS.notifications, ...config.notifications }, |
81 | 65 | }) |
82 | | - .catch(() => setStatus('idle')) |
| 66 | + setL(false) |
| 67 | + }).catch(() => setL(false)) |
83 | 68 | }, []) |
84 | 69 |
|
85 | | - const set = (p: Partial<SettingsForm>) => setForm(f => ({ ...f, ...p })) |
86 | | - |
87 | | - const save = async () => { |
88 | | - setStatus('saving') |
89 | | - const body = { |
90 | | - active_provider: form.activeProvider, |
91 | | - providers: { |
92 | | - [form.activeProvider]: { |
93 | | - name: form.activeProvider, |
94 | | - auth_method: 'api_key', |
95 | | - api_key: form.apiKeys[form.activeProvider], |
96 | | - }, |
97 | | - }, |
98 | | - channels: form.telegramEnabled ? { |
99 | | - telegram: { bot_token: form.botToken, chat_id: form.chatId }, |
100 | | - } : {}, |
101 | | - cron_enabled: form.cronEnabled, |
102 | | - } |
| 70 | + const save = useCallback(async () => { |
| 71 | + setBusy(true) |
103 | 72 | try { |
104 | | - const r = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) |
105 | | - const data = await r.json() |
106 | | - if (data.ok) { setStatus('ok'); setTimeout(() => setStatus('idle'), 2000) } |
107 | | - else { setErrMsg(data.error ?? 'errore'); setStatus('error') } |
108 | | - } catch { setErrMsg('network error'); setStatus('error') } |
109 | | - } |
110 | | - |
111 | | - if (status === 'loading') return ( |
112 | | - <main className="min-h-screen flex items-center justify-center"> |
113 | | - <p className="text-[11px]" style={{ color: 'var(--color-muted)' }}>Caricamento configurazione…</p> |
114 | | - </main> |
115 | | - ) |
| 73 | + const r = await fetch('/api/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, |
| 74 | + body: JSON.stringify({ app: { name: s.app_name, language: s.language }, notifications: s.notifications }) }) |
| 75 | + const d = await r.json() |
| 76 | + d.ok ? addToast({ type: 'success', message: 'Impostazioni salvate' }) |
| 77 | + : addToast({ type: 'error', message: d.error ?? 'Errore' }) |
| 78 | + } catch { addToast({ type: 'error', message: 'Errore di rete' }) } |
| 79 | + finally { setBusy(false) } |
| 80 | + }, [s, addToast]) |
| 81 | + |
| 82 | + const dangerAction = useCallback(async (act: string, label: string) => { |
| 83 | + setBusy(true) |
| 84 | + try { |
| 85 | + const r = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, |
| 86 | + body: JSON.stringify({ _action: act }) }) |
| 87 | + const d = await r.json() |
| 88 | + d.ok ? addToast({ type: 'success', message: `${label} completato` }) |
| 89 | + : addToast({ type: 'error', message: d.error ?? 'Errore' }) |
| 90 | + } catch { addToast({ type: 'error', message: 'Errore di rete' }) } |
| 91 | + finally { setBusy(false) } |
| 92 | + }, [addToast]) |
| 93 | + |
| 94 | + if (loading) return <main className="p-10"><p className="text-[11px]" style={{ color: 'var(--color-muted)' }}>Caricamento…</p></main> |
| 95 | + |
| 96 | + const TABS: Tab<TabId>[] = [ |
| 97 | + { id: 'general', label: 'Generale' }, |
| 98 | + { id: 'notifications', label: 'Notifiche' }, |
| 99 | + { id: 'security', label: 'Sicurezza' }, |
| 100 | + { id: 'danger', label: 'Danger Zone' }, |
| 101 | + ] |
116 | 102 |
|
117 | 103 | return ( |
118 | | - <main className="min-h-screen px-5 py-10 flex flex-col items-center"> |
119 | | - <div className="w-full max-w-lg flex flex-col gap-6"> |
120 | | - |
121 | | - <div className="mb-2"> |
122 | | - <p className="text-[9px] font-semibold tracking-[0.2em] uppercase mb-1" style={{ color: 'var(--color-green)' }}>impostazioni</p> |
123 | | - <h1 className="text-xl font-bold tracking-tight" style={{ color: 'var(--color-white)' }}>Configurazione</h1> |
| 104 | + <main className="min-h-screen px-6 py-10"> |
| 105 | + <div className="max-w-2xl flex flex-col gap-6"> |
| 106 | + <div> |
| 107 | + <p className="text-[9px] font-semibold tracking-[0.2em] uppercase mb-1" style={{ color: 'var(--color-green)' }}>sistema</p> |
| 108 | + <h1 className="text-xl font-bold" style={{ color: 'var(--color-white)' }}>Impostazioni</h1> |
124 | 109 | </div> |
125 | 110 |
|
126 | | - {/* Provider AI */} |
127 | | - <Card title="Provider AI" sub="Provider attivo e API key"> |
128 | | - <Field label="Provider attivo"> |
129 | | - <select value={form.activeProvider} onChange={e => set({ activeProvider: e.target.value as Provider })} |
130 | | - className={inputCls} style={{ color: 'var(--color-bright)' }}> |
131 | | - {(Object.keys(PROVIDER_LABELS) as Provider[]).map(p => ( |
132 | | - <option key={p} value={p}>{PROVIDER_LABELS[p]}</option> |
133 | | - ))} |
134 | | - </select> |
135 | | - </Field> |
136 | | - <Field label={`API Key — ${PROVIDER_LABELS[form.activeProvider]}`}> |
137 | | - <input type="password" placeholder="sk-••••••••" className={inputCls} |
138 | | - value={form.apiKeys[form.activeProvider]} |
139 | | - onChange={e => set({ apiKeys: { ...form.apiKeys, [form.activeProvider]: e.target.value } })} |
140 | | - style={{ color: 'var(--color-bright)' }} /> |
141 | | - </Field> |
142 | | - </Card> |
143 | | - |
144 | | - {/* Telegram */} |
145 | | - <Card title="Telegram" sub="Notifiche e comandi via bot"> |
146 | | - <TelegramStatusWidget /> |
147 | | - <label className="flex items-center gap-3 cursor-pointer"> |
148 | | - <input type="checkbox" checked={form.telegramEnabled} |
149 | | - onChange={e => set({ telegramEnabled: e.target.checked })} /> |
150 | | - <span className="text-[11px]" style={{ color: 'var(--color-muted)' }}>Abilita integrazione Telegram</span> |
151 | | - </label> |
152 | | - {form.telegramEnabled && <> |
153 | | - <Field label="Bot Token"> |
154 | | - <input type="password" placeholder="123456:ABC-DEF…" className={inputCls} |
155 | | - value={form.botToken} onChange={e => set({ botToken: e.target.value })} |
156 | | - style={{ color: 'var(--color-bright)' }} /> |
157 | | - </Field> |
158 | | - <Field label="Chat ID"> |
159 | | - <input type="text" placeholder="-100123456789" className={inputCls} |
160 | | - value={form.chatId} onChange={e => set({ chatId: e.target.value })} |
161 | | - style={{ color: 'var(--color-bright)' }} /> |
162 | | - </Field> |
| 111 | + <Tabs tabs={TABS} active={tab} onChange={setTab} /> |
| 112 | + |
| 113 | + <div className="flex flex-col gap-5 pt-1"> |
| 114 | + {tab === 'general' && <> |
| 115 | + <Row label="Nome applicazione"> |
| 116 | + <input style={inp} value={s.app_name} onChange={e => setS(p => ({ ...p, app_name: e.target.value }))} /> |
| 117 | + </Row> |
| 118 | + <Row label="Lingua default"> |
| 119 | + <select style={inp} value={s.language} onChange={e => setS(p => ({ ...p, language: e.target.value }))}> |
| 120 | + <option value="it">Italiano</option> |
| 121 | + <option value="en">English</option> |
| 122 | + </select> |
| 123 | + </Row> |
| 124 | + <SaveBtn busy={busy} onClick={save} /> |
163 | 125 | </>} |
164 | | - </Card> |
165 | | - |
166 | | - {/* Cron */} |
167 | | - <Card title="Cron Jobs" sub="Esecuzione automatica schedulata"> |
168 | | - <label className="flex items-center gap-3 cursor-pointer"> |
169 | | - <input type="checkbox" checked={form.cronEnabled} |
170 | | - onChange={e => set({ cronEnabled: e.target.checked })} /> |
171 | | - <span className="text-[11px]" style={{ color: 'var(--color-muted)' }}>Abilita cron jobs automatici</span> |
172 | | - </label> |
173 | | - </Card> |
174 | | - |
175 | | - {/* Azioni */} |
176 | | - <div className="flex gap-3"> |
177 | | - <a href="/" className="flex-1 py-2.5 rounded text-[12px] font-semibold text-center cursor-pointer" style={btnSecondary}> |
178 | | - Annulla |
179 | | - </a> |
180 | | - <button onClick={save} disabled={status === 'saving'} |
181 | | - className="flex-1 py-2.5 rounded text-[12px] font-bold cursor-pointer transition-all" |
182 | | - style={status === 'saving' ? { background: 'var(--color-border)', color: 'var(--color-dim)' } : btnPrimary}> |
183 | | - {status === 'saving' ? 'Salvataggio…' : status === 'ok' ? '✓ Salvato' : 'Salva configurazione'} |
184 | | - </button> |
185 | | - </div> |
186 | | - {status === 'error' && <p className="text-[10px] text-center" style={{ color: 'var(--color-red)' }}>{errMsg}</p>} |
187 | 126 |
|
| 127 | + {tab === 'notifications' && <> |
| 128 | + <p className="text-[10px] font-bold uppercase tracking-widest" style={{ color: 'var(--color-dim)' }}>Canali</p> |
| 129 | + {(['telegram', 'email', 'desktop'] as NotifKey[]).map(k => ( |
| 130 | + <Toggle key={k} checked={s.notifications[k]} |
| 131 | + label={k === 'telegram' ? 'Telegram' : k === 'email' ? 'Email' : 'Desktop (browser)'} |
| 132 | + onChange={v => setS(p => ({ ...p, notifications: { ...p.notifications, [k]: v } }))} /> |
| 133 | + ))} |
| 134 | + <SaveBtn busy={busy} onClick={save} /> |
| 135 | + </>} |
| 136 | + |
| 137 | + {tab === 'security' && <> |
| 138 | + <Row label="Nuova password" hint="Disponibile in una prossima versione"> |
| 139 | + <input type="password" style={{ ...inp, opacity: 0.4, cursor: 'not-allowed' }} disabled placeholder="••••••••" /> |
| 140 | + </Row> |
| 141 | + <div className="flex items-center gap-3 px-3 py-2.5 rounded" |
| 142 | + style={{ border: '1px solid var(--color-border)', background: 'var(--color-card)' }}> |
| 143 | + <span className="text-[9px] px-2 py-0.5 rounded" |
| 144 | + style={{ background: 'rgba(255,196,0,0.1)', color: 'var(--color-yellow)', border: '1px solid rgba(255,196,0,0.2)' }}> |
| 145 | + Presto disponibile |
| 146 | + </span> |
| 147 | + <p className="text-[10px]" style={{ color: 'var(--color-dim)' }}>2FA via TOTP (Google Authenticator)</p> |
| 148 | + </div> |
| 149 | + </>} |
| 150 | + |
| 151 | + {tab === 'danger' && <> |
| 152 | + <div className="px-4 py-3 rounded" style={{ background: 'rgba(255,69,96,0.06)', border: '1px solid rgba(255,69,96,0.2)' }}> |
| 153 | + <p className="text-[10px] font-bold" style={{ color: 'var(--color-red)' }}>Attenzione — azioni irreversibili</p> |
| 154 | + </div> |
| 155 | + {[ |
| 156 | + { act: 'reset_config', label: 'Reset configurazione', desc: 'Ripristina jht.config.json ai valori di default' }, |
| 157 | + { act: 'clear_cache', label: 'Svuota cache', desc: 'Elimina tutti i file in ~/.jht/cache/' }, |
| 158 | + ].map(({ act, label, desc }) => ( |
| 159 | + <div key={act} className="flex items-center justify-between gap-4 px-4 py-3 rounded" |
| 160 | + style={{ border: '1px solid var(--color-border)', background: 'var(--color-card)' }}> |
| 161 | + <div> |
| 162 | + <p className="text-[11px] font-semibold" style={{ color: 'var(--color-muted)' }}>{label}</p> |
| 163 | + <p className="text-[10px]" style={{ color: 'var(--color-dim)' }}>{desc}</p> |
| 164 | + </div> |
| 165 | + <button onClick={() => dangerAction(act, label)} disabled={busy} |
| 166 | + className="px-3 py-1.5 rounded text-[10px] font-bold cursor-pointer flex-shrink-0 transition-all" |
| 167 | + style={{ border: '1px solid rgba(255,69,96,0.4)', color: 'var(--color-red)', background: 'transparent' }}> |
| 168 | + {label} |
| 169 | + </button> |
| 170 | + </div> |
| 171 | + ))} |
| 172 | + </>} |
| 173 | + </div> |
188 | 174 | </div> |
189 | 175 | </main> |
190 | 176 | ) |
|
0 commit comments