nio-cat 0.9.7 → 0.9.9

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.
package/web/app/notes.js CHANGED
@@ -15,6 +15,7 @@ const I = {
15
15
  tag: '<path d="M4 4h7.500l8.500 8.500-7.500 7.500L4 11.500z"/><circle cx="8.500" cy="8.500" r="1.200"/>',
16
16
  spark: '<path d="M12 3.500l1.800 5.200 5.200 1.800-5.200 1.800L12 17.500l-1.800-5.200L5 10.500l5.200-1.800zM18.500 16l.7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7z"/>',
17
17
  close: '<path d="M6 6l12 12M18 6L6 18"/>',
18
+ back: '<path d="m10 5-7 7 7 7M3 12h18"/>',
18
19
  plus: '<path d="M12 5v14M5 12h14"/>',
19
20
  search: '<circle cx="11" cy="11" r="6.500"/><path d="M16 16l4 4"/>',
20
21
  grid: '<rect x="4" y="4" width="7" height="7" rx="1.500"/><rect x="13" y="4" width="7" height="7" rx="1.500"/><rect x="4" y="13" width="7" height="7" rx="1.500"/><rect x="13" y="13" width="7" height="7" rx="1.500"/>',
@@ -23,6 +24,7 @@ const I = {
23
24
  pause: '<path d="M8 5.500v13M16 5.500v13"/>',
24
25
  phone: '<rect x="7" y="3" width="10" height="18" rx="2.500"/><path d="M11 18h2"/>',
25
26
  check: '<path d="M5 12.500l4.500 4.500L19 7.500"/>',
27
+ more: '<circle cx="6" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="18" cy="12" r="1.5"/>',
26
28
  };
27
29
  const COLORS = [['', 'Sense color'], ['lemon', 'Llimona'], ['peach', 'Préssec'], ['moss', 'Molsa'], ['sky', 'Cel'], ['lilac', 'Lila'], ['rose', 'Rosa'], ['sand', 'Sorra']];
28
30
  const ACTIONS = [
@@ -46,6 +48,15 @@ export function createNotes(ctx) {
46
48
  let root = null, notes = new Map(), loaded = false, loading = false, engines = null;
47
49
  let view = 'notes', q = '', layout = LSget('nio.notes.layout', 'grid');
48
50
  let ed = null, aborter = null;
51
+ const historyNotes = new Map();
52
+ const pendingSaves = new Set();
53
+ let loadError = '';
54
+ const machineKey = () => ctx.machine()?.id || ctx.machine()?.url;
55
+ let notesMachine = machineKey();
56
+ addEventListener('beforeunload', (event) => {
57
+ if (!pendingSaves.size && !ed?.rec) return;
58
+ event.preventDefault(); event.returnValue = '';
59
+ });
49
60
 
50
61
  // ---------------------------------------------------------------- dades
51
62
  const all = () => [...notes.values()];
@@ -65,8 +76,19 @@ export function createNotes(ctx) {
65
76
  }
66
77
  async function load() {
67
78
  if (loading) return; loading = true;
68
- try { const r = await api('/api/notes'); notes = new Map(r.notes.map((n) => [n.id, n])); loaded = true; }
69
- catch (e) { if (root) root.querySelector('#ntGrid').innerHTML = `<p class="nt-empty">${esc(e.message)}</p>`; }
79
+ const requestedMachine = machineKey();
80
+ loadError = '';
81
+ try {
82
+ const r = await api('/api/notes');
83
+ if (requestedMachine !== machineKey()) { loading = false; if (root) load(); return; }
84
+ notes = new Map(r.notes.map((n) => [n.id, n])); loaded = true;
85
+ for (const entry of pendingSaves) if (entry.id && entry.machineKey === machineKey()) notes.set(entry.id, entry.n);
86
+ if (ed?.id) notes.set(ed.id, ed.n);
87
+ }
88
+ catch (e) {
89
+ if (requestedMachine !== machineKey()) { loading = false; if (root) load(); return; }
90
+ loadError = e.message;
91
+ }
70
92
  loading = false; drawAll();
71
93
  }
72
94
  const upsert = (n) => { const old = notes.get(n.id); notes.set(n.id, n); return old; };
@@ -74,35 +96,52 @@ export function createNotes(ctx) {
74
96
  // ---------------------------------------------------------------- pàgina
75
97
  function mount(el) {
76
98
  root = el; aborter?.abort(); aborter = new AbortController();
99
+ if (notesMachine !== machineKey()) { notesMachine = machineKey(); notes.clear(); loaded = false; engines = null; q = ''; view = 'notes'; }
100
+ if (history.state?.nioNote) { const { nioNote, ...rest } = history.state; history.replaceState(rest, '', location.href); }
77
101
  root.innerHTML = `<div class="nt">
78
102
  <div class="nt-top">
79
103
  <div class="nt-title"><h1>Notes</h1><p>Escriu, grava i deixa que un model ho ordeni. Es desa a l’ordinador.</p></div>
80
- <div class="nt-tools"><button class="btn" type="button" data-nt="phone">${svg(I.phone)}<span>Mòbil com a micròfon</span></button></div>
104
+ ${ctx.isPhone() ? '' : `<div class="nt-tools"><button class="btn" type="button" data-nt="phone">${svg(I.phone)}<span>Mòbil com a micròfon</span></button></div>`}
81
105
  </div>
82
106
  <div class="nt-bar">
83
107
  <label class="nt-search">${svg(I.search)}<input type="search" id="ntQ" placeholder="Busca a les notes" autocomplete="off" aria-label="Busca a les notes"></label>
84
108
  <button class="icon-btn" type="button" data-nt="layout" aria-label="Canvia la vista" id="ntLayout"></button>
85
109
  </div>
86
- <div class="nt-new" id="ntNew" role="button" tabindex="0" data-nt="new"><span>Escriu una nota…</span>
110
+ <div class="nt-new" id="ntNew"><button class="nt-write" type="button" data-nt="new">${svg(I.plus)}<span>Escriu una nota…</span></button>
87
111
  <span class="nt-new-b"><button type="button" data-nt="newlist" aria-label="Nova llista" title="Nova llista">${svg(I.list)}</button><button type="button" data-nt="newmic" aria-label="Grava una nota" title="Grava una nota">${svg(I.mic)}</button></span></div>
88
112
  <nav class="nt-filters" id="ntFilters" aria-label="Filtres"></nav>
113
+ <div id="ntPending" aria-live="polite"></div>
114
+ <p class="nt-results" id="ntResults" aria-live="polite"></p>
89
115
  <div id="ntGrid" class="nt-body"></div>
90
116
  </div>`;
91
117
  const sig = { signal: aborter.signal };
118
+ $('#ntQ', root).value = q;
92
119
  $('#ntQ', root).addEventListener('input', (e) => { q = e.target.value; drawGrid(); }, sig);
93
120
  root.addEventListener('click', onClick, sig);
94
- root.addEventListener('keydown', (e) => { if ((e.key === 'Enter' || e.key === ' ') && e.target.matches?.('.nt-card, #ntNew')) { e.preventDefault(); e.target.click(); } }, sig);
121
+ bindSwipe($('#ntGrid', root), sig);
122
+ root.addEventListener('keydown', (e) => { if ((e.key === 'Enter' || e.key === ' ') && e.target.matches?.('.nt-card')) { e.preventDefault(); e.target.click(); } }, sig);
123
+ addEventListener('popstate', () => {
124
+ if (ed && !history.state?.nioNote) closeEditor(false, true);
125
+ else if (!ed && history.state?.nioNote && location.hash.startsWith('#/notes')) {
126
+ const entry = history.state.nioNote;
127
+ openEditor(historyNotes.get(entry.key) || entry.id, null, { fromHistory: true, historyKey: entry.key });
128
+ }
129
+ }, sig);
95
130
  drawAll();
96
- load();
97
- if (/[?&]mic=1/.test(location.hash)) setTimeout(() => openEditor(null, $('#ntNew', root), { mic: true }), 250);
131
+ load().then(schedule);
132
+ if (/[?&]mic=1/.test(location.hash)) {
133
+ history.replaceState(history.state, '', location.hash.replace(/([?&])mic=1(&?)/, (_, sep, next) => next ? sep : ''));
134
+ toast('Per gravar una nota, prem el botó del micròfon.');
135
+ }
98
136
  }
99
- function unmount() { aborter?.abort(); closeEditor(true); root = null; }
137
+ function unmount() { aborter?.abort(); clearTimeout(set.t); clearTimeout(pollT); closeEditor(true); root = null; }
100
138
  const drawAll = () => { if (!root) return; drawLayout(); drawFilters(); drawGrid(); };
101
139
  function drawLayout() { const b = $('#ntLayout', root); if (b) b.innerHTML = svg(layout === 'grid' ? I.rows : I.grid); root.querySelector('.nt').dataset.layout = layout; }
102
140
  function drawFilters() {
141
+ if (!root) return;
103
142
  const labs = labelsOf(), nTrash = all().filter((n) => n.trashed).length;
104
143
  const chip = (v, label, extra = '') => `<button type="button" class="nt-chip" data-nt="view" data-v="${esc(v)}" ${view === v ? 'aria-current="true"' : ''}>${extra}${esc(label)}</button>`;
105
- $('#ntFilters', root).innerHTML = chip('notes', 'Notes') + labs.map((l) => chip(`l:${l}`, l, svg(I.tag))).join('') + chip('archive', 'Arxivades', svg(I.archive)) + chip('trash', nTrash ? `Paperera · ${nTrash}` : 'Paperera', svg(I.trash));
144
+ $('#ntFilters', root).innerHTML = `<div class="nt-filter-views">${chip('notes', 'Notes')}${chip('archive', 'Arxivades', svg(I.archive))}${chip('trash', nTrash ? `Paperera · ${nTrash}` : 'Paperera', svg(I.trash))}</div>${labs.length ? `<div class="nt-filter-labels"><span>Etiquetes</span>${labs.map((l) => chip(`l:${l}`, l, svg(I.tag))).join('')}</div>` : ''}`;
106
145
  }
107
146
  function cardHtml(n) {
108
147
  const items = n.items || [];
@@ -123,9 +162,14 @@ export function createNotes(ctx) {
123
162
  </article>`;
124
163
  }
125
164
  function drawGrid() {
165
+ if (!root) return;
166
+ if (swipe?.card) { swipe.redraw = true; return; } // no es toca la llista amb una targeta sota el dit
126
167
  const g = $('#ntGrid', root); if (!g) return;
168
+ $('#ntPending', root).innerHTML = [...pendingSaves].some((entry) => entry.error && entry.machineKey === machineKey()) ? '<div class="nt-warning"><span>Hi ha canvis pendents de desar. Mantén aquesta pàgina oberta.</span><button class="btn sm" type="button" data-nt="retry-save">Torna a desar</button></div>' : '';
169
+ if (loadError) { g.innerHTML = `<div class="nt-empty" role="alert"><p>${esc(loadError)}</p><button class="btn" type="button" data-nt="reload">Torna-ho a provar</button></div>`; return; }
127
170
  if (!loaded) { g.innerHTML = '<div class="nt-grid"><i class="nt-skel"></i><i class="nt-skel tall"></i><i class="nt-skel"></i></div>'; return; }
128
171
  const list = visible();
172
+ $('#ntResults', root).textContent = `${list.length} ${list.length === 1 ? 'nota' : 'notes'}${q ? ` per a «${q}»` : ''}`;
129
173
  if (!list.length) {
130
174
  g.innerHTML = `<p class="nt-empty">${q ? 'Cap nota coincideix amb la cerca.' : view === 'trash' ? 'La paperera és buida. Les notes s’hi esborren soles als 30 dies.' : view === 'archive' ? 'No hi ha notes arxivades.' : view.startsWith('l:') ? 'Cap nota amb aquesta etiqueta.' : 'Encara no hi ha cap nota. Escriu-ne una o grava-la amb la veu.'}</p>`;
131
175
  return;
@@ -138,8 +182,15 @@ export function createNotes(ctx) {
138
182
 
139
183
  // ---------------------------------------------------------------- clics de la pàgina
140
184
  async function onClick(e) {
185
+ if (Date.now() - swipedAt < 400) { e.preventDefault(); e.stopPropagation(); return; }
141
186
  const t = e.target.closest('[data-nt]'), card = e.target.closest('.nt-card');
142
187
  const act = t?.dataset.nt, n = card ? notes.get(card.dataset.id) : null;
188
+ if (act === 'reload') return load();
189
+ if (act === 'retry-save') {
190
+ t.disabled = true;
191
+ await Promise.all([...pendingSaves].filter((entry) => entry.machineKey === machineKey()).map((entry) => flush(entry)));
192
+ drawGrid(); return;
193
+ }
143
194
  if (act === 'view') { view = t.dataset.v; drawFilters(); drawGrid(); return; }
144
195
  if (act === 'layout') { layout = layout === 'grid' ? 'list' : 'grid'; LSset('nio.notes.layout', layout); drawLayout(); return; }
145
196
  if (act === 'phone') return phoneSheet();
@@ -158,6 +209,55 @@ export function createNotes(ctx) {
158
209
  }
159
210
  if (card && n) return openEditor(n.id, card);
160
211
  }
212
+ // Lliscar una targeta cap a qualsevol costat l'arxiva (o la desarxiva a «Arxivades»), com a Keep.
213
+ // Segueix el dit 1:1; si passa d'un terç de l'amplada o es llença ràpid, marxa; si no, torna al lloc.
214
+ let swipe = null, swipedAt = 0;
215
+ function bindSwipe(grid, sig) {
216
+ const reset = (card) => { card.style.transition = ''; card.style.transform = ''; card.style.opacity = ''; card.classList.remove('swiping'); };
217
+ grid.addEventListener('pointerdown', (e) => {
218
+ const card = e.target.closest('.nt-card');
219
+ if (!card || e.pointerType === 'mouse' || !e.isPrimary || view === 'trash' || swipe) return;
220
+ swipe = { card, id: card.dataset.id, x0: e.clientX, y0: e.clientY, dx: 0, lock: null, pid: e.pointerId, pts: [[e.timeStamp, e.clientX]] };
221
+ }, sig);
222
+ grid.addEventListener('pointermove', (e) => {
223
+ const g = swipe; if (!g || e.pointerId !== g.pid) return;
224
+ const dx = e.clientX - g.x0, dy = e.clientY - g.y0;
225
+ if (!g.lock) {
226
+ if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return;
227
+ if (Math.abs(dx) > Math.abs(dy) * 1.2) { g.lock = 'x'; g.card.setPointerCapture?.(e.pointerId); g.card.classList.add('swiping'); g.card.style.transition = 'none'; g.w = g.card.offsetWidth; }
228
+ else { swipe = null; return; }
229
+ }
230
+ g.dx = dx; g.pts.push([e.timeStamp, e.clientX]); if (g.pts.length > 5) g.pts.shift();
231
+ g.card.style.transform = `translateX(${dx}px) rotate(${dx / g.w * 3}deg)`;
232
+ g.card.style.opacity = String(Math.max(0.15, 1 - Math.abs(dx) / (g.w * 1.1)));
233
+ }, sig);
234
+ const end = (e) => {
235
+ const g = swipe; if (!g || e.pointerId !== g.pid) return;
236
+ swipe = null;
237
+ if (g.lock !== 'x') return;
238
+ swipedAt = Date.now();
239
+ const [t0, x0] = g.pts[0], [t1, x1] = g.pts[g.pts.length - 1], v = (x1 - x0) / Math.max(1, t1 - t0);
240
+ const n = notes.get(g.id), dir = Math.sign(Math.abs(v) > 0.45 ? v : g.dx);
241
+ const go = n && e.type === 'pointerup' && (Math.abs(g.dx) > g.w * 0.33 || (Math.abs(v) > 0.45 && Math.sign(v) === Math.sign(g.dx) && Math.abs(g.dx) > 24));
242
+ if (!go) {
243
+ g.card.style.transition = 'transform .32s cubic-bezier(.22,1,.36,1), opacity .2s';
244
+ g.card.style.transform = ''; g.card.style.opacity = '';
245
+ setTimeout(() => { reset(g.card); if (g.redraw) drawGrid(); }, 330);
246
+ return;
247
+ }
248
+ navigator.vibrate?.(8);
249
+ const was = { archived: n.archived, pinned: n.pinned };
250
+ const fly = reduce ? 0 : 200;
251
+ g.card.style.transition = `transform ${fly}ms cubic-bezier(.4,0,1,1), opacity ${fly}ms`;
252
+ g.card.style.transform = `translateX(${dir * (g.w + 60)}px) rotate(${dir * 6}deg)`; g.card.style.opacity = '0';
253
+ setTimeout(() => {
254
+ patch(n, { archived: !was.archived, pinned: false });
255
+ toastUndo(was.archived ? 'Desarxivada' : 'Arxivada', () => patch(n, was));
256
+ }, fly);
257
+ };
258
+ grid.addEventListener('pointerup', end, sig);
259
+ grid.addEventListener('pointercancel', end, sig);
260
+ }
161
261
  // La targeta s'esvaeix abans de marxar de la llista; el toast permet desfer-ho.
162
262
  function leave(card, fn, msg, undo) {
163
263
  if (!reduce) { card.style.transition = 'opacity .16s, transform .16s'; card.style.opacity = '0'; card.style.transform = 'scale(.96)'; }
@@ -171,9 +271,11 @@ export function createNotes(ctx) {
171
271
  b.onclick = (ev) => { ev.stopPropagation(); undo(); t.remove(); };
172
272
  t.append(' ', b);
173
273
  }
274
+ let inflight = 0;
174
275
  async function patch(n, fields) {
175
276
  Object.assign(n, fields); notes.set(n.id, n); drawAll();
176
- try { upsert(await api(`/api/notes/${n.id}`, { method: 'PATCH', body: fields })); } catch (e) { toast(e.message, 4200); load(); }
277
+ inflight++;
278
+ try { upsert(await api(`/api/notes/${n.id}`, { method: 'PATCH', body: fields })); } catch (e) { toast(e.message, 4200); load(); } finally { inflight--; }
177
279
  }
178
280
 
179
281
  // ---------------------------------------------------------------- esdeveniments en directe (del flux d'nio)
@@ -182,30 +284,69 @@ export function createNotes(ctx) {
182
284
  const n = msg.note, old = upsert(n); void old;
183
285
  if (ed && ed.id === n.id) {
184
286
  // Del servidor només entra el que no estem editant ara mateix: l'àudio, la transcripció i els canvis d'altres dispositius.
185
- const keep = new Set(Object.keys(ed.dirty));
287
+ const keep = new Set([...Object.keys(ed.dirty), ...Object.keys(ed.savingFields)]);
186
288
  const mine = ed.n; for (const k of Object.keys(n)) if (!keep.has(k)) mine[k] = n[k];
187
289
  ed.n = mine; notes.set(n.id, mine); syncEditor();
188
290
  }
189
291
  if (root) { drawFilters(); drawGrid(); }
292
+ if (n.audio?.some((a) => a.state === 'transcribing')) schedule();
190
293
  }
191
294
  const reload = () => { if (root || loaded) load(); };
192
295
 
296
+ // Per si el flux en directe s'ha perdut (mòbil en segon pla, túnel que talla): es consulta l'ordinador
297
+ // cada poc mentre hi ha àudio transcrivint-se i, si no, de tant en tant. Només s'aplica el que ha canviat.
298
+ let pollT = 0, polling = false;
299
+ const busy = () => all().some((n) => n.audio.some((a) => a.state === 'transcribing')) || !!ed?.uploading;
300
+ async function refresh() {
301
+ if (polling || inflight || loading || !loaded || ctx.isDemo?.()) return; polling = true;
302
+ const mk = machineKey();
303
+ try {
304
+ const r = await api('/api/notes');
305
+ if (mk !== machineKey()) return;
306
+ const seen = new Set();
307
+ for (const n of r.notes) {
308
+ seen.add(n.id); const old = notes.get(n.id);
309
+ if (!old || old.updated !== n.updated || JSON.stringify(old.audio) !== JSON.stringify(n.audio)) onEvent({ type: 'note', note: n });
310
+ }
311
+ for (const id of [...notes.keys()]) if (!seen.has(id) && !(ed?.id === id && pendingSaves.has(ed))) onEvent({ type: 'note-gone', id });
312
+ } catch { /* ja es tornarà a provar */ } finally { polling = false; }
313
+ }
314
+ function schedule() {
315
+ clearTimeout(pollT);
316
+ if (!root && !ed) return;
317
+ pollT = setTimeout(async () => { if (!document.hidden) await refresh(); schedule(); }, busy() ? 1500 : 10000);
318
+ }
319
+ document.addEventListener('visibilitychange', () => { if (!document.hidden && (root || ed)) { refresh(); schedule(); } });
320
+ addEventListener('online', () => { if (root || ed) refresh(); });
321
+
193
322
  // ---------------------------------------------------------------- editor
194
323
  function openEditor(id, src, opts = {}) {
195
324
  if (ed) return;
196
325
  const n = id ? notes.get(id) : { id: null, title: '', text: '', items: opts.list ? [] : null, color: '', pinned: false, archived: false, trashed: 0, labels: [], audio: [] };
197
326
  if (!n) return;
198
- ed = { id, n, dirty: {}, timer: 0, chain: Promise.resolve(), rec: null, ai: null, src, pop: null };
327
+ ed = { id, n: structuredClone(n), machineKey: machineKey(), historyKey: opts.historyKey || uid(), dirty: {}, savingFields: {}, timer: 0, chain: Promise.resolve(true), rec: null, ai: null, src, pop: null };
328
+ if (!opts.fromHistory) history.pushState({ ...history.state, nioNote: { id, key: ed.historyKey } }, '', location.href);
199
329
  const ov = document.createElement('div');
200
330
  ov.className = 'nt-ov'; ov.innerHTML = `<div class="nt-ed" role="dialog" aria-modal="true" aria-label="Edita la nota" data-color="${n.color}"></div>`;
201
331
  document.body.append(ov); ed.ov = ov; ed.card = ov.firstElementChild;
202
332
  buildEditor();
203
333
  ov.addEventListener('mousedown', (e) => { if (e.target === ov) closeEditor(); });
204
- ov.addEventListener('keydown', (e) => { if (e.key === 'Escape') { if (ed.pop) closePop(); else closeEditor(); } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) closeEditor(); });
334
+ ov.addEventListener('keydown', (e) => {
335
+ if (!ed) return;
336
+ if (e.key === 'Escape') { if (ed.pop) closePop(); else closeEditor(); }
337
+ else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) closeEditor();
338
+ else if (e.key === 'Tab') {
339
+ const controls = [...ov.querySelectorAll('button, input, textarea, select, [tabindex="0"]')].filter((el) => !el.disabled && el.getClientRects().length);
340
+ const first = controls[0], last = controls.at(-1);
341
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last?.focus(); }
342
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first?.focus(); }
343
+ }
344
+ });
345
+ $('[data-e="done"]', ed.card).focus({ preventScroll: true });
205
346
  animateOpen(ed.card, src);
206
- if (!n.title && !n.text && !n.items?.length && !opts.mic) setTimeout(() => ($('.nt-ed-title', ed.card) || {}).focus?.(), 60);
347
+ if (!n.title && !n.text && !n.items?.length && !opts.mic && !ctx.isPhone()) setTimeout(() => ed && $('.nt-ed-title', ed.card)?.focus(), 60);
207
348
  if (opts.list) addItem(null);
208
- if (opts.mic) setTimeout(() => toggleRec(), 220);
349
+ if (opts.mic) toggleRec();
209
350
  }
210
351
  function animateOpen(card, src) {
211
352
  if (reduce) return;
@@ -217,8 +358,21 @@ export function createNotes(ctx) {
217
358
  card.animate([{ transform: `translate(${dx}px,${dy}px) scale(${sx},${sy})`, opacity: .6 }, { transform: 'none', opacity: 1 }], { duration: 300, easing: 'cubic-bezier(.22,1,.36,1)' });
218
359
  ed.ov.animate([{ background: 'rgba(39,34,36,0)' }, { background: 'rgba(39,34,36,.32)' }], { duration: 300, easing: 'ease-out' });
219
360
  }
220
- async function closeEditor(instant) {
361
+ async function closeEditor(instant, fromHistory = false) {
221
362
  const e = ed; if (!e) return;
363
+ if (!instant && !fromHistory && history.state?.nioNote) { history.back(); return; }
364
+ if (instant && history.state?.nioNote) { const { nioNote, ...rest } = history.state; history.replaceState(rest, '', location.href); }
365
+ if (!instant) {
366
+ if (e.closing) return;
367
+ e.closing = true;
368
+ const saved = await flush(e);
369
+ e.closing = false;
370
+ if (ed !== e) return;
371
+ if (!saved) {
372
+ if (fromHistory && root && location.hash.startsWith('#/notes')) history.pushState({ ...history.state, nioNote: { id: e.id, key: e.historyKey } }, '', location.href);
373
+ return;
374
+ }
375
+ }
222
376
  ed = null; clearTimeout(e.timer); e.rec?.cancel?.(); e.audioEl?.pause();
223
377
  const done = () => e.ov.remove();
224
378
  if (instant || reduce) done(); else {
@@ -232,36 +386,61 @@ export function createNotes(ctx) {
232
386
  await flush(e);
233
387
  if (e.id && empty(e.n)) { notes.delete(e.id); api(`/api/notes/${e.id}?forever=1`, { method: 'DELETE' }).catch(() => {}); }
234
388
  if (root) drawAll();
389
+ if (root) (e.src?.isConnected ? e.src.querySelector('button') || e.src : root.querySelector('[data-nt="new"]'))?.focus({ preventScroll: true });
235
390
  }
236
391
 
237
392
  // Desat: s'envia només el que ha canviat, en cua, mig segon després de l'última tecla.
238
393
  function set(fields) {
239
394
  Object.assign(ed.n, fields); Object.assign(ed.dirty, fields);
395
+ pendingSaves.add(ed); saveStatus(ed, 'Canvis pendents…');
240
396
  ed.card.dataset.color = ed.n.color;
241
397
  clearTimeout(ed.timer); ed.timer = setTimeout(flush, 450);
242
398
  if (ed.id) notes.set(ed.id, ed.n);
243
399
  if (root) { clearTimeout(set.t); set.t = setTimeout(() => { drawFilters(); drawGrid(); }, 200); }
244
400
  }
245
401
  function flush(e = ed, force = false) {
246
- if (!e) return Promise.resolve();
402
+ if (!e) return Promise.resolve(true);
247
403
  clearTimeout(e.timer);
248
404
  e.chain = e.chain.then(async () => {
249
- const fields = e.dirty; e.dirty = {};
405
+ if (e.machineKey !== machineKey()) {
406
+ if ((!e.id && empty(e.n)) || (e.id && !Object.keys(e.dirty).length && !e.error)) return true;
407
+ e.error = true; pendingSaves.add(e); return false;
408
+ }
409
+ const fields = e.dirty; e.dirty = {}; e.savingFields = fields;
410
+ if (e.id && !Object.keys(fields).length) return !e.error;
411
+ saveStatus(e, 'Desant…');
250
412
  try {
251
413
  if (!e.id) {
252
- if (!force && empty(e.n)) { e.dirty = { ...fields, ...e.dirty }; return; } // una nota buida no es desa
414
+ if (!force && empty(e.n)) { pendingSaves.delete(e); e.savingFields = {}; saveStatus(e, 'Nota buida'); return true; }
253
415
  const { audio, id, ...body } = e.n; void audio; void id;
254
416
  const r = await api('/api/notes', { method: 'POST', body }); e.id = r.id; Object.assign(e.n, { id: r.id, audio: r.audio, created: r.created, updated: r.updated }); notes.set(r.id, e.n);
255
- } else if (Object.keys(fields).length) { const r = await api(`/api/notes/${e.id}`, { method: 'PATCH', body: fields }); void r; }
256
- } catch (er) { Object.assign(fields, e.dirty); e.dirty = fields; toast(`No s’ha pogut desar: ${er.message}`, 4500); }
417
+ historyNotes.set(e.historyKey, r.id);
418
+ if (ed === e && history.state?.nioNote) history.replaceState({ ...history.state, nioNote: { id: r.id, key: e.historyKey } }, '', location.href);
419
+ } else if (Object.keys(fields).length) { const r = await api(`/api/notes/${e.id}`, { method: 'PATCH', body: fields }); e.n.updated = r.updated; }
420
+ e.error = false; e.savingFields = {};
421
+ if (!Object.keys(e.dirty).length) pendingSaves.delete(e);
422
+ saveStatus(e, Object.keys(e.dirty).length ? 'Canvis pendents…' : 'Desat');
423
+ return true;
424
+ } catch (er) {
425
+ e.dirty = { ...fields, ...e.dirty }; e.savingFields = {}; e.error = true; pendingSaves.add(e);
426
+ saveStatus(e, 'No s’ha pogut desar', true);
427
+ toast(`No s’ha pogut desar: ${er.message}`, 4500);
428
+ return false;
429
+ }
257
430
  });
258
431
  return e.chain;
259
432
  }
433
+ function saveStatus(entry, text, error = false) {
434
+ const status = entry.card?.querySelector('#edSaveStatus'), retry = entry.card?.querySelector('[data-e="retry-save"]');
435
+ if (status) { status.textContent = text; status.classList.toggle('err', error); }
436
+ if (retry) retry.hidden = !error;
437
+ }
260
438
  const ensureId = async () => { if (!ed.id) await flush(ed, true); return ed.id; };
261
439
 
262
440
  function buildEditor() {
263
441
  const n = ed.n, c = ed.card;
264
442
  c.innerHTML = `
443
+ <div class="nt-ed-nav"><button class="nt-ib nt-back" type="button" data-e="done">${svg(I.back)}<span>Notes</span></button><div class="nt-save"><span id="edSaveStatus" role="status">Es desa automàticament</span><button type="button" class="nt-ib" data-e="retry-save" hidden>Torna a desar</button></div></div>
265
444
  <div class="nt-ed-head"><input class="nt-ed-title" type="text" placeholder="Títol" value="${esc(n.title)}" aria-label="Títol" maxlength="200" autocomplete="off">
266
445
  <button class="nt-ib ${n.pinned ? 'on' : ''}" type="button" data-e="pin" aria-label="Fixa la nota" aria-pressed="${n.pinned}">${svg(I.pin)}</button></div>
267
446
  <div class="nt-ed-body" id="edBody"></div>
@@ -269,17 +448,25 @@ export function createNotes(ctx) {
269
448
  <div class="nt-labels nt-ed-labels" id="edLabels"></div>
270
449
  <div class="nt-ai" id="edAi" hidden></div>
271
450
  <div class="nt-rec" id="edRec" hidden></div>
451
+ <div class="nt-more-scrim" id="edScrim" hidden></div>
452
+ <div class="nt-more-sheet" id="edMore" role="dialog" aria-label="Més opcions" hidden>
453
+ <div class="nt-more-grab" aria-hidden="true"></div>
454
+ <button type="button" data-e="archive">${n.archived ? 'Desarxiva' : 'Arxiva'}</button>
455
+ <button type="button" data-e="trash" class="danger">Envia a la paperera</button>
456
+ <button type="button" data-e="more-close" class="muted">Tanca</button>
457
+ </div>
272
458
  <div class="nt-ed-foot" id="edFoot">
273
- <div class="nt-tb">
459
+ <div class="nt-tb"><div class="nt-edit-tools">
274
460
  <button class="nt-ib" type="button" data-e="mic" aria-label="Grava amb el micròfon" title="Grava (Whisper, a l’ordinador)">${svg(I.mic)}</button>
275
461
  <button class="nt-ib" type="button" data-e="mode" id="edMode" title="Llista o text"></button>
276
462
  <button class="nt-ib" type="button" data-e="color" aria-label="Color" title="Color">${svg(I.palette)}</button>
277
463
  <button class="nt-ib" type="button" data-e="label" aria-label="Etiquetes" title="Etiquetes">${svg(I.tag)}</button>
278
464
  <button class="nt-ib nt-ai-b" type="button" data-e="ai" aria-label="Assistent" title="Resumeix i organitza amb un model">${svg(I.spark)}<span>Assistent</span></button>
279
- <span class="nt-sp"></span>
465
+ <button class="nt-ib nt-more-btn" type="button" data-e="more" aria-label="Més">${svg(I.more)}</button>
466
+ </div><div class="nt-note-tools">
280
467
  <button class="nt-ib" type="button" data-e="archive" aria-label="Arxiva" title="Arxiva">${svg(I.archive)}</button>
281
468
  <button class="nt-ib" type="button" data-e="trash" aria-label="Paperera" title="Paperera">${svg(I.trash)}</button>
282
- <button class="btn ink nt-done" type="button" data-e="done">Fet</button>
469
+ <button class="btn ink nt-done" type="button" data-e="done">Fet</button></div>
283
470
  </div>
284
471
  </div>`;
285
472
  const title = $('.nt-ed-title', c);
@@ -287,6 +474,31 @@ export function createNotes(ctx) {
287
474
  title.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); ($('.nt-ed-text', c) || $('.nt-item input[type=text]', c))?.focus(); } });
288
475
  c.addEventListener('click', onEditorClick);
289
476
  drawBody(); drawAudio(); drawLabels(); drawMode();
477
+ bindMoreDrag($('#edMore', c), $('#edScrim', c));
478
+ }
479
+ // El panell «Més» segueix el dit cap avall i es tanca si baixa prou o es llença; si no, torna a pujar.
480
+ let moreDraggedAt = 0;
481
+ function bindMoreDrag(sheet, scrim) {
482
+ let g = null;
483
+ sheet.addEventListener('pointerdown', (e) => { if (e.pointerType === 'mouse') return; g = { y0: e.clientY, dy: 0, pid: e.pointerId, pts: [[e.timeStamp, e.clientY]], moved: false }; }, { passive: true });
484
+ sheet.addEventListener('pointermove', (e) => {
485
+ if (!g || e.pointerId !== g.pid) return;
486
+ const dy = e.clientY - g.y0;
487
+ if (!g.moved) { if (Math.abs(dy) < 6) return; g.moved = true; sheet.setPointerCapture?.(e.pointerId); sheet.style.transition = 'none'; scrim.style.transition = 'none'; }
488
+ g.dy = dy > 0 ? dy : dy / 4; // cap amunt, amb resistència
489
+ g.pts.push([e.timeStamp, e.clientY]); if (g.pts.length > 5) g.pts.shift();
490
+ sheet.style.transform = `translateY(${g.dy}px)`;
491
+ scrim.style.opacity = String(Math.max(0, 1 - Math.max(0, g.dy) / sheet.offsetHeight));
492
+ });
493
+ const end = (e) => {
494
+ if (!g || e.pointerId !== g.pid) return;
495
+ const d = g; g = null; if (!d.moved) return;
496
+ moreDraggedAt = Date.now();
497
+ const [t0, y0] = d.pts[0], [t1, y1] = d.pts[d.pts.length - 1], v = (y1 - y0) / Math.max(1, t1 - t0);
498
+ if (d.dy > sheet.offsetHeight * 0.3 || (v > 0.4 && d.dy > 12)) closeMore(true);
499
+ else { sheet.style.transition = ''; scrim.style.transition = ''; sheet.style.transform = ''; scrim.style.opacity = ''; }
500
+ };
501
+ sheet.addEventListener('pointerup', end); sheet.addEventListener('pointercancel', end);
290
502
  }
291
503
  function drawMode() { const b = $('#edMode', ed.card); if (b) { b.innerHTML = svg(ed.n.items ? I.text : I.list); b.setAttribute('aria-label', ed.n.items ? 'Passa a text' : 'Passa a llista'); } }
292
504
  function drawBody() {
@@ -317,10 +529,12 @@ export function createNotes(ctx) {
317
529
  }
318
530
  function removeItem(id) { set({ items: ed.n.items.filter((x) => x.id !== id) }); drawBody(); }
319
531
  function drawLabels() { const box = $('#edLabels', ed.card); box.innerHTML = ed.n.labels.map((l) => `<span class="nt-label">${esc(l)}<button type="button" data-e="unlabel" data-l="${esc(l)}" aria-label="Treu l’etiqueta ${esc(l)}">${svg(I.close)}</button></span>`).join(''); box.hidden = !ed.n.labels.length; }
532
+ // L'àudio acabat de pujar també surt a la llista (targeta «Transcrivint…»), sense esperar el flux.
533
+ function upsertAudio(note) { const cur = notes.get(note.id); if (cur && cur !== ed?.n) cur.audio = note.audio; if (root) drawGrid(); }
320
534
  function drawAudio() {
321
535
  const box = $('#edAudio', ed.card), list = ed.n.audio;
322
- box.hidden = !list.length;
323
- box.innerHTML = list.map((a) => `<div class="nt-au" data-a="${a.id}" data-st="${a.state}">
536
+ box.hidden = !list.length && !ed.uploading;
537
+ box.innerHTML = (ed.uploading ? '<div class="nt-au" data-st="transcribing"><span class="nt-au-s"><i class="nt-spin"></i>Enviant l’àudio a l’ordinador…</span></div>' : '') + list.map((a) => `<div class="nt-au" data-a="${a.id}" data-st="${a.state}">
324
538
  <button type="button" class="nt-ib sm" data-e="play" aria-label="Escolta">${svg(I.play)}</button>
325
539
  <span class="nt-au-d">${a.dur ? fmtDur(a.dur) : 'Àudio'}</span>
326
540
  <span class="nt-au-s">${a.state === 'transcribing' ? '<i class="nt-spin"></i>Transcrivint amb Whisper…' : a.state === 'ok' ? `${svg(I.check)}Transcripció afegida` : `${esc(a.error || 'No s’ha pogut transcriure')}`}</span>
@@ -330,16 +544,24 @@ export function createNotes(ctx) {
330
544
  function syncEditor() {
331
545
  const n = ed.n, c = ed.card;
332
546
  drawAudio(); drawLabels(); drawMode(); c.dataset.color = n.color;
333
- const ta = $('.nt-ed-text', c); if (ta && document.activeElement !== ta && ta.value !== n.text) { ta.value = n.text; grow(ta); }
334
- const ti = $('.nt-ed-title', c); if (document.activeElement !== ti && ti.value !== n.title) ti.value = n.title;
335
- if (n.items && !c.contains(document.activeElement?.closest?.('.nt-item'))) drawBody();
547
+ // El text que arriba (transcripció, un altre dispositiu) entra encara que el camp tingui el cursor,
548
+ // sempre que ara mateix no s'hi estigui escrivint; el cursor es queda on era.
549
+ const mine = (k) => k in ed.dirty || k in ed.savingFields;
550
+ const put = (el, v) => { const at = el.selectionStart, end = el.selectionEnd, foc = document.activeElement === el; el.value = v; if (foc) try { el.setSelectionRange(at, end); } catch { /* res */ } };
551
+ const ta = $('.nt-ed-text', c); if (ta && !mine('text') && ta.value !== n.text) { put(ta, n.text); grow(ta); }
552
+ const ti = $('.nt-ed-title', c); if (!mine('title') && ti.value !== n.title) put(ti, n.title);
553
+ if (n.items && !mine('items') && !c.contains(document.activeElement?.closest?.('.nt-item'))) drawBody();
336
554
  if (!n.items && !ta) drawBody();
337
555
  $('[data-e="pin"]', c).classList.toggle('on', n.pinned);
338
556
  }
339
557
 
340
558
  async function onEditorClick(ev) {
341
- const b = ev.target.closest('[data-e]'), c = ed?.card; if (!b || !ed) { if (ed?.pop && !ev.target.closest('.nt-pop')) closePop(); return; }
559
+ const b = ev.target.closest('[data-e]'), c = ed?.card; if (!b || !ed) { if (ed?.pop && !ev.target.closest('.nt-pop')) closePop(); if (ed && !ev.target.closest('.nt-more-sheet')) closeMore(true); return; }
342
560
  const a = b.dataset.e;
561
+ if (ev.target.closest('.nt-more-sheet') && Date.now() - moreDraggedAt < 350) return; // era un arrossegament, no un toc
562
+ if (a === 'more-close') return closeMore(true);
563
+ if (a !== 'more' && !ev.target.closest('.nt-more-sheet')) closeMore(true);
564
+ if (a === 'retry-save') { b.disabled = true; await flush(ed); b.disabled = false; return; }
343
565
  if (!['color', 'label', 'ai', 'aiengine'].includes(a) && !ev.target.closest('.nt-pop')) closePop();
344
566
  if (a === 'done') return closeEditor();
345
567
  if (a === 'pin') { set({ pinned: !ed.n.pinned }); b.classList.toggle('on', ed.n.pinned); b.setAttribute('aria-pressed', ed.n.pinned); return; }
@@ -358,6 +580,7 @@ export function createNotes(ctx) {
358
580
  if (a === 'color') return popColor(b);
359
581
  if (a === 'label') return popLabel(b);
360
582
  if (a === 'ai') return popAi(b);
583
+ if (a === 'more') return openMore();
361
584
  if (a === 'mic') return toggleRec();
362
585
  if (a === 'play') return play(b.closest('.nt-au').dataset.a, b);
363
586
  if (a === 'voice') return ctx.voiceSheet();
@@ -366,12 +589,31 @@ export function createNotes(ctx) {
366
589
  }
367
590
 
368
591
  // ---------------------------------------------------------------- finestretes (color, etiquetes, assistent)
592
+ function openMore() {
593
+ if (!ed) return; closePop();
594
+ const s = $('#edMore', ed.card), sc = $('#edScrim', ed.card);
595
+ // Puja amb una transició (no una animació CSS) perquè el dit el pugui agafar mentre encara puja.
596
+ s.hidden = sc.hidden = false;
597
+ if (!reduce) { s.style.transition = sc.style.transition = 'none'; s.style.transform = 'translateY(100%)'; sc.style.opacity = '0'; void s.offsetHeight; }
598
+ s.style.transition = sc.style.transition = s.style.transform = sc.style.opacity = '';
599
+ $('button', s)?.focus({ preventScroll: true });
600
+ }
601
+ function closeMore(animated) {
602
+ const s = ed?.card?.querySelector('#edMore'), sc = ed?.card?.querySelector('#edScrim');
603
+ if (!s || s.hidden) return;
604
+ const hide = () => { s.hidden = sc.hidden = true; s.style.transition = sc.style.transition = s.style.transform = sc.style.opacity = ''; };
605
+ if (!animated || reduce) return hide();
606
+ s.style.transition = 'transform .22s cubic-bezier(.4,0,1,1)'; sc.style.transition = 'opacity .22s';
607
+ s.style.transform = 'translateY(105%)'; sc.style.opacity = '0';
608
+ setTimeout(hide, 220);
609
+ }
369
610
  function closePop() { ed?.pop?.remove(); if (ed) ed.pop = null; }
370
611
  function pop(anchor, html) {
371
612
  closePop();
372
613
  const p = document.createElement('div'); p.className = 'nt-pop'; p.innerHTML = html;
373
614
  ed.card.append(p); ed.pop = p;
374
615
  const a = anchor.getBoundingClientRect(), r = ed.card.getBoundingClientRect(), w = p.offsetWidth;
616
+ p.style.maxHeight = `${Math.max(80, a.top - Math.max(r.top, window.visualViewport?.offsetTop || 0) - 16)}px`;
375
617
  p.style.left = `${Math.max(8, Math.min(a.left - r.left, r.width - w - 8))}px`; p.style.bottom = `${r.bottom - a.top + 8}px`;
376
618
  return p;
377
619
  }
@@ -413,10 +655,11 @@ export function createNotes(ctx) {
413
655
  if (!ed) return;
414
656
  try {
415
657
  if (blob.size < 1500) throw new Error('No he sentit res. Prova-ho una altra vegada.');
658
+ ed.uploading = true; drawAudio();
416
659
  const id = await ensureId(); if (!id) throw new Error('No s’ha pogut crear la nota');
417
660
  const res = await ctx.nodeAudio(`/api/notes/${id}/audio?dur=${Math.round(dur)}`, { headers: { 'content-type': blob.type || 'audio/webm' }, body: blob });
418
- const note = await res.json(); if (ed) { const mine = ed.n; mine.audio = note.audio; drawAudio(); }
419
- } catch (e) { toast(e.message, 4500); }
661
+ const note = await res.json(); if (ed) { ed.uploading = false; ed.n.audio = note.audio; upsertAudio(note); drawAudio(); schedule(); }
662
+ } catch (e) { if (ed) { ed.uploading = false; drawAudio(); } toast(e.message, 4500); }
420
663
  };
421
664
  ed.rec = { stop: () => rec.stop(), cancel: () => { send = false; try { rec.stop(); } catch { cleanup(); } } };
422
665
  rec.start(1000);
package/web/app/style.css CHANGED
@@ -191,6 +191,11 @@ button.chip:hover { border-color: var(--fg); }
191
191
  .session-card .last.err { color: var(--red); }
192
192
  .session-card .last { color: var(--fg-muted); font-size: 14px; margin-top: 4px; line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
193
193
  .session-card .meta { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; font-size: 12px; color: var(--fg-muted); }
194
+ #termSessions:not(:empty) { margin-bottom: 16px; }
195
+ .old-sessions { margin-top: 8px; }
196
+ .old-sessions > summary { cursor: pointer; padding: 8px 0; list-style: none; }
197
+ .old-sessions > summary::-webkit-details-marker { display: none; }
198
+ .old-sessions > .list { margin-top: 8px; }
194
199
  .fab { position: fixed; right: 24px; bottom: calc(24px + var(--safe-b)); z-index: 15; display: inline-flex; align-items: center; gap: 8px; height: 50px; padding: 0 20px 0 16px; border: 0; border-radius: 16px; background: var(--fg); color: var(--bg); font-weight: 600; box-shadow: 0 14px 30px -14px color-mix(in srgb, var(--fg) 70%, transparent); transition: transform .15s; }
195
200
  .fab:active { transform: scale(.97); }
196
201
 
@@ -744,3 +749,55 @@ body.chat-mode .demo-bar, body.board-mode .demo-bar { display: none; }
744
749
  }
745
750
  /* Referència per mesurar els missatges (offsetTop) dins la conversa. */
746
751
  .feed { position: relative; }
752
+
753
+ /* Totes les seccions, amb controls còmodes de tocar. */
754
+ .more-sections { display: grid; gap: 22px; margin-top: 20px; }
755
+ .more-sections h3 { margin: 0 0 6px; font-size: 12px; font-weight: 600; color: var(--fg-muted); }
756
+ .more-sections section > div { display: grid; }
757
+ .more-link { display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 10px 12px; border-radius: 10px; text-decoration: none; }
758
+ .more-link > span:first-of-type { flex: 1; }
759
+ .more-link > .ico:last-child { width: 14px; color: var(--fg-muted); }
760
+ .more-link:hover, .more-link[aria-current="page"] { background: var(--bg-2); }
761
+ .voice-extras { border: 1px solid var(--border); border-radius: 12px; padding: 0 14px; }
762
+ .voice-extras summary { min-height: 48px; padding-block: 13px; cursor: pointer; font-weight: 600; }
763
+ .voice-extras > div { padding: 6px 0 16px; }
764
+ @media (max-width: 820px) { .tabbar > * { min-height: 48px; } .side nav a { min-height: 46px; } }
765
+
766
+ /* Llistes i formularis també han d’encaixar amb text llarg o el teclat obert. */
767
+ .side nav { min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
768
+ .side > .brand, .side > .foot { flex-shrink: 0; }
769
+ :where(.panel, .card, .stack, .stack-lg, .grid-2, .grid-2 > *, label.f) { min-width: 0; }
770
+ :where(.top, .panel .body, .mem-list, .job, .sheet-in h2, .conn span) { overflow-wrap: anywhere; }
771
+ .grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
772
+ .conn span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
773
+ :where(button, a, input, textarea, select, summary):focus-visible { outline: 2px solid var(--fg); outline-offset: 3px; }
774
+ @media (max-width: 820px) {
775
+ dialog.sheet { max-height: min(92dvh, calc(var(--vvh, 100dvh) - 8px)); }
776
+ .btn, .btn.sm, .icon-btn, .seg button { min-height: 44px; }
777
+ input[type="text"], input[type="password"], input[type="url"], input[type="search"], select, textarea.field { font-size: 16px; }
778
+ .sheet-foot { bottom: calc(-22px - var(--safe-b)); }
779
+ }
780
+ @media (max-width: 520px) { .grid-2 { grid-template-columns: minmax(0, 1fr); } }
781
+ .sheet-in { position: relative; }
782
+ .sheet-close { position: absolute; top: 14px; right: 14px; z-index: 1; }
783
+ .sheet-in > h2 { padding-right: 42px; }
784
+ .form-error { color: var(--red); font-size: 13px; overflow-wrap: anywhere; }
785
+ dialog.sheet:has(#memForm) { width: min(780px, calc(100% - 32px)); }
786
+ @media (max-width: 820px) {
787
+ .sheet-close { top: 16px; right: 10px; }
788
+ dialog.sheet:has(#memForm) { width: 100%; }
789
+ }
790
+ @media (max-width: 520px) {
791
+ #jobWhen { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 3px; }
792
+ #jobWhen button { white-space: normal; padding: 6px 8px; }
793
+ }
794
+ @media (max-width: 820px) {
795
+ dialog.sheet { top: auto; bottom: var(--kb, 0px); margin: 0; }
796
+ }
797
+
798
+ /* Sessions obertes al terminal de l'ordinador */
799
+ .term-sessions { margin-bottom: 22px; }
800
+ .term-sessions h2.sec { margin: 0 0 8px; }
801
+ .term-card { width: 100%; text-align: left; font: inherit; color: inherit; cursor: pointer; border-style: dashed; }
802
+ .term-card:disabled { opacity: .6; cursor: progress; }
803
+ .term-go { margin-left: auto; display: inline-flex; align-items: center; gap: 2px; font-weight: 600; color: var(--fg); }
package/web/sw.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // nio · service worker: obre l'app encara que no hi hagi connexió i mostra els avisos.
2
- const SHELL = 'nio-shell-v45';
3
- const FILES = ['/app', '/app/style.css', '/app/notes.css', '/app/app.js', '/app/notes.js', '/app/demo.js', '/bot.css', '/bot.js', '/board.css', '/board.js', '/favicon.svg', '/manifest.webmanifest'];
2
+ const SHELL = 'nio-shell-v50';
3
+ const FILES = ['/app', '/app/style.css', '/app/notes.css', '/app/call.css', '/app/app.js', '/app/notes.js', '/app/call.js', '/app/demo.js', '/bot.css', '/bot.js', '/board.css', '/board.js', '/favicon.svg', '/manifest.webmanifest'];
4
4
 
5
5
  self.addEventListener('install', (e) => {
6
6
  e.waitUntil(caches.open(SHELL).then((c) => c.addAll(FILES)).catch(() => {}));