nio-cat 0.9.6 → 0.9.7

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,504 @@
1
+ // nio · Notes. Com Google Keep, però a l'ordinador: targetes, llistes, colors, etiquetes, veu (Whisper local)
2
+ // i un assistent que resumeix i ordena amb un model local, Claude Code o Codex.
3
+ // Es carrega des d'app.js, que li passa el que necessita (ctx).
4
+
5
+ const I = {
6
+ pin: '<path d="M9 4h6l-1 6 3 3v1H7v-1l3-3z"/><path d="M12 14v6"/>',
7
+ archive: '<rect x="3.5" y="4.5" width="17" height="4" rx="1"/><path d="M5 8.5V19h14V8.5M10 12.5h4"/>',
8
+ trash: '<path d="M5 7h14M10 7V4.5h4V7M7 7l1 13h8l1-13M10.5 11v5.5M13.5 11v5.5"/>',
9
+ restore: '<path d="M4 12a8 8 0 1 0 2.5-5.8L4 8.5"/><path d="M4 4v4.5h4.5"/>',
10
+ list: '<path d="M9 6.5h11M9 12h11M9 17.5h11"/><path d="M3.8 6.5l1 1 1.8-2M3.8 12l1 1 1.8-2M3.8 17.5l1 1 1.8-2"/>',
11
+ text: '<path d="M5 6h14M5 11h14M5 16h9"/>',
12
+ mic: '<rect x="9" y="3.5" width="6" height="11" rx="3"/><path d="M5.5 11.5a6.5 6.5 0 0 0 13 0M12 18v3"/>',
13
+ stop: '<rect x="7" y="7" width="10" height="10" rx="2"/>',
14
+ palette: '<path d="M12 3.5a8.5 8.5 0 1 0 0 17c1.4 0 2-.9 1.6-2-.4-1.2.3-2.3 1.600-2.300H17a3.500 3.500 0 0 0 3.500-3.500C20.500 7 16.800 3.500 12 3.500z"/><circle cx="8" cy="11" r="1"/><circle cx="11" cy="7.500" r="1"/><circle cx="15.500" cy="8.500" r="1"/>',
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
+ 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
+ close: '<path d="M6 6l12 12M18 6L6 18"/>',
18
+ plus: '<path d="M12 5v14M5 12h14"/>',
19
+ search: '<circle cx="11" cy="11" r="6.500"/><path d="M16 16l4 4"/>',
20
+ 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"/>',
21
+ rows: '<rect x="4" y="5" width="16" height="5" rx="1.500"/><rect x="4" y="14" width="16" height="5" rx="1.500"/>',
22
+ play: '<path d="M8 5.500l10 6.500-10 6.500z"/>',
23
+ pause: '<path d="M8 5.500v13M16 5.500v13"/>',
24
+ phone: '<rect x="7" y="3" width="10" height="18" rx="2.500"/><path d="M11 18h2"/>',
25
+ check: '<path d="M5 12.500l4.500 4.500L19 7.500"/>',
26
+ };
27
+ const COLORS = [['', 'Sense color'], ['lemon', 'Llimona'], ['peach', 'Préssec'], ['moss', 'Molsa'], ['sky', 'Cel'], ['lilac', 'Lila'], ['rose', 'Rosa'], ['sand', 'Sorra']];
28
+ const ACTIONS = [
29
+ ['summary', 'Resumeix', 'Punts clau de la nota'],
30
+ ['clean', 'Neteja la transcripció', 'Puntuació, paràgrafs, sense «eh»'],
31
+ ['organize', 'Organitza', 'Títol, ordre i etiquetes'],
32
+ ['tasks', 'Extreu tasques', 'Les accions pendents, en llista'],
33
+ ['title', 'Posa-hi un títol', 'Una frase curta'],
34
+ ];
35
+ const svg = (d) => `<svg class="ico" viewBox="0 0 24 24" aria-hidden="true">${d}</svg>`;
36
+ const uid = () => Math.random().toString(36).slice(2, 10);
37
+ const fmtDur = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
38
+ const LSget = (k, d) => { try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch { return d; } };
39
+ const LSset = (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* sense desar */ } };
40
+ const grow = (ta) => { ta.style.height = 'auto'; ta.style.height = `${ta.scrollHeight}px`; };
41
+ const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
42
+
43
+ export function createNotes(ctx) {
44
+ const { api, esc, toast } = ctx;
45
+ const $ = (s, e = document) => e.querySelector(s);
46
+ let root = null, notes = new Map(), loaded = false, loading = false, engines = null;
47
+ let view = 'notes', q = '', layout = LSget('nio.notes.layout', 'grid');
48
+ let ed = null, aborter = null;
49
+
50
+ // ---------------------------------------------------------------- dades
51
+ const all = () => [...notes.values()];
52
+ const empty = (n) => !n.title.trim() && !n.text.trim() && !(n.items || []).some((i) => i.text.trim()) && !n.audio.length;
53
+ const labelsOf = () => [...new Set(all().filter((n) => !n.trashed).flatMap((n) => n.labels))].sort((a, b) => a.localeCompare(b, 'ca'));
54
+ const haystack = (n) => `${n.title}\n${n.text}\n${(n.items || []).map((i) => i.text).join('\n')}\n${n.labels.join(' ')}`.toLowerCase();
55
+ function visible() {
56
+ const term = q.trim().toLowerCase();
57
+ return all().filter((n) => {
58
+ if (view === 'trash') { if (!n.trashed) return false; }
59
+ else if (n.trashed) return false;
60
+ else if (view === 'archive') { if (!n.archived) return false; }
61
+ else if (view.startsWith('l:')) { if (!n.labels.includes(view.slice(2))) return false; }
62
+ else if (n.archived && !term) return false;
63
+ return !term || haystack(n).includes(term);
64
+ }).sort((a, b) => (b.pinned - a.pinned) || b.updated - a.updated);
65
+ }
66
+ async function load() {
67
+ 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>`; }
70
+ loading = false; drawAll();
71
+ }
72
+ const upsert = (n) => { const old = notes.get(n.id); notes.set(n.id, n); return old; };
73
+
74
+ // ---------------------------------------------------------------- pàgina
75
+ function mount(el) {
76
+ root = el; aborter?.abort(); aborter = new AbortController();
77
+ root.innerHTML = `<div class="nt">
78
+ <div class="nt-top">
79
+ <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>
81
+ </div>
82
+ <div class="nt-bar">
83
+ <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
+ <button class="icon-btn" type="button" data-nt="layout" aria-label="Canvia la vista" id="ntLayout"></button>
85
+ </div>
86
+ <div class="nt-new" id="ntNew" role="button" tabindex="0" data-nt="new"><span>Escriu una nota…</span>
87
+ <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
+ <nav class="nt-filters" id="ntFilters" aria-label="Filtres"></nav>
89
+ <div id="ntGrid" class="nt-body"></div>
90
+ </div>`;
91
+ const sig = { signal: aborter.signal };
92
+ $('#ntQ', root).addEventListener('input', (e) => { q = e.target.value; drawGrid(); }, sig);
93
+ 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);
95
+ drawAll();
96
+ load();
97
+ if (/[?&]mic=1/.test(location.hash)) setTimeout(() => openEditor(null, $('#ntNew', root), { mic: true }), 250);
98
+ }
99
+ function unmount() { aborter?.abort(); closeEditor(true); root = null; }
100
+ const drawAll = () => { if (!root) return; drawLayout(); drawFilters(); drawGrid(); };
101
+ function drawLayout() { const b = $('#ntLayout', root); if (b) b.innerHTML = svg(layout === 'grid' ? I.rows : I.grid); root.querySelector('.nt').dataset.layout = layout; }
102
+ function drawFilters() {
103
+ const labs = labelsOf(), nTrash = all().filter((n) => n.trashed).length;
104
+ 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));
106
+ }
107
+ function cardHtml(n) {
108
+ const items = n.items || [];
109
+ const open = items.filter((i) => !i.done), doneN = items.length - open.length;
110
+ const shown = [...open, ...items.filter((i) => i.done)].slice(0, 8);
111
+ const pend = n.audio.filter((a) => a.state === 'transcribing').length, err = n.audio.some((a) => a.state === 'error');
112
+ return `<article class="nt-card" data-id="${n.id}" data-color="${n.color}" tabindex="0" aria-label="${esc(n.title || 'Nota sense títol')}">
113
+ ${view === 'trash' ? '' : `<button class="nt-pin ${n.pinned ? 'on' : ''}" type="button" data-nt="pin" aria-label="${n.pinned ? 'Deixa de fixar' : 'Fixa la nota'}" aria-pressed="${n.pinned}">${svg(I.pin)}</button>`}
114
+ ${n.title ? `<h3>${esc(n.title)}</h3>` : ''}
115
+ ${n.text.trim() ? `<div class="nt-txt">${esc(n.text.trim())}</div>` : ''}
116
+ ${items.length ? `<div class="nt-its">${shown.map((i) => `<div class="nt-it ${i.done ? 'done' : ''}"><input type="checkbox" data-nt="tick" data-i="${i.id}" ${i.done ? 'checked' : ''} aria-label="Fet"><span>${esc(i.text)}</span></div>`).join('')}${items.length > 8 ? `<span class="nt-more">+ ${items.length - 8} més</span>` : ''}${doneN && items.length <= 8 ? '' : ''}</div>` : ''}
117
+ ${!n.title && !n.text.trim() && !items.length ? `<div class="nt-txt muted">${n.audio.length ? 'Nota de veu' : 'Nota buida'}</div>` : ''}
118
+ ${n.audio.length ? `<div class="nt-badge ${err ? 'err' : ''}">${svg(I.mic)}<span>${pend ? 'Transcrivint…' : err ? 'No s’ha pogut transcriure' : `${n.audio.length} ${n.audio.length === 1 ? 'àudio' : 'àudios'}`}</span></div>` : ''}
119
+ ${n.labels.length ? `<div class="nt-labels">${n.labels.map((l) => `<span class="nt-label">${esc(l)}</span>`).join('')}</div>` : ''}
120
+ <div class="nt-acts">${view === 'trash'
121
+ ? `<button type="button" data-nt="restore" title="Recupera">${svg(I.restore)}</button><button type="button" data-nt="forever" title="Esborra del tot">${svg(I.trash)}</button>`
122
+ : `<button type="button" data-nt="archive" title="${n.archived ? 'Desarxiva' : 'Arxiva'}">${svg(I.archive)}</button><button type="button" data-nt="trash" title="Paperera">${svg(I.trash)}</button>`}</div>
123
+ </article>`;
124
+ }
125
+ function drawGrid() {
126
+ const g = $('#ntGrid', root); if (!g) return;
127
+ 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
+ const list = visible();
129
+ if (!list.length) {
130
+ 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
+ return;
132
+ }
133
+ const sec = (title, arr) => arr.length ? `${title ? `<h2 class="nt-sec">${title}</h2>` : ''}<div class="nt-grid">${arr.map(cardHtml).join('')}</div>` : '';
134
+ const pinned = view === 'notes' || view.startsWith('l:') ? list.filter((n) => n.pinned) : [];
135
+ const rest = pinned.length ? list.filter((n) => !n.pinned) : list;
136
+ g.innerHTML = view === 'trash' ? `<div class="nt-trash-bar"><span>Esborrades soles als 30 dies.</span><button class="btn ghost" type="button" data-nt="empty-trash">Buida la paperera</button></div>${sec('', list)}` : sec(pinned.length ? 'Fixades' : '', pinned) + sec(pinned.length ? 'Altres' : '', rest);
137
+ }
138
+
139
+ // ---------------------------------------------------------------- clics de la pàgina
140
+ async function onClick(e) {
141
+ const t = e.target.closest('[data-nt]'), card = e.target.closest('.nt-card');
142
+ const act = t?.dataset.nt, n = card ? notes.get(card.dataset.id) : null;
143
+ if (act === 'view') { view = t.dataset.v; drawFilters(); drawGrid(); return; }
144
+ if (act === 'layout') { layout = layout === 'grid' ? 'list' : 'grid'; LSset('nio.notes.layout', layout); drawLayout(); return; }
145
+ if (act === 'phone') return phoneSheet();
146
+ if (act === 'new') return openEditor(null, $('#ntNew', root));
147
+ if (act === 'newlist') { e.stopPropagation(); return openEditor(null, $('#ntNew', root), { list: true }); }
148
+ if (act === 'newmic') { e.stopPropagation(); return openEditor(null, $('#ntNew', root), { mic: true }); }
149
+ if (act === 'empty-trash') { if (!confirm('S’esborraran per sempre totes les notes de la paperera.')) return; for (const x of all().filter((m) => m.trashed)) api(`/api/notes/${x.id}?forever=1`, { method: 'DELETE' }).catch(() => {}); return; }
150
+ if (n && act) {
151
+ e.stopPropagation();
152
+ if (act === 'tick') { const items = n.items.map((i) => (i.id === t.dataset.i ? { ...i, done: t.checked } : i)); return patch(n, { items }); }
153
+ if (act === 'pin') return patch(n, { pinned: !n.pinned });
154
+ if (act === 'archive') return leave(card, () => patch(n, { archived: !n.archived, pinned: false }), n.archived ? 'Desarxivada' : 'Arxivada', () => patch(n, { archived: n.archived, pinned: n.pinned }));
155
+ if (act === 'trash') return leave(card, () => patch(n, { trashed: true }), 'Anat a la paperera', () => patch(n, { trashed: false }));
156
+ if (act === 'restore') return patch(n, { trashed: false });
157
+ if (act === 'forever') { if (confirm('Aquesta nota s’esborrarà per sempre.')) api(`/api/notes/${n.id}?forever=1`, { method: 'DELETE' }).catch((er) => toast(er.message)); return; }
158
+ }
159
+ if (card && n) return openEditor(n.id, card);
160
+ }
161
+ // La targeta s'esvaeix abans de marxar de la llista; el toast permet desfer-ho.
162
+ function leave(card, fn, msg, undo) {
163
+ if (!reduce) { card.style.transition = 'opacity .16s, transform .16s'; card.style.opacity = '0'; card.style.transform = 'scale(.96)'; }
164
+ setTimeout(() => { fn(); toastUndo(msg, undo); }, reduce ? 0 : 150);
165
+ }
166
+ function toastUndo(msg, undo) {
167
+ toast(msg, 4200);
168
+ const t = document.querySelector('#toasts')?.lastElementChild; if (!t || !undo) return;
169
+ const b = document.createElement('button'); b.type = 'button'; b.className = 'nt-undo'; b.textContent = 'Desfés';
170
+ b.addEventListener('pointerdown', (ev) => ev.stopPropagation()); // el toast captura el punter per poder-lo arrossegar
171
+ b.onclick = (ev) => { ev.stopPropagation(); undo(); t.remove(); };
172
+ t.append(' ', b);
173
+ }
174
+ async function patch(n, fields) {
175
+ 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(); }
177
+ }
178
+
179
+ // ---------------------------------------------------------------- esdeveniments en directe (del flux d'nio)
180
+ function onEvent(msg) {
181
+ if (msg.type === 'note-gone') { notes.delete(msg.id); if (ed?.id === msg.id) closeEditor(true); drawAll(); return; }
182
+ const n = msg.note, old = upsert(n); void old;
183
+ if (ed && ed.id === n.id) {
184
+ // 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));
186
+ const mine = ed.n; for (const k of Object.keys(n)) if (!keep.has(k)) mine[k] = n[k];
187
+ ed.n = mine; notes.set(n.id, mine); syncEditor();
188
+ }
189
+ if (root) { drawFilters(); drawGrid(); }
190
+ }
191
+ const reload = () => { if (root || loaded) load(); };
192
+
193
+ // ---------------------------------------------------------------- editor
194
+ function openEditor(id, src, opts = {}) {
195
+ if (ed) return;
196
+ const n = id ? notes.get(id) : { id: null, title: '', text: '', items: opts.list ? [] : null, color: '', pinned: false, archived: false, trashed: 0, labels: [], audio: [] };
197
+ if (!n) return;
198
+ ed = { id, n, dirty: {}, timer: 0, chain: Promise.resolve(), rec: null, ai: null, src, pop: null };
199
+ const ov = document.createElement('div');
200
+ 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
+ document.body.append(ov); ed.ov = ov; ed.card = ov.firstElementChild;
202
+ buildEditor();
203
+ 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(); });
205
+ animateOpen(ed.card, src);
206
+ if (!n.title && !n.text && !n.items?.length && !opts.mic) setTimeout(() => ($('.nt-ed-title', ed.card) || {}).focus?.(), 60);
207
+ if (opts.list) addItem(null);
208
+ if (opts.mic) setTimeout(() => toggleRec(), 220);
209
+ }
210
+ function animateOpen(card, src) {
211
+ if (reduce) return;
212
+ const phone = matchMedia('(max-width: 700px)').matches;
213
+ if (phone || !src?.isConnected) { card.animate([{ transform: 'translateY(40px)', opacity: 0 }, { transform: 'none', opacity: 1 }], { duration: 260, easing: 'cubic-bezier(.22,1,.36,1)' }); return; }
214
+ const a = src.getBoundingClientRect(), b = card.getBoundingClientRect();
215
+ const dx = a.left - b.left, dy = a.top - b.top, sx = a.width / b.width, sy = Math.max(.2, a.height / b.height);
216
+ card.style.transformOrigin = '0 0';
217
+ 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
+ ed.ov.animate([{ background: 'rgba(39,34,36,0)' }, { background: 'rgba(39,34,36,.32)' }], { duration: 300, easing: 'ease-out' });
219
+ }
220
+ async function closeEditor(instant) {
221
+ const e = ed; if (!e) return;
222
+ ed = null; clearTimeout(e.timer); e.rec?.cancel?.(); e.audioEl?.pause();
223
+ const done = () => e.ov.remove();
224
+ if (instant || reduce) done(); else {
225
+ const src = e.id && root ? root.querySelector(`.nt-card[data-id="${e.id}"]`) : null, phone = matchMedia('(max-width: 700px)').matches;
226
+ let kf = [{ transform: 'none', opacity: 1 }, { transform: 'translateY(30px)', opacity: 0 }];
227
+ if (!phone && src) { const a = src.getBoundingClientRect(), b = e.card.getBoundingClientRect(); kf = [{ transform: 'none', opacity: 1 }, { transform: `translate(${a.left - b.left}px,${a.top - b.top}px) scale(${a.width / b.width},${Math.max(.2, a.height / b.height)})`, opacity: .4 }]; }
228
+ e.card.style.transformOrigin = '0 0';
229
+ e.card.animate(kf, { duration: 220, easing: 'cubic-bezier(.4,0,.2,1)', fill: 'forwards' });
230
+ e.ov.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 220, fill: 'forwards' }).onfinish = done;
231
+ }
232
+ await flush(e);
233
+ if (e.id && empty(e.n)) { notes.delete(e.id); api(`/api/notes/${e.id}?forever=1`, { method: 'DELETE' }).catch(() => {}); }
234
+ if (root) drawAll();
235
+ }
236
+
237
+ // Desat: s'envia només el que ha canviat, en cua, mig segon després de l'última tecla.
238
+ function set(fields) {
239
+ Object.assign(ed.n, fields); Object.assign(ed.dirty, fields);
240
+ ed.card.dataset.color = ed.n.color;
241
+ clearTimeout(ed.timer); ed.timer = setTimeout(flush, 450);
242
+ if (ed.id) notes.set(ed.id, ed.n);
243
+ if (root) { clearTimeout(set.t); set.t = setTimeout(() => { drawFilters(); drawGrid(); }, 200); }
244
+ }
245
+ function flush(e = ed, force = false) {
246
+ if (!e) return Promise.resolve();
247
+ clearTimeout(e.timer);
248
+ e.chain = e.chain.then(async () => {
249
+ const fields = e.dirty; e.dirty = {};
250
+ try {
251
+ if (!e.id) {
252
+ if (!force && empty(e.n)) { e.dirty = { ...fields, ...e.dirty }; return; } // una nota buida no es desa
253
+ const { audio, id, ...body } = e.n; void audio; void id;
254
+ 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); }
257
+ });
258
+ return e.chain;
259
+ }
260
+ const ensureId = async () => { if (!ed.id) await flush(ed, true); return ed.id; };
261
+
262
+ function buildEditor() {
263
+ const n = ed.n, c = ed.card;
264
+ c.innerHTML = `
265
+ <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
+ <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
+ <div class="nt-ed-body" id="edBody"></div>
268
+ <div class="nt-audio" id="edAudio"></div>
269
+ <div class="nt-labels nt-ed-labels" id="edLabels"></div>
270
+ <div class="nt-ai" id="edAi" hidden></div>
271
+ <div class="nt-rec" id="edRec" hidden></div>
272
+ <div class="nt-ed-foot" id="edFoot">
273
+ <div class="nt-tb">
274
+ <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
+ <button class="nt-ib" type="button" data-e="mode" id="edMode" title="Llista o text"></button>
276
+ <button class="nt-ib" type="button" data-e="color" aria-label="Color" title="Color">${svg(I.palette)}</button>
277
+ <button class="nt-ib" type="button" data-e="label" aria-label="Etiquetes" title="Etiquetes">${svg(I.tag)}</button>
278
+ <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>
280
+ <button class="nt-ib" type="button" data-e="archive" aria-label="Arxiva" title="Arxiva">${svg(I.archive)}</button>
281
+ <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>
283
+ </div>
284
+ </div>`;
285
+ const title = $('.nt-ed-title', c);
286
+ title.addEventListener('input', () => set({ title: title.value }));
287
+ title.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); ($('.nt-ed-text', c) || $('.nt-item input[type=text]', c))?.focus(); } });
288
+ c.addEventListener('click', onEditorClick);
289
+ drawBody(); drawAudio(); drawLabels(); drawMode();
290
+ }
291
+ 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
+ function drawBody() {
293
+ const n = ed.n, box = $('#edBody', ed.card);
294
+ if (!n.items) {
295
+ box.innerHTML = '<textarea class="nt-ed-text" placeholder="Escriu una nota…" aria-label="Text de la nota" rows="3"></textarea>';
296
+ const ta = $('textarea', box); ta.value = n.text; grow(ta);
297
+ ta.addEventListener('input', () => { grow(ta); set({ text: ta.value }); });
298
+ return;
299
+ }
300
+ const open = n.items.filter((i) => !i.done), done = n.items.filter((i) => i.done);
301
+ const row = (i) => `<div class="nt-item ${i.done ? 'done' : ''}" data-i="${i.id}"><input type="checkbox" data-e="tick" ${i.done ? 'checked' : ''} aria-label="Fet"><input type="text" value="${esc(i.text)}" placeholder="Element" aria-label="Element" maxlength="500"><button type="button" class="nt-x" data-e="rm" aria-label="Treu l’element">${svg(I.close)}</button></div>`;
302
+ box.innerHTML = `${n.text.trim() ? '<textarea class="nt-ed-text nt-desc" rows="2" aria-label="Text de la nota"></textarea>' : ''}<div id="edItems">${open.map(row).join('')}</div><button class="nt-add" type="button" data-e="add">${svg(I.plus)}<span>Afegeix un element</span></button>${done.length ? `<div class="nt-donehd">${done.length} ${done.length === 1 ? 'fet' : 'fets'}</div><div>${done.map(row).join('')}</div>` : ''}`;
303
+ const d = $('.nt-desc', box); if (d) { d.value = n.text; grow(d); d.addEventListener('input', () => { grow(d); set({ text: d.value }); }); }
304
+ $$all('.nt-item input[type=text]', box).forEach((inp) => {
305
+ const id = inp.closest('.nt-item').dataset.i;
306
+ inp.addEventListener('input', () => set({ items: ed.n.items.map((x) => (x.id === id ? { ...x, text: inp.value } : x)) }));
307
+ inp.addEventListener('keydown', (e) => {
308
+ if (e.key === 'Enter') { e.preventDefault(); addItem(id); }
309
+ else if (e.key === 'Backspace' && !inp.value) { e.preventDefault(); const prev = inp.closest('.nt-item').previousElementSibling; removeItem(id); prev?.querySelector('input[type=text]')?.focus(); }
310
+ });
311
+ });
312
+ }
313
+ const $$all = (s, e) => [...e.querySelectorAll(s)];
314
+ function addItem(afterId) {
315
+ const it = { id: uid(), text: '', done: false }, arr = [...ed.n.items], i = afterId ? arr.findIndex((x) => x.id === afterId) : arr.length - 1;
316
+ arr.splice(i + 1, 0, it); set({ items: arr }); drawBody(); $(`.nt-item[data-i="${it.id}"] input[type=text]`, ed.card)?.focus();
317
+ }
318
+ function removeItem(id) { set({ items: ed.n.items.filter((x) => x.id !== id) }); drawBody(); }
319
+ 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; }
320
+ function drawAudio() {
321
+ 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}">
324
+ <button type="button" class="nt-ib sm" data-e="play" aria-label="Escolta">${svg(I.play)}</button>
325
+ <span class="nt-au-d">${a.dur ? fmtDur(a.dur) : 'Àudio'}</span>
326
+ <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>
327
+ ${a.state === 'error' ? (/Whisper|veu/i.test(a.error) && !ctx.voiceOn() ? '<button type="button" class="btn sm" data-e="voice">Activa Whisper</button>' : '<button type="button" class="btn ghost sm" data-e="retry">Torna-ho a provar</button>') : ''}</div>`).join('');
328
+ }
329
+ // Canvis que arriben de fora (transcripció acabada, un altre dispositiu).
330
+ function syncEditor() {
331
+ const n = ed.n, c = ed.card;
332
+ 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();
336
+ if (!n.items && !ta) drawBody();
337
+ $('[data-e="pin"]', c).classList.toggle('on', n.pinned);
338
+ }
339
+
340
+ 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; }
342
+ const a = b.dataset.e;
343
+ if (!['color', 'label', 'ai', 'aiengine'].includes(a) && !ev.target.closest('.nt-pop')) closePop();
344
+ if (a === 'done') return closeEditor();
345
+ if (a === 'pin') { set({ pinned: !ed.n.pinned }); b.classList.toggle('on', ed.n.pinned); b.setAttribute('aria-pressed', ed.n.pinned); return; }
346
+ if (a === 'archive') { set({ archived: !ed.n.archived, pinned: false }); toast(ed.n.archived ? 'Arxivada' : 'Desarxivada'); return closeEditor(); }
347
+ if (a === 'trash') { set({ trashed: true }); toast('Anat a la paperera'); return closeEditor(); }
348
+ if (a === 'mode') {
349
+ const n = ed.n;
350
+ if (n.items) set({ text: [n.text, ...n.items.map((i) => i.text)].filter((s) => s.trim()).join('\n'), items: null });
351
+ else { const lines = n.text.split('\n').map((s) => s.replace(/^\s*(?:[-*•]|\[[ x]\])\s*/i, '').trim()).filter(Boolean); set({ items: lines.map((t) => ({ id: uid(), text: t, done: false })), text: '' }); if (!lines.length) addItem(null); }
352
+ drawMode(); drawBody(); return;
353
+ }
354
+ if (a === 'tick') { const id = b.closest('.nt-item').dataset.i; set({ items: ed.n.items.map((x) => (x.id === id ? { ...x, done: b.checked } : x)) }); drawBody(); return; }
355
+ if (a === 'rm') return removeItem(b.closest('.nt-item').dataset.i);
356
+ if (a === 'add') return addItem(null);
357
+ if (a === 'unlabel') { set({ labels: ed.n.labels.filter((l) => l !== b.dataset.l) }); drawLabels(); return; }
358
+ if (a === 'color') return popColor(b);
359
+ if (a === 'label') return popLabel(b);
360
+ if (a === 'ai') return popAi(b);
361
+ if (a === 'mic') return toggleRec();
362
+ if (a === 'play') return play(b.closest('.nt-au').dataset.a, b);
363
+ if (a === 'voice') return ctx.voiceSheet();
364
+ if (a === 'retry') { try { await api(`/api/notes/${ed.id}/audio/${b.closest('.nt-au').dataset.a}/retry`, { method: 'POST' }); } catch (e) { toast(e.message, 4500); } return; }
365
+ void c;
366
+ }
367
+
368
+ // ---------------------------------------------------------------- finestretes (color, etiquetes, assistent)
369
+ function closePop() { ed?.pop?.remove(); if (ed) ed.pop = null; }
370
+ function pop(anchor, html) {
371
+ closePop();
372
+ const p = document.createElement('div'); p.className = 'nt-pop'; p.innerHTML = html;
373
+ ed.card.append(p); ed.pop = p;
374
+ const a = anchor.getBoundingClientRect(), r = ed.card.getBoundingClientRect(), w = p.offsetWidth;
375
+ 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
+ return p;
377
+ }
378
+ function popColor(b) {
379
+ const p = pop(b, `<div class="nt-colors">${COLORS.map(([k, l]) => `<button type="button" class="nt-dot" data-color="${k}" aria-label="${l}" title="${l}" ${ed.n.color === k ? 'aria-pressed="true"' : ''}></button>`).join('')}</div>`);
380
+ p.addEventListener('click', (e) => { const d = e.target.closest('.nt-dot'); if (d) { set({ color: d.dataset.color }); closePop(); } });
381
+ }
382
+ function popLabel(b) {
383
+ const mine = new Set(ed.n.labels), labs = labelsOf().filter((l) => !mine.has(l));
384
+ const p = pop(b, `<form class="nt-lf"><input type="text" placeholder="Nova etiqueta" maxlength="30" aria-label="Nova etiqueta" autocomplete="off"><button class="btn ink sm" type="submit">Afegeix</button></form>${labs.length ? `<div class="nt-lsug">${labs.map((l) => `<button type="button" class="nt-chip" data-l="${esc(l)}">${esc(l)}</button>`).join('')}</div>` : ''}`);
385
+ const add = (l) => { l = l.trim().toLowerCase(); if (!l || ed.n.labels.includes(l) || ed.n.labels.length >= 12) return; set({ labels: [...ed.n.labels, l] }); drawLabels(); closePop(); };
386
+ $('form', p).onsubmit = (e) => { e.preventDefault(); add($('input', p).value); };
387
+ p.addEventListener('click', (e) => { const c = e.target.closest('[data-l]'); if (c) add(c.dataset.l); });
388
+ setTimeout(() => $('input', p).focus(), 30);
389
+ }
390
+
391
+ // ---------------------------------------------------------------- veu: grava aquí, transcriu l'ordinador
392
+ async function toggleRec() {
393
+ if (!ed) return;
394
+ if (ed.rec) return ed.rec.stop();
395
+ if (ctx.isDemo()) return toast('A la demo no hi ha micròfon. Amb nio instal·lat, transcrius amb Whisper, gratis.', 4500);
396
+ if (!ctx.voiceOn()) { toast('Cal activar Whisper per transcriure (gratis, a l’ordinador).', 3500); return ctx.voiceSheet(); }
397
+ if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) return toast('Aquest navegador no pot gravar àudio.');
398
+ let stream; try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch { return toast('No tinc permís per al micròfon. Activa’l als ajustos del navegador.', 4500); }
399
+ if (!ed) { stream.getTracks().forEach((t) => t.stop()); return; }
400
+ const rec = new MediaRecorder(stream), chunks = [], t0 = Date.now(); let send = true;
401
+ const box = $('#edRec', ed.card), foot = $('#edFoot', ed.card);
402
+ const tick = () => { const s = $('.nt-rec-t', box); if (s) s.textContent = fmtDur((Date.now() - t0) / 1000); };
403
+ box.hidden = false; foot.hidden = true;
404
+ box.innerHTML = `<span class="nt-rec-dot"></span><span class="nt-rec-t">0:00</span><span class="nt-rec-l">Gravant…</span><button class="btn ghost sm" type="button" data-r="cancel">Cancel·la</button><button class="btn ink sm" type="button" data-r="stop">${svg(I.stop)}Atura i transcriu</button>`;
405
+ const timer = setInterval(tick, 500);
406
+ box.onclick = (e) => { const r = e.target.closest('[data-r]')?.dataset.r; if (r === 'cancel') { send = false; rec.stop(); } else if (r === 'stop') rec.stop(); };
407
+ const cleanup = () => { clearInterval(timer); stream.getTracks().forEach((t) => t.stop()); if (ed) { ed.rec = null; box.hidden = true; foot.hidden = false; } };
408
+ rec.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
409
+ rec.onstop = async () => {
410
+ const dur = (Date.now() - t0) / 1000; cleanup();
411
+ if (!send) return;
412
+ const blob = new Blob(chunks, { type: rec.mimeType || 'audio/webm' });
413
+ if (!ed) return;
414
+ try {
415
+ if (blob.size < 1500) throw new Error('No he sentit res. Prova-ho una altra vegada.');
416
+ const id = await ensureId(); if (!id) throw new Error('No s’ha pogut crear la nota');
417
+ 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); }
420
+ };
421
+ ed.rec = { stop: () => rec.stop(), cancel: () => { send = false; try { rec.stop(); } catch { cleanup(); } } };
422
+ rec.start(1000);
423
+ }
424
+ async function play(aid, btn) {
425
+ if (ed.audioEl && ed.audioId === aid) { const a = ed.audioEl; if (a.paused) { a.play(); btn.innerHTML = svg(I.pause); } else { a.pause(); btn.innerHTML = svg(I.play); } return; }
426
+ ed.audioEl?.pause(); $$all('.nt-au [data-e=play]', ed.card).forEach((x) => { x.innerHTML = svg(I.play); });
427
+ try {
428
+ const m = ctx.machine(), r = await fetch(`${m.url}/api/notes/${ed.id}/audio/${aid}`, { headers: { Authorization: `Bearer ${m.token}` } });
429
+ if (!r.ok) throw new Error('No he pogut baixar l’àudio');
430
+ const url = URL.createObjectURL(await r.blob()), a = new Audio(url);
431
+ a.onended = () => { btn.innerHTML = svg(I.play); ed.audioId = null; URL.revokeObjectURL(url); };
432
+ ed.audioEl = a; ed.audioId = aid; await a.play(); btn.innerHTML = svg(I.pause);
433
+ } catch (e) { toast(e.message, 4000); }
434
+ }
435
+
436
+ // ---------------------------------------------------------------- assistent: resumeix i organitza amb un model
437
+ async function loadEngines() { if (!engines) { try { engines = (await api('/api/notes/engines')).engines; } catch { engines = []; } } return engines; }
438
+ async function popAi(b) {
439
+ const p = pop(b, '<p class="muted small nt-pad">Mirant quins models hi ha…</p>');
440
+ const list = await loadEngines(); if (ed?.pop !== p) return;
441
+ if (!list.length) { p.innerHTML = '<p class="small nt-pad">No hi ha cap model en aquest ordinador.<br>Instal·la <b>Ollama</b> i un model (<span class="mono">ollama pull qwen3:4b</span>) o <b>Claude Code</b>.</p>'; return; }
442
+ // Per defecte: el model local més petit de 3 mil milions de paràmetres o més; si no, Claude ràpid; si no, el primer.
443
+ const auto = (list.find((e) => e.local && e.id.startsWith('ollama:') && e.params >= 3) || list.find((e) => e.id === 'claude:haiku') || list[0]).id;
444
+ const saved0 = LSget('nio.notes.engine', ''), saved = list.some((e) => e.id === saved0) ? saved0 : auto, groups = [...new Set(list.map((e) => e.group))];
445
+ const hasText = !!ed.n.text.trim() || !!ed.n.items?.length;
446
+ p.innerHTML = `<label class="nt-pad small"><span class="muted">Model</span><select id="aiEng" class="nt-sel">${groups.map((g) => `<optgroup label="${esc(g)}">${list.filter((e) => e.group === g).map((e) => `<option value="${esc(e.id)}" ${e.id === saved ? 'selected' : ''}>${esc(e.label)}</option>`).join('')}</optgroup>`).join('')}</select></label>
447
+ <div class="nt-acts-list">${ACTIONS.map(([k, l, d]) => `<button type="button" data-ai="${k}" ${hasText ? '' : 'disabled'}><b>${l}</b><span>${d}</span></button>`).join('')}</div>
448
+ <form class="nt-lf" id="aiForm"><input type="text" placeholder="O demana-li qualsevol cosa…" maxlength="300" aria-label="Instrucció pròpia" ${hasText ? '' : 'disabled'}><button class="btn ink sm" type="submit" ${hasText ? '' : 'disabled'}>Envia</button></form>
449
+ <p class="nt-note small muted" id="aiNote"></p>`;
450
+ const eng = $('#aiEng', p), note = $('#aiNote', p);
451
+ const upd = () => { const e = list.find((x) => x.id === eng.value); note.textContent = e?.local ? 'Es queda a l’ordinador: cap dada surt de casa.' : 'Aquest text s’envia al servei del model (Anthropic, OpenAI o núvol d’Ollama).'; };
452
+ eng.onchange = () => { LSset('nio.notes.engine', eng.value); upd(); }; upd();
453
+ p.addEventListener('click', (e) => { const x = e.target.closest('[data-ai]'); if (x && !x.disabled) runAi(x.dataset.ai, eng.value); });
454
+ $('#aiForm', p).onsubmit = (e) => { e.preventDefault(); const v = $('input', p).value.trim(); if (v) runAi('custom', eng.value, v); };
455
+ }
456
+ async function runAi(action, engine, prompt) {
457
+ closePop();
458
+ const e = ed; await flush(); if (!ed || ed !== e) return; if (!e.id) return;
459
+ const box = $('#edAi', e.card); box.hidden = false;
460
+ box.innerHTML = `<div class="nt-ai-run"><i class="nt-spin"></i><span>Treballant amb ${esc(engine.replace(/^ollama:/, ''))}…</span></div>`;
461
+ try {
462
+ const r = await api('/api/notes/ai', { method: 'POST', body: { id: e.id, action, engine, prompt } });
463
+ if (ed !== e) return;
464
+ showAiResult(box, r);
465
+ } catch (er) { if (ed === e) box.innerHTML = `<div class="nt-ai-err"><span>${esc(er.message)}</span><button class="btn ghost sm" type="button" data-x>Tanca</button></div>`; box.onclick = (ev) => { if (ev.target.closest('[data-x]')) box.hidden = true; }; }
466
+ }
467
+ function showAiResult(box, r) {
468
+ const { action, result: x } = r, n = ed.n;
469
+ const body = action === 'tasks' ? (x.items.length ? `<ul>${x.items.map((i) => `<li>${esc(i)}</li>`).join('')}</ul>` : '<p class="muted">No he trobat cap tasca.</p>')
470
+ : action === 'title' ? `<p><b>${esc(x.title)}</b></p>`
471
+ : action === 'organize' ? `<p><b>${esc(x.title)}</b></p><pre>${esc(x.text)}</pre>${x.labels.length ? `<p>${x.labels.map((l) => `<span class="nt-label">${esc(l)}</span>`).join('')}</p>` : ''}`
472
+ : `<pre>${esc(x.text)}</pre>`;
473
+ const label = { summary: 'Resum', clean: 'Transcripció neta', organize: 'Nota organitzada', tasks: 'Tasques', title: 'Títol', custom: 'Resposta' }[action];
474
+ const can = action === 'tasks' ? x.items.length : action === 'title' ? x.title : true;
475
+ box.innerHTML = `<div class="nt-ai-h"><span>${svg(I.spark)}${label}</span><small>${esc(r.engine.replace(/^ollama:/, ''))}${r.local ? ' · local' : ''}</small></div><div class="nt-ai-b">${body}</div>
476
+ <div class="row nt-ai-f"><button class="btn ghost sm" type="button" data-x>Descarta</button>${action === 'summary' || action === 'custom' ? '<button class="btn sm" type="button" data-ap="add">Afegeix al final</button>' : ''}<button class="btn ink sm" type="button" data-ap="apply" ${can ? '' : 'disabled'}>${action === 'summary' ? 'Afegeix a dalt' : 'Aplica'}</button></div>`;
477
+ box.onclick = (ev) => {
478
+ if (ev.target.closest('[data-x]')) { box.hidden = true; return; }
479
+ const ap = ev.target.closest('[data-ap]')?.dataset.ap; if (!ap) return;
480
+ const snap = { title: n.title, text: n.text, items: n.items ? n.items.map((i) => ({ ...i })) : null, labels: [...n.labels] };
481
+ if (action === 'summary') set({ text: ap === 'add' ? `${n.text.trimEnd()}\n\nResum\n${x.text}` : `Resum\n${x.text}\n\n${n.text}`.trimEnd() });
482
+ else if (action === 'custom') set({ text: ap === 'add' ? `${n.text.trimEnd()}\n\n${x.text}` : `${x.text}\n\n${n.text}`.trimEnd() });
483
+ else if (action === 'clean') set({ text: x.text });
484
+ else if (action === 'title') set({ title: x.title });
485
+ else if (action === 'tasks') set({ items: [...(n.items || []), ...x.items.map((t) => ({ id: uid(), text: t, done: false }))] });
486
+ else if (action === 'organize') set({ title: x.title, text: x.text, labels: [...new Set([...n.labels, ...x.labels])].slice(0, 12) });
487
+ const t = ed.card.querySelector('.nt-ed-title'); if (t) t.value = n.title;
488
+ drawBody(); drawLabels(); drawMode();
489
+ box.innerHTML = '<div class="nt-ai-run"><span>Aplicat.</span><button class="btn ghost sm" type="button" data-undo>Desfés</button></div>';
490
+ box.onclick = (e2) => { if (e2.target.closest('[data-undo]')) { set(snap); if (t) t.value = n.title; drawBody(); drawLabels(); drawMode(); box.hidden = true; } };
491
+ };
492
+ }
493
+
494
+ // ---------------------------------------------------------------- el mòbil com a micròfon
495
+ function phoneSheet() {
496
+ ctx.openSheet(`<h2>El mòbil, com a micròfon</h2>
497
+ <div class="stack"><p class="small">Obre nio al mòbil, entra a <b>Notes</b> i prem el micròfon. L’àudio va a aquest ordinador i el transcriu Whisper, gratis i sense sortir de casa. La nota surt aquí a l’instant.</p>
498
+ <ol class="small nt-steps"><li>Connecta el mòbil (un cop).</li><li>Obre <b>Notes</b> i prem <b>${svg(I.mic)}</b>.</li><li>Parla. Prem «Atura i transcriu».</li></ol>
499
+ <div class="row sheet-foot"><button class="btn ghost" type="button" data-close>Tanca</button><button class="btn ink" type="button" data-qr>${svg(I.phone)}Connecta el mòbil</button></div></div>`,
500
+ (sh) => { sh.querySelector('[data-close]').onclick = ctx.closeSheet; sh.querySelector('[data-qr]').onclick = () => { ctx.closeSheet(); setTimeout(ctx.mobileQrSheet, 220); }; });
501
+ }
502
+
503
+ return { mount, unmount, onEvent, reload, get open() { return !!ed; } };
504
+ }
package/web/app/style.css CHANGED
@@ -134,6 +134,9 @@ button.chip:hover { border-color: var(--fg); }
134
134
  .chip.running { background: var(--fg); color: var(--bg); border-color: var(--fg); }
