termsearch 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1051 @@
1
+ // TermSearch — Vanilla JS SPA (allineato a MmmSearch)
2
+
3
+ // ─── State ────────────────────────────────────────────────────────────────
4
+ const state = {
5
+ query: '',
6
+ category: 'web',
7
+ results: [],
8
+ aiSummary: '',
9
+ aiStatus: 'idle',
10
+ aiError: null,
11
+ aiMeta: null,
12
+ profilerData: null,
13
+ profilerLoading: false,
14
+ torrentData: [],
15
+ socialData: [],
16
+ loading: false,
17
+ providers: [],
18
+ config: null,
19
+ };
20
+
21
+ function buildSearchHash(query, category = 'web') {
22
+ const params = new URLSearchParams();
23
+ if (query) params.set('q', query);
24
+ if (category && category !== 'web') params.set('cat', category);
25
+ const raw = params.toString();
26
+ return raw ? `#/?${raw}` : '#/';
27
+ }
28
+
29
+ // ─── Router ───────────────────────────────────────────────────────────────
30
+ function route() {
31
+ const hash = location.hash || '#/';
32
+ if (hash.startsWith('#/settings')) return renderSettings();
33
+ const queryIdx = hash.indexOf('?');
34
+ const params = new URLSearchParams(queryIdx >= 0 ? hash.slice(queryIdx + 1) : '');
35
+ const q = params.get('q') || '';
36
+ const cat = (params.get('cat') || 'web').toLowerCase();
37
+ state.category = ['web', 'images', 'news'].includes(cat) ? cat : 'web';
38
+ if (q && (q !== state.query || state.results.length === 0)) {
39
+ state.query = q;
40
+ doSearch(q);
41
+ return;
42
+ }
43
+ if (!q) {
44
+ state.query = '';
45
+ }
46
+ renderApp();
47
+ }
48
+
49
+ function navigate(path) { location.hash = path; }
50
+ window.addEventListener('hashchange', route);
51
+ window.addEventListener('load', route);
52
+
53
+ // ─── Helpers ──────────────────────────────────────────────────────────────
54
+ async function api(path, opts = {}) {
55
+ const r = await fetch(path, opts);
56
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
57
+ return r.json();
58
+ }
59
+
60
+ function getLang() { return localStorage.getItem('ts-lang') || 'auto'; }
61
+ function setLang(v) { localStorage.setItem('ts-lang', v); }
62
+ function getTheme() { return localStorage.getItem('ts-theme') || 'dark'; }
63
+ function toggleTheme() {
64
+ const isLight = document.documentElement.classList.toggle('light');
65
+ localStorage.setItem('ts-theme', isLight ? 'light' : 'dark');
66
+ }
67
+
68
+ // ─── SVG Icons ────────────────────────────────────────────────────────────
69
+ function svg(paths, size = 16, extra = '') {
70
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" ${extra}>${paths}</svg>`;
71
+ }
72
+
73
+ const ICONS = {
74
+ search: svg('<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>'),
75
+ settings: svg('<circle cx="12" cy="12" r="3"/><path d="M12 2v3m0 14v3M2 12h3m14 0h3m-3.7-8.3-2.1 2.1m-8.4 8.4-2.1 2.1m12.5 0-2.1-2.1M5.7 5.7 3.6 3.6"/>'),
76
+ theme: svg('<circle cx="12" cy="12" r="5"/><path d="M12 1v2m0 18v2M4.22 4.22l1.42 1.42m12.72 12.72 1.42 1.42M1 12h2m18 0h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>'),
77
+ back: svg('<path d="M19 12H5"/><path d="m12 5-7 7 7 7"/>'),
78
+ magnet: svg('<path d="M6 15A6 6 0 1 0 6 3a6 6 0 0 0 0 12z"/><path d="M6 3v12"/><path d="M18 3a6 6 0 0 1 0 12"/><path d="M18 3v12"/><path d="M6 15h12"/>'),
79
+ profile: svg('<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>'),
80
+ torrent: svg('<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>'),
81
+ social: svg('<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>'),
82
+ github: svg('<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.2c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.4 5.4 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/>'),
83
+ spinner: svg('<path opacity=".25" d="M12 2a10 10 0 1 0 10 10" stroke-width="3"/><path d="M12 2a10 10 0 0 1 10 10" stroke-width="3"/>', 14, 'class="spin"'),
84
+ chevron: svg('<polyline points="6 9 12 15 18 9"/>'),
85
+ external: svg('<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>'),
86
+ };
87
+
88
+ function iconEl(name, cls = '') {
89
+ const span = document.createElement('span');
90
+ span.innerHTML = ICONS[name] || '';
91
+ span.style.display = 'inline-flex';
92
+ span.style.alignItems = 'center';
93
+ if (cls) span.className = cls;
94
+ return span;
95
+ }
96
+
97
+ // ─── DOM helper ───────────────────────────────────────────────────────────
98
+ function el(tag, props, ...children) {
99
+ const e = document.createElement(tag);
100
+ for (const [k, v] of Object.entries(props || {})) {
101
+ if (k === 'className') e.className = v;
102
+ else if (k === 'html') e.innerHTML = v;
103
+ else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.slice(2).toLowerCase(), v);
104
+ else e.setAttribute(k, v);
105
+ }
106
+ for (const child of children.flat()) {
107
+ if (child == null) continue;
108
+ e.append(typeof child === 'string' ? document.createTextNode(child) : child);
109
+ }
110
+ return e;
111
+ }
112
+
113
+ // ─── Language picker ──────────────────────────────────────────────────────
114
+ const LANGS = [
115
+ { code: 'auto', label: '🌐 Auto' },
116
+ { code: 'it-IT', label: '🇮🇹 IT' },
117
+ { code: 'en-US', label: '🇺🇸 EN' },
118
+ { code: 'es-ES', label: '🇪🇸 ES' },
119
+ { code: 'fr-FR', label: '🇫🇷 FR' },
120
+ { code: 'de-DE', label: '🇩🇪 DE' },
121
+ { code: 'pt-PT', label: '🇵🇹 PT' },
122
+ { code: 'ru-RU', label: '🇷🇺 RU' },
123
+ { code: 'zh-CN', label: '🇨🇳 ZH' },
124
+ { code: 'ja-JP', label: '🇯🇵 JA' },
125
+ ];
126
+
127
+ function LangPicker() {
128
+ const wrap = el('div', { className: 'lang-wrap' });
129
+ const sel = el('select', { className: 'lang-select' });
130
+ for (const l of LANGS) {
131
+ const opt = el('option', { value: l.code }, l.label);
132
+ if (l.code === getLang()) opt.selected = true;
133
+ sel.append(opt);
134
+ }
135
+ sel.addEventListener('change', () => { setLang(sel.value); });
136
+ const arrow = el('span', { className: 'lang-arrow', html: svg('<polyline points="6 9 12 15 18 9"/>', 12) });
137
+ wrap.append(sel, arrow);
138
+ return wrap;
139
+ }
140
+
141
+ // ─── Search form ──────────────────────────────────────────────────────────
142
+ function SearchForm(value, onSearch) {
143
+ const form = el('form', { className: 'search-form' });
144
+ const sicon = el('span', { className: 'search-icon', html: ICONS.search });
145
+ const input = el('input', {
146
+ className: 'search-input', type: 'search',
147
+ placeholder: 'Search...', value: value || '',
148
+ autocomplete: 'off', autocorrect: 'off', autocapitalize: 'off', spellcheck: 'false',
149
+ });
150
+ const btn = el('button', { className: 'search-btn', type: 'submit', html: ICONS.search });
151
+ form.append(sicon, input, btn);
152
+ form.addEventListener('submit', (e) => {
153
+ e.preventDefault();
154
+ const q = input.value.trim();
155
+ if (!q) return;
156
+ navigate(buildSearchHash(q, state.category));
157
+ onSearch(q, state.category);
158
+ });
159
+ return form;
160
+ }
161
+
162
+ // ─── Favicon helper ───────────────────────────────────────────────────────
163
+ function Favicon(url) {
164
+ try {
165
+ const host = new URL(url).hostname.replace(/^www\./, '');
166
+ return el('span', { className: 'result-favicon result-favicon-fallback' }, host.slice(0, 1).toUpperCase());
167
+ } catch {
168
+ return el('span', { className: 'result-favicon result-favicon-fallback' }, '?');
169
+ }
170
+ }
171
+
172
+ // ─── Result item ──────────────────────────────────────────────────────────
173
+ function ResultItem(r, idx = 0) {
174
+ let host = '', displayUrl = r.url || '';
175
+ try {
176
+ const u = new URL(r.url);
177
+ host = u.hostname.replace(/^www\./, '');
178
+ displayUrl = u.hostname + u.pathname.replace(/\/$/, '');
179
+ } catch { host = r.url; }
180
+
181
+ const item = el('div', { className: 'result-item anim-fade-up', style: `animation-delay:${idx * 50}ms` });
182
+
183
+ // Source row: favicon + host
184
+ const source = el('div', { className: 'result-source' });
185
+ source.append(Favicon(r.url), el('span', { className: 'result-host' }, host));
186
+
187
+ // Badges
188
+ const titleRow = el('div', { style: 'display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:2px' });
189
+ const titleLink = el('a', { className: 'result-title', href: r.url, target: '_blank', rel: 'noopener noreferrer' }, r.title || r.url);
190
+ titleRow.append(titleLink);
191
+
192
+ // Type badge
193
+ const type = r.type || r.engine || '';
194
+ if (type.includes('torrent') || r.magnetLink) {
195
+ const b = el('span', { className: 'result-badge badge-torrent' }, 'torrent');
196
+ titleRow.append(b);
197
+ } else if (type.includes('video') || /youtube|vimeo/.test(r.url || '')) {
198
+ titleRow.append(el('span', { className: 'result-badge badge-video' }, 'video'));
199
+ } else if (/github\.com/.test(r.url || '')) {
200
+ titleRow.append(el('span', { className: 'result-badge badge-github' }, 'github'));
201
+ } else if (/reddit\.com/.test(r.url || '')) {
202
+ titleRow.append(el('span', { className: 'result-badge badge-reddit' }, 'reddit'));
203
+ }
204
+
205
+ const urlLine = el('div', { className: 'result-url' }, displayUrl);
206
+ const snippet = r.snippet ? el('div', { className: 'result-snippet' }, r.snippet) : null;
207
+
208
+ // Actions
209
+ const actions = el('div', { className: 'result-actions' });
210
+ actions.append(el('span', { className: 'result-engine' }, r.engine || ''));
211
+
212
+ if (r.magnetLink) {
213
+ const magBtn = el('a', { className: 'magnet-btn', href: r.magnetLink, title: 'Open magnet' });
214
+ magBtn.innerHTML = ICONS.magnet + ' Magnet';
215
+ actions.append(magBtn);
216
+ }
217
+
218
+ item.append(source, titleRow, urlLine);
219
+ if (snippet) item.append(snippet);
220
+ item.append(actions);
221
+ return item;
222
+ }
223
+
224
+ // ─── AI Panel ─────────────────────────────────────────────────────────────
225
+ function renderAiPanel() {
226
+ const panel = document.getElementById('ai-panel');
227
+ if (!panel) return;
228
+ const isActive = ['loading', 'streaming', 'done', 'error'].includes(state.aiStatus);
229
+ if (!isActive) { panel.style.display = 'none'; return; }
230
+ panel.style.display = 'block';
231
+
232
+ const dotsClass = state.aiStatus === 'done' ? 'done' : state.aiStatus === 'error' ? 'error' : '';
233
+ const statusText = state.aiStatus === 'loading' ? 'Thinking…' : state.aiStatus === 'streaming' ? 'Generating…' : state.aiStatus === 'done' ? 'AI Summary' : 'Error';
234
+
235
+ const dotsEl = el('div', { className: 'ai-dots' });
236
+ ['violet', 'indigo', 'dim'].forEach(c => {
237
+ dotsEl.append(el('div', { className: `ai-dot ${dotsClass || c}` }));
238
+ });
239
+
240
+ const header = el('div', { className: 'panel-header' },
241
+ el('div', { className: 'panel-header-left' },
242
+ dotsEl,
243
+ el('span', { className: 'panel-label' }, statusText),
244
+ state.aiMeta?.model ? el('span', { style: 'font-size:10px;color:var(--text3);margin-left:6px' }, state.aiMeta.model) : null,
245
+ ),
246
+ );
247
+
248
+ const content = el('div', { className: 'ai-content' });
249
+ if (state.aiError) {
250
+ content.style.color = '#f87171';
251
+ content.textContent = state.aiError;
252
+ } else {
253
+ content.textContent = state.aiSummary;
254
+ }
255
+
256
+ panel.innerHTML = '';
257
+ panel.append(header, content);
258
+
259
+ if (state.aiMeta?.fetchedCount) {
260
+ panel.append(el('div', { className: 'ai-meta' }, `Read ${state.aiMeta.fetchedCount} pages`));
261
+ }
262
+ if (state.aiStatus === 'streaming') {
263
+ panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
264
+ }
265
+ }
266
+
267
+ // ─── Profiler Panel ───────────────────────────────────────────────────────
268
+ function ProfilerPanel(data) {
269
+ if (!data) return null;
270
+ const { target, profile, similar } = data;
271
+
272
+ const platformClass = {
273
+ github: 'plat-github', bluesky: 'plat-bluesky', reddit: 'plat-reddit',
274
+ twitter: 'plat-twitter', instagram: 'plat-instagram', linkedin: 'plat-linkedin',
275
+ youtube: 'plat-youtube', facebook: 'plat-facebook', telegram: 'plat-telegram', tiktok: 'plat-tiktok',
276
+ };
277
+
278
+ const panel = el('div', { className: 'panel panel-profiler anim-fade-in' });
279
+
280
+ // Header
281
+ panel.append(el('div', { className: 'panel-header' },
282
+ el('div', { className: 'panel-header-left' },
283
+ iconEl('profile'),
284
+ el('span', { className: 'panel-label' }, 'Profile Scan'),
285
+ target?.platform ? el('span', { className: `${platformClass[target.platform] || ''} `, style: 'font-size:11px;margin-left:4px' }, target.platform) : null,
286
+ ),
287
+ ));
288
+
289
+ if (!profile) {
290
+ if (state.profilerLoading) {
291
+ panel.append(el('div', { className: 'loader' },
292
+ el('span', { html: ICONS.spinner }),
293
+ 'Scanning…',
294
+ ));
295
+ } else {
296
+ panel.append(el('div', { style: 'font-size:13px;color:var(--text3)' }, 'No profile data found.'));
297
+ }
298
+ return panel;
299
+ }
300
+
301
+ // Profile card
302
+ const card = el('div', { className: 'profile-card' });
303
+ if (profile.avatar) {
304
+ const av = el('img', { className: 'profile-avatar', src: profile.avatar, alt: profile.name || '' });
305
+ av.onerror = () => { av.style.display = 'none'; };
306
+ card.append(av);
307
+ }
308
+ const info = el('div', { style: 'flex:1;min-width:0' });
309
+ const nameEl = profile.url
310
+ ? el('a', { className: 'profile-name', href: profile.url, target: '_blank', rel: 'noopener' }, profile.name || profile.handle || '')
311
+ : el('div', { className: 'profile-name' }, profile.name || profile.handle || '');
312
+ info.append(nameEl);
313
+ if (profile.handle) info.append(el('div', { className: 'profile-handle' }, '@' + profile.handle));
314
+ if (profile.bio) info.append(el('div', { className: 'profile-bio' }, profile.bio));
315
+
316
+ // Extra metadata
317
+ const extras = [profile.location, profile.company].filter(Boolean);
318
+ if (extras.length) {
319
+ info.append(el('div', { style: 'font-size:10px;color:var(--text3);margin-top:4px' }, extras.join(' · ')));
320
+ }
321
+ card.append(info);
322
+ panel.append(card);
323
+
324
+ // Stats
325
+ const statsFields = [
326
+ { key: 'followers', label: 'Followers' },
327
+ { key: 'following', label: 'Following' },
328
+ { key: 'repos', label: 'Repos' },
329
+ { key: 'karma', label: 'Karma' },
330
+ { key: 'posts', label: 'Posts' },
331
+ { key: 'subscribers',label: 'Subscribers' },
332
+ { key: 'likes', label: 'Likes' },
333
+ ];
334
+ const statsData = statsFields.filter(f => profile[f.key] != null);
335
+ if (statsData.length) {
336
+ const statsRow = el('div', { className: 'profile-stats' });
337
+ statsData.forEach(f => {
338
+ statsRow.append(el('div', { className: 'stat' },
339
+ el('div', { className: 'stat-val' }, String(profile[f.key])),
340
+ el('div', { className: 'stat-key' }, f.label),
341
+ ));
342
+ });
343
+ panel.append(statsRow);
344
+ }
345
+
346
+ // Top repos (GitHub)
347
+ if (profile.topRepos?.length) {
348
+ panel.append(el('div', { className: 'section-title' }, 'Top Repositories'));
349
+ profile.topRepos.slice(0, 5).forEach(r => {
350
+ const repoEl = el('a', { className: 'repo-item', href: r.url || '#', target: '_blank', rel: 'noopener' });
351
+ repoEl.append(
352
+ el('span', { className: 'repo-name' }, r.name),
353
+ r.lang ? el('span', { className: 'repo-lang' }, r.lang) : null,
354
+ r.stars != null ? el('span', { className: 'repo-stars' }, '★ ' + r.stars) : null,
355
+ r.forks != null ? el('span', { className: 'repo-forks' }, '⑂ ' + r.forks) : null,
356
+ );
357
+ panel.append(repoEl);
358
+ });
359
+ }
360
+
361
+ // Similar profiles
362
+ if (similar?.length) {
363
+ panel.append(el('div', { className: 'section-title' }, 'Similar Profiles'));
364
+ const grid = el('div', { className: 'similar-grid' });
365
+ similar.slice(0, 8).forEach(s => {
366
+ const item = el('div', { className: 'similar-item', onClick: () => {
367
+ const q = s.url || s.handle;
368
+ if (q) navigate(`#/?q=${encodeURIComponent(q)}`);
369
+ }});
370
+ if (s.avatar) {
371
+ const av = el('img', { className: 'similar-avatar', src: s.avatar, alt: s.handle || '' });
372
+ av.onerror = () => av.remove();
373
+ item.append(av);
374
+ }
375
+ item.append(el('span', { className: 'similar-handle' }, '@' + (s.handle || '')));
376
+ grid.append(item);
377
+ });
378
+ panel.append(grid);
379
+ }
380
+
381
+ return panel;
382
+ }
383
+
384
+ // ─── Torrent Panel ────────────────────────────────────────────────────────
385
+ function TorrentPanel(results) {
386
+ if (!results?.length) return null;
387
+ const panel = el('div', { className: 'panel panel-torrent anim-fade-in' });
388
+
389
+ panel.append(el('div', { className: 'panel-header' },
390
+ el('div', { className: 'panel-header-left' },
391
+ iconEl('torrent'),
392
+ el('span', { className: 'panel-label' }, 'Best Magnets'),
393
+ el('span', { style: 'font-size:10px;color:var(--text3);margin-left:4px' }, results.length + ' results'),
394
+ ),
395
+ ));
396
+
397
+ results.slice(0, 6).forEach((r, i) => {
398
+ const item = el('div', { className: 'torrent-item' });
399
+ const rank = el('span', { className: 'torrent-rank' }, '#' + (i + 1));
400
+ const info = el('div', { className: 'torrent-info' });
401
+ info.append(el('div', { className: 'torrent-title' }, r.title || 'Unknown'));
402
+ const meta = el('div', { className: 'torrent-meta' });
403
+ if (r.seed != null) meta.append(el('span', {}, r.seed + ' seed'));
404
+ if (r.filesize) meta.append(el('span', {}, r.filesize));
405
+ if (r.engine) meta.append(el('span', { style: 'color:var(--text3)' }, r.engine));
406
+ info.append(meta);
407
+
408
+ const actions = el('div', { style: 'display:flex;gap:4px;flex-shrink:0' });
409
+ if (r.magnetLink) {
410
+ const magBtn = el('a', { className: 'magnet-btn', href: r.magnetLink, title: 'Magnet link' });
411
+ magBtn.innerHTML = ICONS.magnet;
412
+ actions.append(magBtn);
413
+ }
414
+ item.append(rank, info, actions);
415
+ panel.append(item);
416
+ });
417
+
418
+ return panel;
419
+ }
420
+
421
+ // ─── Social Panel ─────────────────────────────────────────────────────────
422
+ function SocialPanel(results) {
423
+ if (!results?.length) return null;
424
+ const panel = el('div', { className: 'panel panel-social anim-fade-in' });
425
+
426
+ const engines = [...new Set(results.map(r => r._socialEngine || r.engine).filter(Boolean))];
427
+ panel.append(el('div', { className: 'panel-header' },
428
+ el('div', { className: 'panel-header-left' },
429
+ iconEl('social'),
430
+ el('span', { className: 'panel-label' }, 'Social & News'),
431
+ engines.length ? el('span', { style: 'font-size:10px;color:var(--text3);margin-left:4px' }, engines.join(' · ')) : null,
432
+ ),
433
+ ));
434
+
435
+ const inner = el('div', {});
436
+ results.forEach((r, i) => inner.append(ResultItem(r, i)));
437
+ panel.append(inner);
438
+ return panel;
439
+ }
440
+
441
+ // ─── Search logic ─────────────────────────────────────────────────────────
442
+ function isProfileQuery(q) {
443
+ return /^https?:\/\/(github|twitter|x|instagram|bluesky|reddit|linkedin|youtube|tiktok|telegram|facebook)/.test(q)
444
+ || /^@[a-zA-Z0-9_\.]{2,}$/.test(q)
445
+ || /github\.com\/.+|bsky\.app\/.+|reddit\.com\/u\//.test(q);
446
+ }
447
+
448
+ function flattenSocialResults(payload) {
449
+ if (!payload) return [];
450
+ const nested = payload.results && !Array.isArray(payload.results) ? payload.results : {};
451
+ const flat = Array.isArray(payload.results) ? payload.results : [];
452
+ return [
453
+ ...flat,
454
+ ...(nested.bluesky_posts || []).map((item) => ({
455
+ title: item.title || item.text || '',
456
+ url: item.url || '',
457
+ snippet: item.snippet || item.author || '',
458
+ _socialEngine: 'bluesky',
459
+ })),
460
+ ...(nested.bluesky_actors || []).map((item) => ({
461
+ title: item.title || item.displayName || item.handle || '',
462
+ url: item.url || '',
463
+ snippet: item.snippet || item.description || '',
464
+ _socialEngine: 'bluesky users',
465
+ })),
466
+ ...(nested.gdelt || []).map((item) => ({
467
+ title: item.title || '',
468
+ url: item.url || '',
469
+ snippet: item.snippet || item.domain || '',
470
+ _socialEngine: 'gdelt',
471
+ })),
472
+ ].filter((item) => item.url);
473
+ }
474
+
475
+ async function runSearchProgressive(q, lang, category) {
476
+ const params = new URLSearchParams({ q, lang, cat: category });
477
+ const response = await fetch(`/api/search-stream?${params.toString()}`);
478
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
479
+
480
+ const contentType = response.headers.get('content-type') || '';
481
+ if (!contentType.includes('text/event-stream')) {
482
+ const fallback = await response.json();
483
+ return { results: fallback.results || [], providers: fallback.providers || [] };
484
+ }
485
+
486
+ const reader = response.body.getReader();
487
+ const decoder = new TextDecoder();
488
+ let buffer = '';
489
+ let fullResults = null;
490
+ let providers = [];
491
+
492
+ while (true) {
493
+ const { done, value } = await reader.read();
494
+ if (done) break;
495
+ buffer += decoder.decode(value, { stream: true });
496
+ const lines = buffer.split('\n');
497
+ buffer = lines.pop() || '';
498
+
499
+ for (const line of lines) {
500
+ if (!line.startsWith('data: ')) continue;
501
+ let chunk;
502
+ try {
503
+ chunk = JSON.parse(line.slice(6));
504
+ } catch {
505
+ continue;
506
+ }
507
+ if (chunk.error) throw new Error(chunk.message || chunk.error);
508
+ if (chunk.batch === 'fast') {
509
+ state.results = chunk.results || [];
510
+ state.providers = chunk.providers || state.providers;
511
+ state.loading = false;
512
+ renderApp();
513
+ } else if (chunk.batch === 'full') {
514
+ fullResults = chunk.allResults || chunk.results || [];
515
+ providers = chunk.providers || providers;
516
+ state.results = fullResults;
517
+ state.providers = providers;
518
+ state.loading = false;
519
+ renderApp();
520
+ } else if (chunk.providers && Array.isArray(chunk.providers)) {
521
+ providers = chunk.providers;
522
+ }
523
+ }
524
+ }
525
+
526
+ return {
527
+ results: fullResults || state.results || [],
528
+ providers: providers.length ? providers : state.providers || [],
529
+ };
530
+ }
531
+
532
+ async function doSearch(q, category = state.category) {
533
+ if (!q.trim()) return;
534
+ state.category = ['web', 'images', 'news'].includes(category) ? category : 'web';
535
+ state.loading = true;
536
+ state.results = [];
537
+ state.aiSummary = '';
538
+ state.aiStatus = 'idle';
539
+ state.aiError = null;
540
+ state.aiMeta = null;
541
+ state.profilerData = null;
542
+ state.profilerLoading = isProfileQuery(q);
543
+ state.torrentData = [];
544
+ state.socialData = [];
545
+ renderApp();
546
+
547
+ const lang = getLang();
548
+
549
+ try {
550
+ const searchPromise = runSearchProgressive(q, lang, state.category).catch(async () => {
551
+ return api(`/api/search?q=${encodeURIComponent(q)}&lang=${lang}&cat=${encodeURIComponent(state.category)}`);
552
+ });
553
+ const promises = [
554
+ searchPromise,
555
+ api(`/api/social-search?q=${encodeURIComponent(q)}`).catch(() => null),
556
+ ];
557
+
558
+ if (state.profilerLoading) {
559
+ promises.push(
560
+ api(`/api/profiler?q=${encodeURIComponent(q)}`).catch(() => null)
561
+ );
562
+ } else {
563
+ promises.push(Promise.resolve(null));
564
+ }
565
+
566
+ const [searchRes, socialRes, profilerRes] = await Promise.all(promises);
567
+
568
+ state.loading = false;
569
+ state.results = searchRes?.results || state.results || [];
570
+ state.providers = searchRes?.providers || state.providers || [];
571
+
572
+ // Social results
573
+ state.socialData = flattenSocialResults(socialRes);
574
+
575
+ // Profiler
576
+ state.profilerData = profilerRes;
577
+ state.profilerLoading = false;
578
+
579
+ // Torrent results (from main search or extracted by engine)
580
+ state.torrentData = state.results.filter(r => r.magnetLink || r.engine?.includes('torrent') || r.engine?.includes('piratebay') || r.engine?.includes('1337x'));
581
+
582
+ renderApp();
583
+
584
+ // AI summary
585
+ if (state.config?.ai?.enabled && state.config?.ai?.api_base && state.config?.ai?.model) {
586
+ startAiSummary(q, state.results, lang);
587
+ }
588
+ } catch (e) {
589
+ state.loading = false;
590
+ state.profilerLoading = false;
591
+ state.results = [];
592
+ renderApp();
593
+ }
594
+ }
595
+
596
+ async function startAiSummary(query, results, lang) {
597
+ state.aiStatus = 'loading';
598
+ state.aiSummary = '';
599
+ renderAiPanel();
600
+
601
+ try {
602
+ const r = await fetch('/api/ai-summary', {
603
+ method: 'POST',
604
+ headers: { 'Content-Type': 'application/json' },
605
+ body: JSON.stringify({ query, lang, results: results.slice(0, 10), stream: true }),
606
+ });
607
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
608
+
609
+ const reader = r.body.getReader();
610
+ const dec = new TextDecoder();
611
+ let buf = '';
612
+ state.aiStatus = 'streaming';
613
+ renderAiPanel();
614
+
615
+ while (true) {
616
+ const { done, value } = await reader.read();
617
+ if (done) break;
618
+ buf += dec.decode(value, { stream: true });
619
+ const lines = buf.split('\n');
620
+ buf = lines.pop() || '';
621
+ for (const line of lines) {
622
+ if (!line.startsWith('data: ')) continue;
623
+ try {
624
+ const d = JSON.parse(line.slice(6));
625
+ if (d.chunk) { state.aiSummary += d.chunk; renderAiPanel(); }
626
+ else if (d.error) { state.aiStatus = 'error'; state.aiError = d.message || d.error; renderAiPanel(); }
627
+ else if (d.model || d.sites) { state.aiStatus = 'done'; state.aiMeta = { fetchedCount: d.fetchedCount, model: d.model }; renderAiPanel(); }
628
+ } catch { /* ignore */ }
629
+ }
630
+ }
631
+ if (state.aiStatus === 'streaming') { state.aiStatus = 'done'; renderAiPanel(); }
632
+ } catch (e) {
633
+ state.aiStatus = 'error';
634
+ state.aiError = e.message;
635
+ renderAiPanel();
636
+ }
637
+ }
638
+
639
+ // ─── Main render ──────────────────────────────────────────────────────────
640
+ function renderApp() {
641
+ const app = document.getElementById('app');
642
+ if (!app) return;
643
+ app.innerHTML = '';
644
+
645
+ if (!state.query) {
646
+ renderHome(app);
647
+ return;
648
+ }
649
+
650
+ // Results page
651
+ const header = el('div', { className: 'header' },
652
+ el('div', { className: 'logo-text', onClick: () => { state.query = ''; state.category = 'web'; navigate('#/'); renderApp(); } },
653
+ 'Term', el('strong', {}, 'Search'),
654
+ ),
655
+ el('div', { className: 'header-search' }, SearchForm(state.query, (q, cat) => { state.query = q; doSearch(q, cat); })),
656
+ el('div', { className: 'header-nav' },
657
+ LangPicker(),
658
+ el('button', { className: 'btn-icon', title: 'Settings', onClick: () => navigate('#/settings') }, iconEl('settings')),
659
+ el('button', { className: 'btn-icon', title: 'Toggle theme', onClick: toggleTheme }, iconEl('theme')),
660
+ ),
661
+ );
662
+ const categoryBar = el('div', { className: 'category-tabs' });
663
+ const categories = [
664
+ { id: 'web', label: 'Web' },
665
+ { id: 'images', label: 'Images' },
666
+ { id: 'news', label: 'News' },
667
+ ];
668
+ categories.forEach((cat) => {
669
+ categoryBar.append(el('button', {
670
+ className: `cat-tab ${state.category === cat.id ? 'active' : ''}`,
671
+ onClick: () => {
672
+ if (state.category === cat.id) return;
673
+ state.category = cat.id;
674
+ navigate(buildSearchHash(state.query, state.category));
675
+ if (state.query) doSearch(state.query, state.category);
676
+ },
677
+ type: 'button',
678
+ }, cat.label));
679
+ });
680
+
681
+ const main = el('div', { className: 'main' });
682
+
683
+ // AI panel placeholder
684
+ const aiPanel = el('div', { id: 'ai-panel', className: 'panel panel-ai', style: 'display:none' });
685
+ main.append(aiPanel);
686
+
687
+ if (state.loading) {
688
+ main.append(el('div', { className: 'loader' },
689
+ el('span', { html: ICONS.spinner }),
690
+ el('span', {}, 'Searching '),
691
+ el('span', { style: 'color:var(--text2)' }, state.query),
692
+ ));
693
+ } else {
694
+ // Results meta
695
+ if (state.results.length > 0) {
696
+ const meta = el('div', { className: 'results-meta' });
697
+ meta.append(document.createTextNode(`${state.results.length} results`));
698
+ if (state.providers.length) meta.append(document.createTextNode(' · ' + state.providers.join(', ')));
699
+ main.append(meta);
700
+ }
701
+
702
+ // 1. Profiler
703
+ const profPanel = ProfilerPanel(state.profilerData || (state.profilerLoading ? { target: null, profile: null } : null));
704
+ if (profPanel) main.append(profPanel);
705
+
706
+ // 2. Torrent panel (only if there are magnets)
707
+ const torPanel = TorrentPanel(state.torrentData);
708
+ if (torPanel) main.append(torPanel);
709
+
710
+ // 3. Web results (excluding torrents)
711
+ const webResults = state.results.filter(r => !r.magnetLink && !r.engine?.includes('piratebay') && !r.engine?.includes('1337x'));
712
+ if (webResults.length === 0 && state.results.length === 0) {
713
+ main.append(el('div', { className: 'no-results' }, 'No results found.'));
714
+ } else {
715
+ webResults.forEach((r, i) => main.append(ResultItem(r, i)));
716
+ }
717
+
718
+ // 4. Social panel
719
+ const socPanel = SocialPanel(state.socialData);
720
+ if (socPanel) main.append(socPanel);
721
+ }
722
+
723
+ app.append(header, categoryBar, main);
724
+ renderAiPanel();
725
+ }
726
+
727
+ // ─── Homepage ─────────────────────────────────────────────────────────────
728
+ function renderHome(app) {
729
+ const home = el('div', { className: 'home' },
730
+ el('div', { className: 'home-logo' }, 'Term', el('strong', {}, 'Search')),
731
+ el('div', { className: 'home-tagline' }, 'Personal search engine · privacy-first · local-first'),
732
+ el('div', { className: 'home-search' }, SearchForm('', (q) => { state.query = q; state.category = 'web'; doSearch(q, 'web'); })),
733
+ el('div', { className: 'home-actions' },
734
+ LangPicker(),
735
+ el('button', { className: 'btn', onClick: () => navigate('#/settings') }, iconEl('settings'), ' Settings'),
736
+ el('button', { className: 'btn', onClick: toggleTheme }, iconEl('theme'), ' Theme'),
737
+ ),
738
+ );
739
+
740
+ const footer = el('div', { className: 'footer' },
741
+ el('span', { className: 'footer-link' }, '© 2026 DioNanos'),
742
+ el('a', { className: 'footer-link', href: 'https://github.com/DioNanos/termsearch', target: '_blank', rel: 'noopener' },
743
+ iconEl('github'), 'GitHub',
744
+ ),
745
+ );
746
+
747
+ app.append(home, footer);
748
+ }
749
+
750
+ // ─── Settings ─────────────────────────────────────────────────────────────
751
+ async function renderSettings() {
752
+ const app = document.getElementById('app');
753
+ if (!app) return;
754
+ app.innerHTML = '';
755
+
756
+ let cfg = state.config;
757
+ if (!cfg) {
758
+ try { cfg = state.config = await api('/api/config'); } catch { cfg = {}; }
759
+ }
760
+ let health = null, autostart = null;
761
+ await Promise.all([
762
+ api('/api/health').then(h => { health = h; }).catch(() => {}),
763
+ api('/api/autostart').then(a => { autostart = a; }).catch(() => {}),
764
+ ]);
765
+
766
+ const ai = cfg.ai || {};
767
+ const brave = cfg.brave || {};
768
+ const mojeek = cfg.mojeek || {};
769
+ const searxng = cfg.searxng || {};
770
+
771
+ const header = el('div', { className: 'header' },
772
+ el('button', { className: 'btn', onClick: () => history.back() }, iconEl('back'), ' Back'),
773
+ el('div', { className: 'logo-text' }, 'Settings'),
774
+ el('button', { className: 'btn-icon', onClick: toggleTheme }, iconEl('theme')),
775
+ );
776
+
777
+ function makeInput(id, value, placeholder = '', type = 'text') {
778
+ return el('input', { className: 'form-input', id, type, value: value || '', placeholder });
779
+ }
780
+ function val(id) { return document.getElementById(id)?.value?.trim() || ''; }
781
+ function isChecked(id) { return document.getElementById(id)?.checked || false; }
782
+
783
+ function showAlert(alertEl, msg, type) {
784
+ alertEl.className = `alert alert-${type}`;
785
+ alertEl.textContent = msg;
786
+ alertEl.style.display = 'block';
787
+ setTimeout(() => { alertEl.style.display = 'none'; }, 4000);
788
+ }
789
+
790
+ const saveAlertEl = el('div', { style: 'display:none' });
791
+
792
+ async function saveSettings() {
793
+ const aiKey = val('ai-key');
794
+ const braveKey = val('brave-key');
795
+ const mojeekKey = val('mojeek-key');
796
+ const update = {
797
+ ai: {
798
+ api_base: val('ai-base'),
799
+ model: val('ai-model'),
800
+ enabled: Boolean(val('ai-base') && val('ai-model')),
801
+ },
802
+ brave: { enabled: isChecked('brave-enabled') },
803
+ mojeek: { enabled: isChecked('mojeek-enabled') },
804
+ searxng:{ url: val('searxng-url'), enabled: isChecked('searxng-enabled') },
805
+ };
806
+ if (aiKey) update.ai.api_key = aiKey;
807
+ if (braveKey) update.brave.api_key = braveKey;
808
+ if (mojeekKey) update.mojeek.api_key = mojeekKey;
809
+ try {
810
+ const res = await api('/api/config', {
811
+ method: 'POST',
812
+ headers: { 'Content-Type': 'application/json' },
813
+ body: JSON.stringify(update),
814
+ });
815
+ state.config = cfg = res.config;
816
+ showAlert(saveAlertEl, 'Saved', 'ok');
817
+ } catch (e) {
818
+ showAlert(saveAlertEl, 'Save failed: ' + e.message, 'err');
819
+ }
820
+ }
821
+
822
+ async function testAi() {
823
+ const payload = {
824
+ api_base: val('ai-base') || ai.api_base || '',
825
+ model: val('ai-model') || ai.model || '',
826
+ };
827
+ const key = val('ai-key');
828
+ if (key) payload.api_key = key;
829
+ const btn = document.getElementById('ai-test-btn');
830
+ if (btn) btn.disabled = true;
831
+ const alertEl = document.getElementById('ai-test-result');
832
+ if (alertEl) showAlert(alertEl, 'Testing…', 'info');
833
+ try {
834
+ const res = await api('/api/config/test-ai', {
835
+ method: 'POST',
836
+ headers: { 'Content-Type': 'application/json' },
837
+ body: JSON.stringify(payload),
838
+ });
839
+ if (alertEl) showAlert(alertEl, res.ok ? `OK — ${res.model}: "${res.response}"` : `Failed: ${res.error}`, res.ok ? 'ok' : 'err');
840
+ } catch (e) {
841
+ if (alertEl) showAlert(alertEl, 'Test failed: ' + e.message, 'err');
842
+ } finally {
843
+ if (btn) btn.disabled = false;
844
+ }
845
+ }
846
+
847
+ async function testProvider(name) {
848
+ const alertEl = document.getElementById(`provider-test-${name}`);
849
+ if (!alertEl) return;
850
+ showAlert(alertEl, 'Testing…', 'info');
851
+ try {
852
+ const res = await api(`/api/config/test-provider/${name}`);
853
+ showAlert(alertEl, res.ok ? `OK — ${res.count} results` : `Failed: ${res.error || 'no results'}`, res.ok ? 'ok' : 'err');
854
+ } catch (e) {
855
+ showAlert(alertEl, 'Test failed: ' + e.message, 'err');
856
+ }
857
+ }
858
+
859
+ function renderProvidersRow() {
860
+ const div = el('div', { style: 'margin-bottom:12px;display:flex;flex-wrap:wrap;gap:6px' });
861
+ for (const p of health?.providers || []) {
862
+ div.append(el('span', { className: 'provider-badge active' }, p));
863
+ }
864
+ return div;
865
+ }
866
+
867
+ // Autostart section
868
+ const autostartAlertEl = el('div', { style: 'display:none' });
869
+ let autostartEnabled = autostart?.enabled ?? false;
870
+ const platLabel = { termux: 'Termux:Boot', linux: 'systemd --user', macos: 'launchd', unsupported: 'Not supported' };
871
+ const platName = platLabel[autostart?.platform] || autostart?.method || 'Unknown';
872
+
873
+ function statusDot(on) {
874
+ return el('span', { className: `status-dot ${on ? 'on' : 'off'}` });
875
+ }
876
+
877
+ const autostartStatusEl = el('div', { className: 'info-val', id: 'autostart-status-val', style: 'display:flex;align-items:center;gap:6px' },
878
+ statusDot(autostartEnabled),
879
+ autostartEnabled ? 'Enabled' : 'Disabled',
880
+ );
881
+
882
+ async function toggleAutostart() {
883
+ const newVal = !autostartEnabled;
884
+ const btn = document.getElementById('autostart-toggle-btn');
885
+ if (btn) btn.disabled = true;
886
+ try {
887
+ const res = await api('/api/autostart', {
888
+ method: 'POST',
889
+ headers: { 'Content-Type': 'application/json' },
890
+ body: JSON.stringify({ enabled: newVal }),
891
+ });
892
+ autostartEnabled = res.enabled;
893
+ const sv = document.getElementById('autostart-status-val');
894
+ if (sv) { sv.innerHTML = ''; sv.append(statusDot(autostartEnabled), autostartEnabled ? 'Enabled' : 'Disabled'); }
895
+ if (btn) {
896
+ btn.textContent = autostartEnabled ? 'Disable' : 'Enable';
897
+ btn.className = autostartEnabled ? 'btn btn-primary' : 'btn';
898
+ }
899
+ showAlert(autostartAlertEl, autostartEnabled ? 'Autostart enabled' : 'Autostart disabled', autostartEnabled ? 'ok' : 'info');
900
+ } catch (e) {
901
+ showAlert(autostartAlertEl, 'Failed: ' + e.message, 'err');
902
+ } finally {
903
+ if (btn) btn.disabled = false;
904
+ }
905
+ }
906
+
907
+ const autostartToggleBtn = el('button', {
908
+ id: 'autostart-toggle-btn',
909
+ className: autostartEnabled ? 'btn btn-primary' : 'btn',
910
+ onClick: toggleAutostart,
911
+ ...(autostart?.available === false ? { disabled: '' } : {}),
912
+ }, autostartEnabled ? 'Disable' : 'Enable');
913
+
914
+ const main = el('div', { className: 'main' },
915
+ el('div', { style: 'margin-bottom:20px' },
916
+ el('h1', { style: 'font-size:20px;font-weight:700' }, 'Settings'),
917
+ ),
918
+
919
+ // AI
920
+ el('div', { className: 'settings-section' },
921
+ el('h2', {}, 'AI Configuration (optional)'),
922
+ el('div', { className: 'alert alert-info', style: 'margin-bottom:12px;font-size:11px' },
923
+ 'Any OpenAI-compatible endpoint: Ollama, LM Studio, Groq, OpenAI, Z.AI, Chutes.ai…'
924
+ ),
925
+ el('div', { className: 'form-group' },
926
+ el('label', { className: 'form-label', for: 'ai-base' }, 'API Endpoint'),
927
+ makeInput('ai-base', ai.api_base, 'http://localhost:11434/v1'),
928
+ el('div', { className: 'form-hint' },
929
+ 'Localhost: localhost:11434/v1 (Ollama) · localhost:1234/v1 (LM Studio)',
930
+ el('br', {}),
931
+ 'Chutes/OpenAI-compatible: llm.chutes.ai/v1 · OpenAI: api.openai.com/v1',
932
+ ),
933
+ ),
934
+ el('div', { className: 'form-group' },
935
+ el('label', { className: 'form-label', for: 'ai-key' }, 'API Key'),
936
+ makeInput('ai-key', '', ai.api_key ? '••••••••' : '', 'password'),
937
+ el('div', { className: 'form-hint' }, 'Not required for local models (Ollama, LM Studio)'),
938
+ ),
939
+ el('div', { className: 'form-group' },
940
+ el('label', { className: 'form-label', for: 'ai-model' }, 'Model'),
941
+ makeInput('ai-model', ai.model, 'qwen3.5:4b'),
942
+ el('div', { className: 'form-hint' },
943
+ 'Localhost: qwen3.5:4b, llama3.2, mistral… · Chutes: deepseek-ai/DeepSeek-V3.2-TEE',
944
+ el('br', {}), 'OpenAI: gpt-4o-mini',
945
+ ),
946
+ ),
947
+ el('div', { className: 'form-row', style: 'margin-top:4px' },
948
+ el('button', { id: 'ai-test-btn', className: 'btn btn-primary', onClick: testAi }, 'Test Connection'),
949
+ el('button', { className: 'btn btn-primary', onClick: saveSettings }, 'Save'),
950
+ ),
951
+ el('div', { id: 'ai-test-result', style: 'display:none' }),
952
+ ),
953
+
954
+ // Providers
955
+ el('div', { className: 'settings-section' },
956
+ el('h2', {}, 'Search Providers'),
957
+ renderProvidersRow(),
958
+
959
+ // Brave
960
+ el('div', { style: 'padding:10px 0;border-bottom:1px solid var(--border2)' },
961
+ el('div', { className: 'toggle-row' },
962
+ el('span', { className: 'toggle-label' }, 'Brave Search API'),
963
+ el('label', { className: 'toggle' },
964
+ el('input', { type: 'checkbox', id: 'brave-enabled', ...(brave.enabled ? { checked: '' } : {}) }),
965
+ el('span', { className: 'toggle-slider' }),
966
+ ),
967
+ ),
968
+ el('div', { className: 'form-row' },
969
+ makeInput('brave-key', '', brave.api_key ? '••••••••' : 'API key'),
970
+ el('button', { className: 'btn', onClick: () => testProvider('brave') }, 'Test'),
971
+ ),
972
+ el('div', { id: 'provider-test-brave', style: 'display:none' }),
973
+ el('div', { className: 'form-hint', style: 'margin-top:3px' }, 'Free tier: 2000 req/month · search.brave.com/goodies'),
974
+ ),
975
+
976
+ // Mojeek
977
+ el('div', { style: 'padding:10px 0;border-bottom:1px solid var(--border2)' },
978
+ el('div', { className: 'toggle-row' },
979
+ el('span', { className: 'toggle-label' }, 'Mojeek API'),
980
+ el('label', { className: 'toggle' },
981
+ el('input', { type: 'checkbox', id: 'mojeek-enabled', ...(mojeek.enabled ? { checked: '' } : {}) }),
982
+ el('span', { className: 'toggle-slider' }),
983
+ ),
984
+ ),
985
+ el('div', { className: 'form-row' },
986
+ makeInput('mojeek-key', '', mojeek.api_key ? '••••••••' : 'API key'),
987
+ el('button', { className: 'btn', onClick: () => testProvider('mojeek') }, 'Test'),
988
+ ),
989
+ el('div', { id: 'provider-test-mojeek', style: 'display:none' }),
990
+ ),
991
+
992
+ // SearXNG
993
+ el('div', { style: 'padding:10px 0' },
994
+ el('div', { className: 'toggle-row' },
995
+ el('span', { className: 'toggle-label' }, 'SearXNG (self-hosted)'),
996
+ el('label', { className: 'toggle' },
997
+ el('input', { type: 'checkbox', id: 'searxng-enabled', ...(searxng.enabled ? { checked: '' } : {}) }),
998
+ el('span', { className: 'toggle-slider' }),
999
+ ),
1000
+ ),
1001
+ el('div', { className: 'form-row' },
1002
+ makeInput('searxng-url', searxng.url, 'http://localhost:9090'),
1003
+ el('button', { className: 'btn', onClick: () => testProvider('searxng') }, 'Test'),
1004
+ ),
1005
+ el('div', { id: 'provider-test-searxng', style: 'display:none' }),
1006
+ ),
1007
+
1008
+ el('div', { style: 'margin-top:12px;display:flex;align-items:center;gap:8px' },
1009
+ el('button', { className: 'btn btn-primary', onClick: saveSettings }, 'Save All'),
1010
+ saveAlertEl,
1011
+ ),
1012
+ ),
1013
+
1014
+ // Server info
1015
+ el('div', { className: 'settings-section' },
1016
+ el('h2', {}, 'Server Info'),
1017
+ el('div', { className: 'info-row' }, el('span', { className: 'info-key' }, 'Version'), el('span', { className: 'info-val' }, health?.version || '0.3.0')),
1018
+ el('div', { className: 'info-row' }, el('span', { className: 'info-key' }, 'Active providers'), el('span', { className: 'info-val' }, (health?.providers || []).join(', ') || 'none')),
1019
+ el('div', { className: 'info-row' }, el('span', { className: 'info-key' }, 'AI'), el('span', { className: 'info-val' }, health?.ai_enabled ? `enabled (${health.ai_model})` : 'not configured')),
1020
+ el('div', { className: 'info-row' }, el('span', { className: 'info-key' }, 'GitHub'), el('a', { href: 'https://github.com/DioNanos/termsearch', target: '_blank', className: 'info-val', style: 'color:var(--link)' }, 'DioNanos/termsearch')),
1021
+ ),
1022
+
1023
+ // Autostart
1024
+ el('div', { className: 'settings-section' },
1025
+ el('h2', {}, 'Autostart at Boot'),
1026
+ el('div', { className: 'alert alert-info', style: 'margin-bottom:12px;font-size:11px' },
1027
+ autostart?.available === false
1028
+ ? (autostart?.note || 'Autostart not available on this platform')
1029
+ : `Boot using ${platName}.`,
1030
+ ),
1031
+ el('div', { className: 'info-row' }, el('span', { className: 'info-key' }, 'Platform'), el('span', { className: 'info-val' }, platName)),
1032
+ el('div', { className: 'info-row' }, el('span', { className: 'info-key' }, 'Status'), autostartStatusEl),
1033
+ autostart?.config_path ? el('div', { className: 'info-row' },
1034
+ el('span', { className: 'info-key' }, 'Config'),
1035
+ el('span', { className: 'info-val', style: 'font-size:10px;word-break:break-all;max-width:240px' }, autostart.config_path),
1036
+ ) : null,
1037
+ el('div', { style: 'margin-top:12px;display:flex;align-items:center;gap:8px' },
1038
+ autostartToggleBtn, autostartAlertEl,
1039
+ ),
1040
+ ),
1041
+ );
1042
+
1043
+ app.append(header, main);
1044
+ }
1045
+
1046
+ // ─── Bootstrap ────────────────────────────────────────────────────────────
1047
+ (async () => {
1048
+ if (getTheme() === 'light') document.documentElement.classList.add('light');
1049
+ try { state.config = await api('/api/config'); } catch { /* non-fatal */ }
1050
+ route();
1051
+ })();