nio-cat 0.9.6 → 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.
@@ -0,0 +1,747 @@
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
+ back: '<path d="m10 5-7 7 7 7M3 12h18"/>',
19
+ plus: '<path d="M12 5v14M5 12h14"/>',
20
+ search: '<circle cx="11" cy="11" r="6.500"/><path d="M16 16l4 4"/>',
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"/>',
22
+ 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"/>',
23
+ play: '<path d="M8 5.500l10 6.500-10 6.500z"/>',
24
+ pause: '<path d="M8 5.500v13M16 5.500v13"/>',
25
+ phone: '<rect x="7" y="3" width="10" height="18" rx="2.500"/><path d="M11 18h2"/>',
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"/>',
28
+ };
29
+ const COLORS = [['', 'Sense color'], ['lemon', 'Llimona'], ['peach', 'Préssec'], ['moss', 'Molsa'], ['sky', 'Cel'], ['lilac', 'Lila'], ['rose', 'Rosa'], ['sand', 'Sorra']];
30
+ const ACTIONS = [
31
+ ['summary', 'Resumeix', 'Punts clau de la nota'],
32
+ ['clean', 'Neteja la transcripció', 'Puntuació, paràgrafs, sense «eh»'],
33
+ ['organize', 'Organitza', 'Títol, ordre i etiquetes'],
34
+ ['tasks', 'Extreu tasques', 'Les accions pendents, en llista'],
35
+ ['title', 'Posa-hi un títol', 'Una frase curta'],
36
+ ];
37
+ const svg = (d) => `<svg class="ico" viewBox="0 0 24 24" aria-hidden="true">${d}</svg>`;
38
+ const uid = () => Math.random().toString(36).slice(2, 10);
39
+ const fmtDur = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
40
+ const LSget = (k, d) => { try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch { return d; } };
41
+ const LSset = (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* sense desar */ } };
42
+ const grow = (ta) => { ta.style.height = 'auto'; ta.style.height = `${ta.scrollHeight}px`; };
43
+ const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
44
+
45
+ export function createNotes(ctx) {
46
+ const { api, esc, toast } = ctx;
47
+ const $ = (s, e = document) => e.querySelector(s);
48
+ let root = null, notes = new Map(), loaded = false, loading = false, engines = null;
49
+ let view = 'notes', q = '', layout = LSget('nio.notes.layout', 'grid');
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
+ });
60
+
61
+ // ---------------------------------------------------------------- dades
62
+ const all = () => [...notes.values()];
63
+ const empty = (n) => !n.title.trim() && !n.text.trim() && !(n.items || []).some((i) => i.text.trim()) && !n.audio.length;
64
+ const labelsOf = () => [...new Set(all().filter((n) => !n.trashed).flatMap((n) => n.labels))].sort((a, b) => a.localeCompare(b, 'ca'));
65
+ const haystack = (n) => `${n.title}\n${n.text}\n${(n.items || []).map((i) => i.text).join('\n')}\n${n.labels.join(' ')}`.toLowerCase();
66
+ function visible() {
67
+ const term = q.trim().toLowerCase();
68
+ return all().filter((n) => {
69
+ if (view === 'trash') { if (!n.trashed) return false; }
70
+ else if (n.trashed) return false;
71
+ else if (view === 'archive') { if (!n.archived) return false; }
72
+ else if (view.startsWith('l:')) { if (!n.labels.includes(view.slice(2))) return false; }
73
+ else if (n.archived && !term) return false;
74
+ return !term || haystack(n).includes(term);
75
+ }).sort((a, b) => (b.pinned - a.pinned) || b.updated - a.updated);
76
+ }
77
+ async function load() {
78
+ if (loading) return; loading = true;
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
+ }
92
+ loading = false; drawAll();
93
+ }
94
+ const upsert = (n) => { const old = notes.get(n.id); notes.set(n.id, n); return old; };
95
+
96
+ // ---------------------------------------------------------------- pàgina
97
+ function mount(el) {
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); }
101
+ root.innerHTML = `<div class="nt">
102
+ <div class="nt-top">
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>
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>`}
105
+ </div>
106
+ <div class="nt-bar">
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>
108
+ <button class="icon-btn" type="button" data-nt="layout" aria-label="Canvia la vista" id="ntLayout"></button>
109
+ </div>
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>
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>
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>
115
+ <div id="ntGrid" class="nt-body"></div>
116
+ </div>`;
117
+ const sig = { signal: aborter.signal };
118
+ $('#ntQ', root).value = q;
119
+ $('#ntQ', root).addEventListener('input', (e) => { q = e.target.value; drawGrid(); }, sig);
120
+ root.addEventListener('click', onClick, 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);
130
+ drawAll();
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
+ }
136
+ }
137
+ function unmount() { aborter?.abort(); clearTimeout(set.t); clearTimeout(pollT); closeEditor(true); root = null; }
138
+ const drawAll = () => { if (!root) return; drawLayout(); drawFilters(); drawGrid(); };
139
+ function drawLayout() { const b = $('#ntLayout', root); if (b) b.innerHTML = svg(layout === 'grid' ? I.rows : I.grid); root.querySelector('.nt').dataset.layout = layout; }
140
+ function drawFilters() {
141
+ if (!root) return;
142
+ const labs = labelsOf(), nTrash = all().filter((n) => n.trashed).length;
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>`;
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>` : ''}`;
145
+ }
146
+ function cardHtml(n) {
147
+ const items = n.items || [];
148
+ const open = items.filter((i) => !i.done), doneN = items.length - open.length;
149
+ const shown = [...open, ...items.filter((i) => i.done)].slice(0, 8);
150
+ const pend = n.audio.filter((a) => a.state === 'transcribing').length, err = n.audio.some((a) => a.state === 'error');
151
+ return `<article class="nt-card" data-id="${n.id}" data-color="${n.color}" tabindex="0" aria-label="${esc(n.title || 'Nota sense títol')}">
152
+ ${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>`}
153
+ ${n.title ? `<h3>${esc(n.title)}</h3>` : ''}
154
+ ${n.text.trim() ? `<div class="nt-txt">${esc(n.text.trim())}</div>` : ''}
155
+ ${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>` : ''}
156
+ ${!n.title && !n.text.trim() && !items.length ? `<div class="nt-txt muted">${n.audio.length ? 'Nota de veu' : 'Nota buida'}</div>` : ''}
157
+ ${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>` : ''}
158
+ ${n.labels.length ? `<div class="nt-labels">${n.labels.map((l) => `<span class="nt-label">${esc(l)}</span>`).join('')}</div>` : ''}
159
+ <div class="nt-acts">${view === 'trash'
160
+ ? `<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>`
161
+ : `<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>
162
+ </article>`;
163
+ }
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
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; }
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; }
171
+ const list = visible();
172
+ $('#ntResults', root).textContent = `${list.length} ${list.length === 1 ? 'nota' : 'notes'}${q ? ` per a «${q}»` : ''}`;
173
+ if (!list.length) {
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>`;
175
+ return;
176
+ }
177
+ const sec = (title, arr) => arr.length ? `${title ? `<h2 class="nt-sec">${title}</h2>` : ''}<div class="nt-grid">${arr.map(cardHtml).join('')}</div>` : '';
178
+ const pinned = view === 'notes' || view.startsWith('l:') ? list.filter((n) => n.pinned) : [];
179
+ const rest = pinned.length ? list.filter((n) => !n.pinned) : list;
180
+ 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);
181
+ }
182
+
183
+ // ---------------------------------------------------------------- clics de la pàgina
184
+ async function onClick(e) {
185
+ if (Date.now() - swipedAt < 400) { e.preventDefault(); e.stopPropagation(); return; }
186
+ const t = e.target.closest('[data-nt]'), card = e.target.closest('.nt-card');
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
+ }
194
+ if (act === 'view') { view = t.dataset.v; drawFilters(); drawGrid(); return; }
195
+ if (act === 'layout') { layout = layout === 'grid' ? 'list' : 'grid'; LSset('nio.notes.layout', layout); drawLayout(); return; }
196
+ if (act === 'phone') return phoneSheet();
197
+ if (act === 'new') return openEditor(null, $('#ntNew', root));
198
+ if (act === 'newlist') { e.stopPropagation(); return openEditor(null, $('#ntNew', root), { list: true }); }
199
+ if (act === 'newmic') { e.stopPropagation(); return openEditor(null, $('#ntNew', root), { mic: true }); }
200
+ 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; }
201
+ if (n && act) {
202
+ e.stopPropagation();
203
+ if (act === 'tick') { const items = n.items.map((i) => (i.id === t.dataset.i ? { ...i, done: t.checked } : i)); return patch(n, { items }); }
204
+ if (act === 'pin') return patch(n, { pinned: !n.pinned });
205
+ 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 }));
206
+ if (act === 'trash') return leave(card, () => patch(n, { trashed: true }), 'Anat a la paperera', () => patch(n, { trashed: false }));
207
+ if (act === 'restore') return patch(n, { trashed: false });
208
+ 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; }
209
+ }
210
+ if (card && n) return openEditor(n.id, card);
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
+ }
261
+ // La targeta s'esvaeix abans de marxar de la llista; el toast permet desfer-ho.
262
+ function leave(card, fn, msg, undo) {
263
+ if (!reduce) { card.style.transition = 'opacity .16s, transform .16s'; card.style.opacity = '0'; card.style.transform = 'scale(.96)'; }
264
+ setTimeout(() => { fn(); toastUndo(msg, undo); }, reduce ? 0 : 150);
265
+ }
266
+ function toastUndo(msg, undo) {
267
+ toast(msg, 4200);
268
+ const t = document.querySelector('#toasts')?.lastElementChild; if (!t || !undo) return;
269
+ const b = document.createElement('button'); b.type = 'button'; b.className = 'nt-undo'; b.textContent = 'Desfés';
270
+ b.addEventListener('pointerdown', (ev) => ev.stopPropagation()); // el toast captura el punter per poder-lo arrossegar
271
+ b.onclick = (ev) => { ev.stopPropagation(); undo(); t.remove(); };
272
+ t.append(' ', b);
273
+ }
274
+ let inflight = 0;
275
+ async function patch(n, fields) {
276
+ Object.assign(n, fields); notes.set(n.id, n); drawAll();
277
+ inflight++;
278
+ try { upsert(await api(`/api/notes/${n.id}`, { method: 'PATCH', body: fields })); } catch (e) { toast(e.message, 4200); load(); } finally { inflight--; }
279
+ }
280
+
281
+ // ---------------------------------------------------------------- esdeveniments en directe (del flux d'nio)
282
+ function onEvent(msg) {
283
+ if (msg.type === 'note-gone') { notes.delete(msg.id); if (ed?.id === msg.id) closeEditor(true); drawAll(); return; }
284
+ const n = msg.note, old = upsert(n); void old;
285
+ if (ed && ed.id === n.id) {
286
+ // Del servidor només entra el que no estem editant ara mateix: l'àudio, la transcripció i els canvis d'altres dispositius.
287
+ const keep = new Set([...Object.keys(ed.dirty), ...Object.keys(ed.savingFields)]);
288
+ const mine = ed.n; for (const k of Object.keys(n)) if (!keep.has(k)) mine[k] = n[k];
289
+ ed.n = mine; notes.set(n.id, mine); syncEditor();
290
+ }
291
+ if (root) { drawFilters(); drawGrid(); }
292
+ if (n.audio?.some((a) => a.state === 'transcribing')) schedule();
293
+ }
294
+ const reload = () => { if (root || loaded) load(); };
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
+
322
+ // ---------------------------------------------------------------- editor
323
+ function openEditor(id, src, opts = {}) {
324
+ if (ed) return;
325
+ const n = id ? notes.get(id) : { id: null, title: '', text: '', items: opts.list ? [] : null, color: '', pinned: false, archived: false, trashed: 0, labels: [], audio: [] };
326
+ if (!n) return;
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);
329
+ const ov = document.createElement('div');
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>`;
331
+ document.body.append(ov); ed.ov = ov; ed.card = ov.firstElementChild;
332
+ buildEditor();
333
+ ov.addEventListener('mousedown', (e) => { if (e.target === ov) 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 });
346
+ animateOpen(ed.card, src);
347
+ if (!n.title && !n.text && !n.items?.length && !opts.mic && !ctx.isPhone()) setTimeout(() => ed && $('.nt-ed-title', ed.card)?.focus(), 60);
348
+ if (opts.list) addItem(null);
349
+ if (opts.mic) toggleRec();
350
+ }
351
+ function animateOpen(card, src) {
352
+ if (reduce) return;
353
+ const phone = matchMedia('(max-width: 700px)').matches;
354
+ 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; }
355
+ const a = src.getBoundingClientRect(), b = card.getBoundingClientRect();
356
+ const dx = a.left - b.left, dy = a.top - b.top, sx = a.width / b.width, sy = Math.max(.2, a.height / b.height);
357
+ card.style.transformOrigin = '0 0';
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)' });
359
+ ed.ov.animate([{ background: 'rgba(39,34,36,0)' }, { background: 'rgba(39,34,36,.32)' }], { duration: 300, easing: 'ease-out' });
360
+ }
361
+ async function closeEditor(instant, fromHistory = false) {
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
+ }
376
+ ed = null; clearTimeout(e.timer); e.rec?.cancel?.(); e.audioEl?.pause();
377
+ const done = () => e.ov.remove();
378
+ if (instant || reduce) done(); else {
379
+ const src = e.id && root ? root.querySelector(`.nt-card[data-id="${e.id}"]`) : null, phone = matchMedia('(max-width: 700px)').matches;
380
+ let kf = [{ transform: 'none', opacity: 1 }, { transform: 'translateY(30px)', opacity: 0 }];
381
+ 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 }]; }
382
+ e.card.style.transformOrigin = '0 0';
383
+ e.card.animate(kf, { duration: 220, easing: 'cubic-bezier(.4,0,.2,1)', fill: 'forwards' });
384
+ e.ov.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 220, fill: 'forwards' }).onfinish = done;
385
+ }
386
+ await flush(e);
387
+ if (e.id && empty(e.n)) { notes.delete(e.id); api(`/api/notes/${e.id}?forever=1`, { method: 'DELETE' }).catch(() => {}); }
388
+ if (root) drawAll();
389
+ if (root) (e.src?.isConnected ? e.src.querySelector('button') || e.src : root.querySelector('[data-nt="new"]'))?.focus({ preventScroll: true });
390
+ }
391
+
392
+ // Desat: s'envia només el que ha canviat, en cua, mig segon després de l'última tecla.
393
+ function set(fields) {
394
+ Object.assign(ed.n, fields); Object.assign(ed.dirty, fields);
395
+ pendingSaves.add(ed); saveStatus(ed, 'Canvis pendents…');
396
+ ed.card.dataset.color = ed.n.color;
397
+ clearTimeout(ed.timer); ed.timer = setTimeout(flush, 450);
398
+ if (ed.id) notes.set(ed.id, ed.n);
399
+ if (root) { clearTimeout(set.t); set.t = setTimeout(() => { drawFilters(); drawGrid(); }, 200); }
400
+ }
401
+ function flush(e = ed, force = false) {
402
+ if (!e) return Promise.resolve(true);
403
+ clearTimeout(e.timer);
404
+ e.chain = e.chain.then(async () => {
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…');
412
+ try {
413
+ if (!e.id) {
414
+ if (!force && empty(e.n)) { pendingSaves.delete(e); e.savingFields = {}; saveStatus(e, 'Nota buida'); return true; }
415
+ const { audio, id, ...body } = e.n; void audio; void id;
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);
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
+ }
430
+ });
431
+ return e.chain;
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
+ }
438
+ const ensureId = async () => { if (!ed.id) await flush(ed, true); return ed.id; };
439
+
440
+ function buildEditor() {
441
+ const n = ed.n, c = ed.card;
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>
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">
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>
446
+ <div class="nt-ed-body" id="edBody"></div>
447
+ <div class="nt-audio" id="edAudio"></div>
448
+ <div class="nt-labels nt-ed-labels" id="edLabels"></div>
449
+ <div class="nt-ai" id="edAi" hidden></div>
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>
458
+ <div class="nt-ed-foot" id="edFoot">
459
+ <div class="nt-tb"><div class="nt-edit-tools">
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>
461
+ <button class="nt-ib" type="button" data-e="mode" id="edMode" title="Llista o text"></button>
462
+ <button class="nt-ib" type="button" data-e="color" aria-label="Color" title="Color">${svg(I.palette)}</button>
463
+ <button class="nt-ib" type="button" data-e="label" aria-label="Etiquetes" title="Etiquetes">${svg(I.tag)}</button>
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>
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">
467
+ <button class="nt-ib" type="button" data-e="archive" aria-label="Arxiva" title="Arxiva">${svg(I.archive)}</button>
468
+ <button class="nt-ib" type="button" data-e="trash" aria-label="Paperera" title="Paperera">${svg(I.trash)}</button>
469
+ <button class="btn ink nt-done" type="button" data-e="done">Fet</button></div>
470
+ </div>
471
+ </div>`;
472
+ const title = $('.nt-ed-title', c);
473
+ title.addEventListener('input', () => set({ title: title.value }));
474
+ title.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); ($('.nt-ed-text', c) || $('.nt-item input[type=text]', c))?.focus(); } });
475
+ c.addEventListener('click', onEditorClick);
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);
502
+ }
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'); } }
504
+ function drawBody() {
505
+ const n = ed.n, box = $('#edBody', ed.card);
506
+ if (!n.items) {
507
+ box.innerHTML = '<textarea class="nt-ed-text" placeholder="Escriu una nota…" aria-label="Text de la nota" rows="3"></textarea>';
508
+ const ta = $('textarea', box); ta.value = n.text; grow(ta);
509
+ ta.addEventListener('input', () => { grow(ta); set({ text: ta.value }); });
510
+ return;
511
+ }
512
+ const open = n.items.filter((i) => !i.done), done = n.items.filter((i) => i.done);
513
+ 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>`;
514
+ 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>` : ''}`;
515
+ const d = $('.nt-desc', box); if (d) { d.value = n.text; grow(d); d.addEventListener('input', () => { grow(d); set({ text: d.value }); }); }
516
+ $$all('.nt-item input[type=text]', box).forEach((inp) => {
517
+ const id = inp.closest('.nt-item').dataset.i;
518
+ inp.addEventListener('input', () => set({ items: ed.n.items.map((x) => (x.id === id ? { ...x, text: inp.value } : x)) }));
519
+ inp.addEventListener('keydown', (e) => {
520
+ if (e.key === 'Enter') { e.preventDefault(); addItem(id); }
521
+ else if (e.key === 'Backspace' && !inp.value) { e.preventDefault(); const prev = inp.closest('.nt-item').previousElementSibling; removeItem(id); prev?.querySelector('input[type=text]')?.focus(); }
522
+ });
523
+ });
524
+ }
525
+ const $$all = (s, e) => [...e.querySelectorAll(s)];
526
+ function addItem(afterId) {
527
+ const it = { id: uid(), text: '', done: false }, arr = [...ed.n.items], i = afterId ? arr.findIndex((x) => x.id === afterId) : arr.length - 1;
528
+ arr.splice(i + 1, 0, it); set({ items: arr }); drawBody(); $(`.nt-item[data-i="${it.id}"] input[type=text]`, ed.card)?.focus();
529
+ }
530
+ function removeItem(id) { set({ items: ed.n.items.filter((x) => x.id !== id) }); drawBody(); }
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(); }
534
+ function drawAudio() {
535
+ const box = $('#edAudio', ed.card), list = ed.n.audio;
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}">
538
+ <button type="button" class="nt-ib sm" data-e="play" aria-label="Escolta">${svg(I.play)}</button>
539
+ <span class="nt-au-d">${a.dur ? fmtDur(a.dur) : 'Àudio'}</span>
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>
541
+ ${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('');
542
+ }
543
+ // Canvis que arriben de fora (transcripció acabada, un altre dispositiu).
544
+ function syncEditor() {
545
+ const n = ed.n, c = ed.card;
546
+ drawAudio(); drawLabels(); drawMode(); c.dataset.color = n.color;
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();
554
+ if (!n.items && !ta) drawBody();
555
+ $('[data-e="pin"]', c).classList.toggle('on', n.pinned);
556
+ }
557
+
558
+ async function onEditorClick(ev) {
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; }
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; }
565
+ if (!['color', 'label', 'ai', 'aiengine'].includes(a) && !ev.target.closest('.nt-pop')) closePop();
566
+ if (a === 'done') return closeEditor();
567
+ if (a === 'pin') { set({ pinned: !ed.n.pinned }); b.classList.toggle('on', ed.n.pinned); b.setAttribute('aria-pressed', ed.n.pinned); return; }
568
+ if (a === 'archive') { set({ archived: !ed.n.archived, pinned: false }); toast(ed.n.archived ? 'Arxivada' : 'Desarxivada'); return closeEditor(); }
569
+ if (a === 'trash') { set({ trashed: true }); toast('Anat a la paperera'); return closeEditor(); }
570
+ if (a === 'mode') {
571
+ const n = ed.n;
572
+ if (n.items) set({ text: [n.text, ...n.items.map((i) => i.text)].filter((s) => s.trim()).join('\n'), items: null });
573
+ 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); }
574
+ drawMode(); drawBody(); return;
575
+ }
576
+ 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; }
577
+ if (a === 'rm') return removeItem(b.closest('.nt-item').dataset.i);
578
+ if (a === 'add') return addItem(null);
579
+ if (a === 'unlabel') { set({ labels: ed.n.labels.filter((l) => l !== b.dataset.l) }); drawLabels(); return; }
580
+ if (a === 'color') return popColor(b);
581
+ if (a === 'label') return popLabel(b);
582
+ if (a === 'ai') return popAi(b);
583
+ if (a === 'more') return openMore();
584
+ if (a === 'mic') return toggleRec();
585
+ if (a === 'play') return play(b.closest('.nt-au').dataset.a, b);
586
+ if (a === 'voice') return ctx.voiceSheet();
587
+ 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; }
588
+ void c;
589
+ }
590
+
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
+ }
610
+ function closePop() { ed?.pop?.remove(); if (ed) ed.pop = null; }
611
+ function pop(anchor, html) {
612
+ closePop();
613
+ const p = document.createElement('div'); p.className = 'nt-pop'; p.innerHTML = html;
614
+ ed.card.append(p); ed.pop = p;
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`;
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`;
618
+ return p;
619
+ }
620
+ function popColor(b) {
621
+ 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>`);
622
+ p.addEventListener('click', (e) => { const d = e.target.closest('.nt-dot'); if (d) { set({ color: d.dataset.color }); closePop(); } });
623
+ }
624
+ function popLabel(b) {
625
+ const mine = new Set(ed.n.labels), labs = labelsOf().filter((l) => !mine.has(l));
626
+ 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>` : ''}`);
627
+ 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(); };
628
+ $('form', p).onsubmit = (e) => { e.preventDefault(); add($('input', p).value); };
629
+ p.addEventListener('click', (e) => { const c = e.target.closest('[data-l]'); if (c) add(c.dataset.l); });
630
+ setTimeout(() => $('input', p).focus(), 30);
631
+ }
632
+
633
+ // ---------------------------------------------------------------- veu: grava aquí, transcriu l'ordinador
634
+ async function toggleRec() {
635
+ if (!ed) return;
636
+ if (ed.rec) return ed.rec.stop();
637
+ if (ctx.isDemo()) return toast('A la demo no hi ha micròfon. Amb nio instal·lat, transcrius amb Whisper, gratis.', 4500);
638
+ if (!ctx.voiceOn()) { toast('Cal activar Whisper per transcriure (gratis, a l’ordinador).', 3500); return ctx.voiceSheet(); }
639
+ if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) return toast('Aquest navegador no pot gravar àudio.');
640
+ 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); }
641
+ if (!ed) { stream.getTracks().forEach((t) => t.stop()); return; }
642
+ const rec = new MediaRecorder(stream), chunks = [], t0 = Date.now(); let send = true;
643
+ const box = $('#edRec', ed.card), foot = $('#edFoot', ed.card);
644
+ const tick = () => { const s = $('.nt-rec-t', box); if (s) s.textContent = fmtDur((Date.now() - t0) / 1000); };
645
+ box.hidden = false; foot.hidden = true;
646
+ 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>`;
647
+ const timer = setInterval(tick, 500);
648
+ 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(); };
649
+ const cleanup = () => { clearInterval(timer); stream.getTracks().forEach((t) => t.stop()); if (ed) { ed.rec = null; box.hidden = true; foot.hidden = false; } };
650
+ rec.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
651
+ rec.onstop = async () => {
652
+ const dur = (Date.now() - t0) / 1000; cleanup();
653
+ if (!send) return;
654
+ const blob = new Blob(chunks, { type: rec.mimeType || 'audio/webm' });
655
+ if (!ed) return;
656
+ try {
657
+ if (blob.size < 1500) throw new Error('No he sentit res. Prova-ho una altra vegada.');
658
+ ed.uploading = true; drawAudio();
659
+ const id = await ensureId(); if (!id) throw new Error('No s’ha pogut crear la nota');
660
+ const res = await ctx.nodeAudio(`/api/notes/${id}/audio?dur=${Math.round(dur)}`, { headers: { 'content-type': blob.type || 'audio/webm' }, body: blob });
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); }
663
+ };
664
+ ed.rec = { stop: () => rec.stop(), cancel: () => { send = false; try { rec.stop(); } catch { cleanup(); } } };
665
+ rec.start(1000);
666
+ }
667
+ async function play(aid, btn) {
668
+ 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; }
669
+ ed.audioEl?.pause(); $$all('.nt-au [data-e=play]', ed.card).forEach((x) => { x.innerHTML = svg(I.play); });
670
+ try {
671
+ const m = ctx.machine(), r = await fetch(`${m.url}/api/notes/${ed.id}/audio/${aid}`, { headers: { Authorization: `Bearer ${m.token}` } });
672
+ if (!r.ok) throw new Error('No he pogut baixar l’àudio');
673
+ const url = URL.createObjectURL(await r.blob()), a = new Audio(url);
674
+ a.onended = () => { btn.innerHTML = svg(I.play); ed.audioId = null; URL.revokeObjectURL(url); };
675
+ ed.audioEl = a; ed.audioId = aid; await a.play(); btn.innerHTML = svg(I.pause);
676
+ } catch (e) { toast(e.message, 4000); }
677
+ }
678
+
679
+ // ---------------------------------------------------------------- assistent: resumeix i organitza amb un model
680
+ async function loadEngines() { if (!engines) { try { engines = (await api('/api/notes/engines')).engines; } catch { engines = []; } } return engines; }
681
+ async function popAi(b) {
682
+ const p = pop(b, '<p class="muted small nt-pad">Mirant quins models hi ha…</p>');
683
+ const list = await loadEngines(); if (ed?.pop !== p) return;
684
+ 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; }
685
+ // 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.
686
+ const auto = (list.find((e) => e.local && e.id.startsWith('ollama:') && e.params >= 3) || list.find((e) => e.id === 'claude:haiku') || list[0]).id;
687
+ const saved0 = LSget('nio.notes.engine', ''), saved = list.some((e) => e.id === saved0) ? saved0 : auto, groups = [...new Set(list.map((e) => e.group))];
688
+ const hasText = !!ed.n.text.trim() || !!ed.n.items?.length;
689
+ 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>
690
+ <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>
691
+ <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>
692
+ <p class="nt-note small muted" id="aiNote"></p>`;
693
+ const eng = $('#aiEng', p), note = $('#aiNote', p);
694
+ 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).'; };
695
+ eng.onchange = () => { LSset('nio.notes.engine', eng.value); upd(); }; upd();
696
+ p.addEventListener('click', (e) => { const x = e.target.closest('[data-ai]'); if (x && !x.disabled) runAi(x.dataset.ai, eng.value); });
697
+ $('#aiForm', p).onsubmit = (e) => { e.preventDefault(); const v = $('input', p).value.trim(); if (v) runAi('custom', eng.value, v); };
698
+ }
699
+ async function runAi(action, engine, prompt) {
700
+ closePop();
701
+ const e = ed; await flush(); if (!ed || ed !== e) return; if (!e.id) return;
702
+ const box = $('#edAi', e.card); box.hidden = false;
703
+ box.innerHTML = `<div class="nt-ai-run"><i class="nt-spin"></i><span>Treballant amb ${esc(engine.replace(/^ollama:/, ''))}…</span></div>`;
704
+ try {
705
+ const r = await api('/api/notes/ai', { method: 'POST', body: { id: e.id, action, engine, prompt } });
706
+ if (ed !== e) return;
707
+ showAiResult(box, r);
708
+ } 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; }; }
709
+ }
710
+ function showAiResult(box, r) {
711
+ const { action, result: x } = r, n = ed.n;
712
+ 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>')
713
+ : action === 'title' ? `<p><b>${esc(x.title)}</b></p>`
714
+ : 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>` : ''}`
715
+ : `<pre>${esc(x.text)}</pre>`;
716
+ const label = { summary: 'Resum', clean: 'Transcripció neta', organize: 'Nota organitzada', tasks: 'Tasques', title: 'Títol', custom: 'Resposta' }[action];
717
+ const can = action === 'tasks' ? x.items.length : action === 'title' ? x.title : true;
718
+ 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>
719
+ <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>`;
720
+ box.onclick = (ev) => {
721
+ if (ev.target.closest('[data-x]')) { box.hidden = true; return; }
722
+ const ap = ev.target.closest('[data-ap]')?.dataset.ap; if (!ap) return;
723
+ const snap = { title: n.title, text: n.text, items: n.items ? n.items.map((i) => ({ ...i })) : null, labels: [...n.labels] };
724
+ if (action === 'summary') set({ text: ap === 'add' ? `${n.text.trimEnd()}\n\nResum\n${x.text}` : `Resum\n${x.text}\n\n${n.text}`.trimEnd() });
725
+ else if (action === 'custom') set({ text: ap === 'add' ? `${n.text.trimEnd()}\n\n${x.text}` : `${x.text}\n\n${n.text}`.trimEnd() });
726
+ else if (action === 'clean') set({ text: x.text });
727
+ else if (action === 'title') set({ title: x.title });
728
+ else if (action === 'tasks') set({ items: [...(n.items || []), ...x.items.map((t) => ({ id: uid(), text: t, done: false }))] });
729
+ else if (action === 'organize') set({ title: x.title, text: x.text, labels: [...new Set([...n.labels, ...x.labels])].slice(0, 12) });
730
+ const t = ed.card.querySelector('.nt-ed-title'); if (t) t.value = n.title;
731
+ drawBody(); drawLabels(); drawMode();
732
+ box.innerHTML = '<div class="nt-ai-run"><span>Aplicat.</span><button class="btn ghost sm" type="button" data-undo>Desfés</button></div>';
733
+ box.onclick = (e2) => { if (e2.target.closest('[data-undo]')) { set(snap); if (t) t.value = n.title; drawBody(); drawLabels(); drawMode(); box.hidden = true; } };
734
+ };
735
+ }
736
+
737
+ // ---------------------------------------------------------------- el mòbil com a micròfon
738
+ function phoneSheet() {
739
+ ctx.openSheet(`<h2>El mòbil, com a micròfon</h2>
740
+ <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>
741
+ <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>
742
+ <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>`,
743
+ (sh) => { sh.querySelector('[data-close]').onclick = ctx.closeSheet; sh.querySelector('[data-qr]').onclick = () => { ctx.closeSheet(); setTimeout(ctx.mobileQrSheet, 220); }; });
744
+ }
745
+
746
+ return { mount, unmount, onEvent, reload, get open() { return !!ed; } };
747
+ }