135
135
  .chip.waiting { background: var(--lemon); border-color: var(--lemon); }
136
136
  .chip.error { color: var(--red); border-color: color-mix(in srgb, var(--red) 40%, transparent); }
137
+ .chip.usage { gap: 6px; }
138
+ .chip.usage .uw + .uw::before { content: '·'; margin-right: 6px; color: var(--fg-muted); font-weight: 400; }
139
+ .chip.usage .uw[data-hi="1"] { color: var(--red); font-weight: 600; }
137
140
  .chip.beta { height: 20px; padding: 0 7px; font-size: 10px; color: var(--fg-muted); }
138
141
  .pulse { width: 6px; height: 6px; border-radius: 50%; background: currentColor; animation: pulse 1.2s ease-in-out infinite; }
139
142
  @keyframes pulse { 50% { opacity: .3; } }
@@ -718,3 +721,26 @@ html.rd-open, html.rd-open body { overflow: hidden; }
718
721
  .demo-bar b { color: var(--accent); }
719
722
  body.chat-mode .demo-bar, body.board-mode .demo-bar { display: none; }
720
723
  @media (max-width: 820px) { .demo-bar { margin: 12px 16px 0; } }
724
+
725
+ /* Sense ordinador: seccions de mostra desactivades */
726
+ .panel.off { opacity: .55; }
727
+ .panel.off .btn[disabled] { cursor: not-allowed; }
728
+ .card.off-note { display: flex; flex-direction: row; align-items: center; gap: 12px 16px; flex-wrap: wrap; border-style: dashed; }
729
+
730
+ /* Primer ordinador: ordre d'instal·lació, passos i botons alineats a la mateixa amplada */
731
+ .first-run > p:first-of-type { max-width: 34em; margin-inline: auto; }
732
+ .first-run .cmd { width: 100%; max-width: 640px; margin: 18px auto 0; text-align: left; }
733
+ .first-run .stepper { margin: 22px auto 20px; }
734
+ .first-run > .muted.small { margin-top: 12px; }
735
+ .first-run > .muted.small { max-width: none; }
736
+ .first-run .stepper { max-width: 680px; }
737
+ @media (max-width: 820px) { .first-run .cmd.big code { white-space: normal; overflow-wrap: anywhere; } }
738
+
739
+ /* Mòbil: la barra de dalt ja ocupa 56 px, així que la carcassa no ha de fer tota la pantalla (si no, sobra scroll
740
+ i la conversa puja en obrir-la). El camp de text queda enganxat a baix encara que tingui position: relative més amunt. */
741
+ @media (max-width: 820px) {
742
+ .shell { min-height: calc(100dvh - 56px - var(--safe-t)); }
743
+ body.chat-mode .composer { position: sticky; bottom: var(--kb, 0px); }
744
+ }
745
+ /* Referència per mesurar els missatges (offsetTop) dins la conversa. */
746
+ .feed { position: relative; }
package/web/index.html CHANGED
@@ -20,14 +20,19 @@
20
20
  <meta property="og:url" content="https://www.nio.cat/">
