termsearch 0.3.3 → 0.3.4

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.
@@ -15,6 +15,7 @@ const state = {
15
15
  aiExpanded: false,
16
16
  aiStartTime: null,
17
17
  aiLatencyMs: null,
18
+ aiSession: [], // { q, r }[] — ultimi 4 summary (passati all'AI come contesto)
18
19
  aiLastQuery: null,
19
20
  aiLastResults: null,
20
21
  aiLastLang: null,
@@ -44,6 +45,47 @@ const state = {
44
45
  })(),
45
46
  };
46
47
 
48
+ let mobileBarCleanup = null;
49
+
50
+ function clearMobileBarLayout() {
51
+ if (typeof mobileBarCleanup === 'function') {
52
+ mobileBarCleanup();
53
+ }
54
+ mobileBarCleanup = null;
55
+ document.documentElement.style.setProperty('--mobile-bar-height', '0px');
56
+ }
57
+
58
+ function bindMobileBarLayout(mobileBar) {
59
+ clearMobileBarLayout();
60
+ if (!mobileBar) return;
61
+
62
+ const media = window.matchMedia('(max-width: 640px)');
63
+ const root = document.documentElement;
64
+ const update = () => {
65
+ if (!media.matches || !mobileBar.isConnected) {
66
+ root.style.setProperty('--mobile-bar-height', '0px');
67
+ return;
68
+ }
69
+ const h = Math.ceil(mobileBar.getBoundingClientRect().height);
70
+ root.style.setProperty('--mobile-bar-height', `${h}px`);
71
+ };
72
+
73
+ const onResize = () => update();
74
+ window.addEventListener('resize', onResize);
75
+
76
+ let observer = null;
77
+ if (typeof ResizeObserver !== 'undefined') {
78
+ observer = new ResizeObserver(update);
79
+ observer.observe(mobileBar);
80
+ }
81
+
82
+ requestAnimationFrame(update);
83
+ mobileBarCleanup = () => {
84
+ window.removeEventListener('resize', onResize);
85
+ if (observer) observer.disconnect();
86
+ };
87
+ }
88
+
47
89
  function buildSearchHash(query, category = 'web') {
48
90
  const params = new URLSearchParams();
49
91
  if (query) params.set('q', query);
@@ -443,32 +485,41 @@ function renderAiPanel() {
443
485
  const isDone = state.aiStatus === 'done';
444
486
  const isError = state.aiStatus === 'error';
445
487
  const dotsClass = isDone ? 'done' : isError ? 'error' : '';
446
- const statusText = state.aiStatus === 'loading' ? 'Thinking…'
447
- : state.aiStatus === 'streaming' ? 'Generating…'
448
- : isDone ? 'AI Summary' : 'Error';
449
488
 
450
- // Dots
489
+ // Row 1: dots + "AI" (always) + model + latency
451
490
  const dotsEl = el('div', { className: 'ai-dots' });
452
491
  ['violet', 'indigo', 'dim'].forEach(c => {
453
492
  dotsEl.append(el('div', { className: `ai-dot ${dotsClass || c}` }));
454
493
  });
455
-
456
- // Latency
457
494
  const latMs = state.aiLatencyMs;
458
495
  const latLabel = latMs != null ? (latMs < 1000 ? `${latMs}ms` : `${(latMs / 1000).toFixed(1)}s`) : null;
459
-
460
- // Header
461
- const headerLeft = el('div', { className: 'panel-header-left' },
496
+ const row1 = el('div', { className: 'panel-header-left' },
462
497
  dotsEl,
463
- el('span', { className: 'panel-label' }, statusText),
498
+ el('span', { className: 'panel-label' }, 'AI'),
464
499
  state.aiMeta?.model ? el('span', { className: 'ai-model-label' }, state.aiMeta.model) : null,
465
500
  latLabel ? el('span', { className: 'ai-latency-label' }, `· ${latLabel}`) : null,
466
501
  );
502
+
503
+ // Row 2: status text (violet) + step/fetch meta
504
+ const statusText = isError ? 'Error'
505
+ : isDone ? 'Done'
506
+ : state.aiStatus === 'loading' ? 'Thinking…' : 'Generating…';
507
+ const statusColor = isError ? '#f87171' : isDone ? '#a78bfa' : '#a78bfa';
508
+ const lastStep = state.aiSteps.length > 0 ? state.aiSteps[state.aiSteps.length - 1] : null;
509
+ const metaText = isDone && state.aiMeta?.fetchedCount ? `· ${state.aiMeta.fetchedCount} pages read`
510
+ : isLoading && lastStep ? `· ${lastStep}` : '';
511
+ const row2 = el('div', { className: 'ai-status-row' },
512
+ el('span', { className: 'ai-status-text', style: `color:${statusColor}` }, statusText),
513
+ metaText ? el('span', { className: 'ai-status-meta' }, metaText) : null,
514
+ );
515
+
467
516
  const chevronPath = state.aiExpanded ? '<polyline points="18 15 12 9 6 15"/>' : '<polyline points="6 9 12 15 18 9"/>';
468
517
  const expandBtn = el('button', { className: 'ai-expand-btn', type: 'button', title: state.aiExpanded ? 'Collapse' : 'Expand' });
469
518
  expandBtn.innerHTML = svg(chevronPath, 14);
470
519
  expandBtn.onclick = () => { state.aiExpanded = !state.aiExpanded; renderAiPanel(); };
471
- const header = el('div', { className: 'panel-header' }, headerLeft, expandBtn);
520
+
521
+ const headerInner = el('div', { className: 'ai-header-inner' }, row1, row2);
522
+ const header = el('div', { className: 'panel-header' }, headerInner, expandBtn);
472
523
 
473
524
  // Progress bar
474
525
  const showProgress = isLoading && state.aiProgress > 0;
@@ -509,6 +560,19 @@ function renderAiPanel() {
509
560
  }),
510
561
  ) : null;
511
562
 
563
+ // Session memory (shown when expanded + more than 1 entry, all except current)
564
+ const prevSession = state.aiSession.slice(0, -1);
565
+ const sessionEl = (state.aiExpanded && prevSession.length > 0) ? el('div', { className: 'ai-session' },
566
+ el('p', { className: 'ai-session-label' }, 'Session'),
567
+ ...prevSession.map((item, i) =>
568
+ el('div', { className: 'ai-session-item' },
569
+ el('span', { className: 'ai-session-num' }, `${i + 1}.`),
570
+ el('span', { className: 'ai-session-q' }, item.q),
571
+ el('span', { className: 'ai-session-r' }, `→ ${item.r}`),
572
+ )
573
+ ),
574
+ ) : null;
575
+
512
576
  // Footer: retry + expand/collapse
513
577
  const retryBtn = el('button', { className: 'ai-retry-btn', type: 'button' }, 'Retry');
514
578
  retryBtn.onclick = () => {
@@ -526,6 +590,7 @@ function renderAiPanel() {
526
590
  if (stepsEl) panel.append(stepsEl);
527
591
  panel.append(contentEl);
528
592
  if (sourcesEl) panel.append(sourcesEl);
593
+ if (sessionEl) panel.append(sessionEl);
529
594
  panel.append(footer);
530
595
 
531
596
  if (state.aiStatus === 'streaming' && state.aiSummary.length < 60) {
@@ -887,7 +952,7 @@ async function startAiSummary(query, results, lang) {
887
952
  const r = await fetch('/api/ai-summary', {
888
953
  method: 'POST',
889
954
  headers: { 'Content-Type': 'application/json' },
890
- body: JSON.stringify({ query, lang, results: results.slice(0, 10), stream: true }),
955
+ body: JSON.stringify({ query, lang, results: results.slice(0, 10), stream: true, session: state.aiSession }),
891
956
  });
892
957
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
893
958
 
@@ -917,6 +982,12 @@ async function startAiSummary(query, results, lang) {
917
982
  state.aiSources = Array.isArray(d.sites) ? d.sites.map(sanitizeHttpUrl).filter(Boolean) : [];
918
983
  state.aiMeta = { fetchedCount: d.fetchedCount, model: d.model };
919
984
  state.aiLatencyMs = Date.now() - state.aiStartTime;
985
+ // Save to session memory (max 4 entries, skip if already saved for this query)
986
+ const lastEntry = state.aiSession[state.aiSession.length - 1];
987
+ if (state.aiSummary && (!lastEntry || lastEntry.q !== query)) {
988
+ const r = state.aiSummary.split(/[.!?\n]/)[0].slice(0, 100);
989
+ state.aiSession = [...state.aiSession.slice(-3), { q: query, r }];
990
+ }
920
991
  renderAiPanel();
921
992
  }
922
993
  } catch { /* ignore */ }
@@ -937,12 +1008,13 @@ function renderApp() {
937
1008
  app.innerHTML = '';
938
1009
 
939
1010
  if (!state.query) {
1011
+ clearMobileBarLayout();
940
1012
  renderHome(app);
941
1013
  return;
942
1014
  }
943
1015
 
944
1016
  // Results page
945
- const header = el('div', { className: 'header' },
1017
+ const header = el('div', { className: 'header hide-mobile' },
946
1018
  el('div', { className: 'logo-text', onClick: () => { state.query = ''; state.category = 'web'; navigate('#/'); renderApp(); } },
947
1019
  'Term', el('strong', {}, 'Search'),
948
1020
  ),
@@ -953,26 +1025,47 @@ function renderApp() {
953
1025
  el('button', { className: 'btn-icon', title: 'Toggle theme', onClick: toggleTheme }, iconEl('theme')),
954
1026
  ),
955
1027
  );
956
- const categoryBar = el('div', { className: 'category-tabs' });
1028
+
1029
+ const categoryBar = el('div', { className: 'category-tabs hide-mobile' });
957
1030
  const categories = [
958
1031
  { id: 'web', label: 'Web' },
959
1032
  { id: 'images', label: 'Images' },
960
1033
  { id: 'news', label: 'News' },
961
1034
  ];
962
- categories.forEach((cat) => {
963
- categoryBar.append(el('button', {
964
- className: `cat-tab ${state.category === cat.id ? 'active' : ''}`,
965
- onClick: () => {
966
- if (state.category === cat.id) return;
967
- state.category = cat.id;
968
- navigate(buildSearchHash(state.query, state.category));
969
- if (state.query) doSearch(state.query, state.category);
970
- },
971
- type: 'button',
972
- }, cat.label));
973
- });
1035
+ const buildCatTabs = (container) => {
1036
+ categories.forEach((cat) => {
1037
+ container.append(el('button', {
1038
+ className: `cat-tab ${state.category === cat.id ? 'active' : ''}`,
1039
+ onClick: () => {
1040
+ if (state.category === cat.id) return;
1041
+ state.category = cat.id;
1042
+ navigate(buildSearchHash(state.query, state.category));
1043
+ if (state.query) doSearch(state.query, state.category);
1044
+ },
1045
+ type: 'button',
1046
+ }, cat.label));
1047
+ });
1048
+ };
1049
+ buildCatTabs(categoryBar);
974
1050
  categoryBar.append(EnginePicker());
975
1051
 
1052
+ const mobileTabs = el('div', { className: 'mobile-bar-tabs' });
1053
+ buildCatTabs(mobileTabs);
1054
+ const mobileBar = el('div', { className: 'mobile-bar' },
1055
+ el('div', { className: 'mobile-bar-search' }, SearchForm(state.query, (q, cat) => { state.query = q; doSearch(q, cat); })),
1056
+ mobileTabs,
1057
+ el('div', { className: 'mobile-bar-engine' }, EnginePicker()),
1058
+ el('div', { className: 'mobile-bar-row' },
1059
+ el('div', {
1060
+ className: 'mobile-logo',
1061
+ onClick: () => { state.query = ''; state.category = 'web'; navigate('#/'); renderApp(); },
1062
+ }, 'Term', el('strong', {}, 'Search')),
1063
+ LangPicker(),
1064
+ el('button', { className: 'btn-icon', title: 'Settings', onClick: () => navigate('#/settings') }, iconEl('settings')),
1065
+ el('button', { className: 'btn-icon', title: 'Toggle theme', onClick: toggleTheme }, iconEl('theme')),
1066
+ ),
1067
+ );
1068
+
976
1069
  const main = el('div', { className: 'main' });
977
1070
 
978
1071
  // AI panel placeholder
@@ -1016,7 +1109,8 @@ function renderApp() {
1016
1109
  if (socPanel) main.append(socPanel);
1017
1110
  }
1018
1111
 
1019
- app.append(header, categoryBar, main);
1112
+ app.append(header, categoryBar, main, mobileBar);
1113
+ bindMobileBarLayout(mobileBar);
1020
1114
  renderAiPanel();
1021
1115
  }
1022
1116
 
@@ -1051,6 +1145,7 @@ async function renderSettings() {
1051
1145
  const app = document.getElementById('app');
1052
1146
  if (!app) return;
1053
1147
  app.innerHTML = '';
1148
+ clearMobileBarLayout();
1054
1149
 
1055
1150
  let cfg = state.config;
1056
1151
  if (!cfg) {
@@ -13,6 +13,7 @@
13
13
  --link: #8ab4f8;
14
14
  --link-h: #aecbfa;
15
15
  --url: #34a853;
16
+ --mobile-bar-height: 0px;
16
17
  --radius: 12px;
17
18
  --radius-sm: 8px;
18
19
  --radius-xs: 6px;
@@ -565,10 +566,14 @@ a:hover { color: var(--link-h); }
565
566
  .ai-dot.dim { background: #5b21b6; animation: pulse 1.4s ease-in-out 300ms infinite; }
566
567
  .ai-dot.done { background: #34d399; animation: none; }
567
568
  .ai-dot.error { background: #f87171; animation: none; }
568
- .panel-ai .panel-header { cursor: default; }
569
+ .panel-ai .panel-header { cursor: default; align-items: flex-start; }
570
+ .ai-header-inner { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
571
+ .ai-status-row { display: flex; align-items: center; gap: 6px; }
572
+ .ai-status-text { font-size: 14px; font-weight: 500; }
573
+ .ai-status-meta { font-size: 10px; color: #4b5563; }
569
574
  .ai-model-label { font-size: 10px; color: var(--text3); margin-left: 6px; }
570
575
  .ai-latency-label{ font-size: 10px; color: #4b5563; margin-left: 4px; }
571
- .ai-expand-btn { background: none; border: none; color: var(--text3); cursor: pointer; padding: 0; margin-left: auto; display: flex; align-items: center; }
576
+ .ai-expand-btn { background: none; border: none; color: var(--text3); cursor: pointer; padding: 0; margin-left: 8px; margin-top: 2px; display: flex; align-items: center; flex-shrink: 0; }
572
577
  .ai-expand-btn:hover { color: var(--text2); }
573
578
 
574
579
  /* Progress bar */
@@ -611,6 +616,14 @@ a:hover { color: var(--link-h); }
611
616
  }
612
617
  .ai-source-pill:hover { color: #a78bfa; border-color: #4c1d95; }
613
618
 
619
+ /* Session memory */
620
+ .ai-session { margin-top: 10px; padding-top: 8px; border-top: 1px solid #1b1b2d; }
621
+ .ai-session-label { font-size: 10px; color: #374151; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.05em; }
622
+ .ai-session-item { display: flex; align-items: baseline; gap: 5px; margin-bottom: 3px; flex-wrap: wrap; }
623
+ .ai-session-num { font-size: 9px; color: #374151; flex-shrink: 0; }
624
+ .ai-session-q { font-size: 10px; color: #4b5563; }
625
+ .ai-session-r { font-size: 10px; color: #374151; }
626
+
614
627
  /* Footer */
615
628
  .ai-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; }
616
629
  .ai-retry-btn { font-size: 12px; color: #a78bfa; background: none; border: none; cursor: pointer; padding: 0; }
@@ -904,14 +917,70 @@ a:hover { color: var(--link-h); }
904
917
  }
905
918
  .footer-link:hover { color: var(--text2); }
906
919
 
920
+ /* ─── Mobile bottom bar ───────────────────────────────────────────────────── */
921
+ .mobile-bar {
922
+ display: none;
923
+ }
924
+ .mobile-logo {
925
+ font-size: 15px;
926
+ font-weight: 300;
927
+ letter-spacing: -0.2px;
928
+ color: var(--text);
929
+ white-space: nowrap;
930
+ user-select: none;
931
+ cursor: pointer;
932
+ }
933
+ .mobile-logo strong {
934
+ font-weight: 700;
935
+ background: linear-gradient(90deg, #a78bfa, #818cf8);
936
+ -webkit-background-clip: text;
937
+ -webkit-text-fill-color: transparent;
938
+ background-clip: text;
939
+ }
940
+ @media (max-width: 640px) {
941
+ .mobile-bar {
942
+ display: flex;
943
+ flex-direction: column;
944
+ position: fixed;
945
+ bottom: 0; left: 0; right: 0;
946
+ background: var(--bg);
947
+ border-top: 1px solid var(--border2);
948
+ box-shadow: 0 -12px 28px rgba(0,0,0,0.45);
949
+ z-index: 220;
950
+ padding-bottom: max(6px, env(safe-area-inset-bottom, 0px));
951
+ transform: translateZ(0);
952
+ }
953
+ }
954
+ .mobile-bar-search { padding: 8px 12px 4px; }
955
+ .mobile-bar-search .search-form { width: 100%; }
956
+ .mobile-bar-tabs {
957
+ display: flex;
958
+ align-items: center;
959
+ gap: 4px;
960
+ padding: 2px 12px 4px;
961
+ overflow-x: auto;
962
+ scrollbar-width: none;
963
+ }
964
+ .mobile-bar-tabs::-webkit-scrollbar { display: none; }
965
+ .mobile-bar-engine { padding: 0 12px 4px; }
966
+ .mobile-bar-row {
967
+ display: flex;
968
+ align-items: center;
969
+ gap: 6px;
970
+ padding: 4px 12px 2px;
971
+ }
972
+ .mobile-bar-row .lang-wrap { margin-left: auto; }
973
+
907
974
  /* ─── Responsive ──────────────────────────────────────────────────────────── */
908
975
  @media (max-width: 640px) {
909
- .header { padding: 8px 12px; gap: 8px; }
910
- .category-tabs { top: 48px; padding: 7px 12px 9px; gap: 5px; flex-wrap: wrap; }
976
+ /* Hide desktop header + category bar mobile uses bottom bar */
977
+ .header.hide-mobile, .category-tabs.hide-mobile { display: none !important; }
978
+ .main { padding-bottom: calc(20px + var(--mobile-bar-height, 0px) + env(safe-area-inset-bottom, 0px)); }
979
+
911
980
  .cat-tab { font-size: 10px; padding: 4px 8px; }
912
- .engine-picker { width: 100%; margin-left: 0; }
913
- .engine-picker-summary { width: 100%; justify-content: space-between; }
914
- .engine-picker-body { width: calc(100vw - 24px); right: -6px; }
981
+ .mobile-bar-engine .engine-picker { width: 100%; margin-left: 0; }
982
+ .mobile-bar-engine .engine-picker-summary { width: 100%; justify-content: space-between; }
983
+ .engine-picker-body { width: calc(100vw - 24px); right: -6px; bottom: calc(100% + 8px); top: auto; }
915
984
  .logo-text { font-size: 15px; }
916
985
  .home-logo { font-size: 40px; }
917
986
  .home-tagline { letter-spacing: 0.08em; margin-bottom: 20px; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termsearch",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Personal search engine for Termux/Linux/macOS — zero-config, privacy-first, AI-optional",
5
5
  "type": "module",
6
6
  "bin": {