21
21
  <meta property="og:title" content="nio // els teus agents, des del mòbil">
22
22
  <meta property="og:description" content="Claude Code i Codex als teus ordinadors, controlats des del mòbil. Personal, gratuït i amb les teves claus.">
23
- <meta property="og:image" content="https://www.nio.cat/og.png">
23
+ <meta property="og:image" content="https://www.nio.cat/og.jpg">
24
24
  <meta property="og:image:width" content="1200">
25
25
  <meta property="og:image:height" content="630">
26
26
  <meta property="og:image:alt" content="nio">
27
+ <meta property="og:video" content="https://www.nio.cat/og.mp4">
28
+ <meta property="og:video:secure_url" content="https://www.nio.cat/og.mp4">
29
+ <meta property="og:video:type" content="video/mp4">
30
+ <meta property="og:video:width" content="1920">
31
+ <meta property="og:video:height" content="1080">
27
32
  <meta name="twitter:card" content="summary_large_image">
28
33
  <meta name="twitter:title" content="nio // els teus agents, des del mòbil">
29
34
  <meta name="twitter:description" content="Claude Code i Codex als teus ordinadors, controlats des del mòbil. Personal, gratuït i amb les teves claus.">
30
- <meta name="twitter:image" content="https://www.nio.cat/og.png">
35
+ <meta name="twitter:image" content="https://www.nio.cat/og.jpg">
31
36
  <meta name="format-detection" content="telephone=no">
32
37
  <link rel="icon" href="/favicon.svg" type="image/svg+xml">
33
38
  <link rel="apple-touch-icon" href="/apple-touch-icon.png">
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-v38';
3
- const FILES = ['/app', '/app/style.css', '/app/app.js', '/app/demo.js', '/bot.css', '/bot.js', '/board.css', '/board.js', '/favicon.svg', '/manifest.webmanifest'];
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'];
4
4
 
5
5
  self.addEventListener('install', (e) => {
6
6
  e.waitUntil(caches.open(SHELL).then((c) => c.addAll(FILES)).catch(() => {}));