1<!doctype html>
2<html lang="en">
3<head>
4<meta charset="utf-8">
5<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6<title>Anime Log</title>
7<link rel="preconnect" href="https://fonts.googleapis.com">
8<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&family=Zen+Kaku+Gothic+New:wght@700;900&display=swap">
10<script>
11 // Where the data lives.
12 const CONFIG = {
13 SNIPS: 'https://snips.loken.nl',
14 EXPORT_ID: 67, // Anime-Planet list export, pasted as-is
15 NOTES_ID: 68, // Own notes: comments, types, dates, linked by A-P title
16 };
17</script>
18<style>
19:root {
20 color-scheme: light;
21 --ground: #f4f5f8;
22 --surface: #ffffff;
23 --ink: #1a1d26;
24 --muted: #5d6374;
25 --faint: #8a90a0;
26 --line: #e0e3ea;
27 --accent: #2b59c3;
28 --accent-soft: #e6ecfa;
29 --s-watched: #1f7a4d;
30 --s-watching: #2b59c3;
31 --s-dropped: #b42318;
32 --s-stalled: #a15c07;
33 --s-want: #6b3fa0;
34 --s-wont: #6b7080;
35 --warn-bg: #fdf3e1;
36 --warn-ink: #8a4b00;
37 --display: "Zen Kaku Gothic New", "Hiragino Sans", "Noto Sans JP", system-ui, sans-serif;
38 --body: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
39 --mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
40}
41@media (prefers-color-scheme: dark) {
42 :root:not([data-theme="light"]) {
43 color-scheme: dark;
44 --ground: #111319;
45 --surface: #191c24;
46 --ink: #e6e8ee;
47 --muted: #a0a6b5;
48 --faint: #737a8b;
49 --line: #2a2e3a;
50 --accent: #86a8ff;
51 --accent-soft: #1f2a47;
52 --s-watched: #5cc28f;
53 --s-watching: #86a8ff;
54 --s-dropped: #f07b70;
55 --s-stalled: #e0a54a;
56 --s-want: #b99be6;
57 --s-wont: #8d93a3;
58 --warn-bg: #3a2a10;
59 --warn-ink: #f1c27a;
60 }
61}
62:root[data-theme="dark"] {
63 color-scheme: dark;
64 --ground: #111319;
65 --surface: #191c24;
66 --ink: #e6e8ee;
67 --muted: #a0a6b5;
68 --faint: #737a8b;
69 --line: #2a2e3a;
70 --accent: #86a8ff;
71 --accent-soft: #1f2a47;
72 --s-watched: #5cc28f;
73 --s-watching: #86a8ff;
74 --s-dropped: #f07b70;
75 --s-stalled: #e0a54a;
76 --s-want: #b99be6;
77 --s-wont: #8d93a3;
78 --warn-bg: #3a2a10;
79 --warn-ink: #f1c27a;
80}
81* { box-sizing: border-box; }
82html { padding-block: env(safe-area-inset-top, 0px) env(safe-area-inset-bottom, 0px); }
83body {
84 margin: 0; background: var(--ground); color: var(--ink);
85 font: 15px/1.5 var(--body);
86 padding-inline: 16px;
87}
88[hidden] { display: none !important; }
89.wrap { max-width: 1080px; margin: 0 auto; padding-block: 32px 64px; display: grid; gap: 24px; }
90a { color: inherit; }
91button, input, select, textarea { font: inherit; color: inherit; }
92:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
93
94/* Header */
95header { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 12px 24px; }
96h1 { font: 900 clamp(28px, 5vw, 40px)/1 var(--display); margin: 0; letter-spacing: -0.01em; text-wrap: balance; }
97h1 small { font: 500 13px var(--mono); color: var(--faint); letter-spacing: 0; margin-left: 8px; vertical-align: middle; }
98.summary { color: var(--muted); font-size: 14px; margin: 6px 0 0; }
99.summary b { color: var(--ink); font-weight: 600; font-variant-numeric: tabular-nums; }
100.head-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
101
102.btn {
103 border: 1px solid var(--line); background: var(--surface); border-radius: 6px;
104 padding: 6px 12px; cursor: pointer; font-size: 14px;
105}
106.btn:hover { border-color: var(--faint); }
107.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--surface); font-weight: 600; }
108.btn.danger { color: var(--s-dropped); }
109.btn.small { padding: 2px 8px; font-size: 13px; }
110.editing-badge { font: 500 12px var(--mono); color: var(--accent); background: var(--accent-soft); padding: 3px 8px; border-radius: 4px; }
111
112/* Year strip */
113.years { display: grid; gap: 6px; }
114.years-label { font: 500 11px var(--mono); text-transform: uppercase; letter-spacing: 0.08em; color: var(--faint); display: flex; justify-content: space-between; }
115.years-bars { display: flex; align-items: end; gap: 3px; height: 72px; overflow-x: auto; padding-bottom: 2px; }
116.ybar { flex: 1 0 22px; display: flex; flex-direction: column; justify-content: end; align-items: center; gap: 4px; height: 100%; background: none; border: 0; padding: 0; cursor: pointer; color: var(--faint); }
117.ybar i { display: block; width: 100%; background: var(--line); border-radius: 2px 2px 0 0; min-height: 2px; }
118.ybar span { font: 400 10px var(--mono); }
119.ybar:hover i { background: var(--faint); }
120.ybar.on i { background: var(--accent); }
121.ybar.on span { color: var(--accent); font-weight: 500; }
122
123/* Controls */
124.controls { display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: center; background: var(--ground); padding-block: 10px; border-bottom: 1px solid var(--line); }
125.search { flex: 1 1 240px; min-width: 0; border: 1px solid var(--line); background: var(--surface); border-radius: 6px; padding: 7px 12px; }
126.chips { display: flex; flex-wrap: wrap; gap: 6px; }
127.chip { border: 1px solid var(--line); background: var(--surface); border-radius: 999px; padding: 3px 10px; font-size: 13px; cursor: pointer; display: inline-flex; gap: 6px; align-items: center; }
128.chip em { font: normal 400 12px var(--mono); color: var(--faint); }
129.chip[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); }
130.chip[aria-pressed="true"] em { color: inherit; }
131.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--c, var(--faint)); }
132select.sort { border: 1px solid var(--line); background: var(--surface); border-radius: 6px; padding: 6px 8px; font-size: 14px; }
133
134/* List */
135.list { display: grid; }
136.row {
137 display: grid; grid-template-columns: 92px minmax(0, 1fr) auto; gap: 4px 16px;
138 padding-block: 12px; border-bottom: 1px solid var(--line); align-items: start;
139}
140.when { font: 400 13px/1.6 var(--mono); color: var(--faint); font-variant-numeric: tabular-nums; }
141.title { font-weight: 600; text-decoration: none; }
142a.title:hover { color: var(--accent); text-decoration: underline; }
143.title-line { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; }
144.meta { display: flex; gap: 12px; align-items: baseline; justify-content: end; white-space: nowrap; font-size: 13px; }
145.status { display: inline-flex; align-items: center; gap: 6px; color: var(--c); font-weight: 500; }
146.stars { font: 500 13px var(--mono); color: var(--ink); font-variant-numeric: tabular-nums; }
147.stars::before { content: "★ "; color: var(--s-stalled); }
148.eps { font: 400 12px var(--mono); color: var(--faint); }
149.orphan { font: 500 11px var(--mono); color: var(--faint); border: 1px dashed var(--line); padding: 1px 6px; border-radius: 4px; }
150.check { font: 500 11px var(--mono); color: var(--warn-ink); background: var(--warn-bg); padding: 1px 6px; border-radius: 4px; }
151.notes { grid-column: 2 / -1; display: grid; gap: 4px; margin: 2px 0 0; padding: 0; list-style: none; }
152.note { display: flex; flex-wrap: wrap; gap: 2px 10px; align-items: baseline; color: var(--muted); font-size: 14px; }
153.note .d { font: 400 12px var(--mono); color: var(--faint); }
154.note .t { font: 500 11px var(--mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); border: 1px solid var(--line); padding: 0 5px; border-radius: 3px; }
155.note .c { color: var(--ink); }
156.note .alias { font-style: italic; }
157.note .btn { margin-left: auto; }
158.btn.add { color: var(--muted); }
159.more { justify-self: center; margin-top: 16px; }
160.empty, .state { color: var(--muted); padding: 40px 0; text-align: center; }
161.state.error { color: var(--s-dropped); }
162
163@media (min-width: 641px) {
164 .controls { position: sticky; top: env(safe-area-inset-top, 0px); z-index: 2; }
165}
166@media (max-width: 640px) {
167 .row { grid-template-columns: minmax(0, 1fr); }
168 .when { order: 2; }
169 .meta { justify-content: start; order: 3; }
170 .notes { grid-column: 1; order: 4; }
171}
172
173/* Dialogs */
174dialog { border: 1px solid var(--line); border-radius: 10px; background: var(--surface); color: var(--ink); padding: 0; width: min(520px, calc(100vw - 32px)); }
175dialog::backdrop { background: rgb(10 12 18 / 0.45); }
176dialog form { display: grid; gap: 14px; padding: 20px; }
177dialog h2 { font: 700 20px var(--display); margin: 0; }
178dialog p { margin: 0; color: var(--muted); font-size: 14px; }
179.field { display: grid; gap: 4px; }
180.field label { font: 500 11px var(--mono); text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }
181.field input, .field textarea, .field select { border: 1px solid var(--line); background: var(--ground); border-radius: 6px; padding: 7px 10px; width: 100%; }
182.field textarea { min-height: 72px; resize: vertical; }
183.two { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
184.dlg-actions { display: flex; gap: 8px; justify-content: end; flex-wrap: wrap; }
185.dlg-actions .spacer { flex: 1; }
186.form-error { color: var(--s-dropped); font-size: 14px; }
187
188.toast { position: fixed; left: 50%; bottom: calc(20px + env(safe-area-inset-bottom, 0px)); transform: translateX(-50%); background: var(--ink); color: var(--ground); padding: 8px 14px; border-radius: 6px; font-size: 14px; z-index: 10; max-width: calc(100vw - 32px); }
189@media (prefers-reduced-motion: no-preference) { .toast { transition: opacity .2s; } }
190</style>
191</head>
192<body>
193<div class="wrap">
194 <header>
195 <div>
196 <h1>Anime Log <small id="since"></small></h1>
197 <p class="summary" id="summary">Loading…</p>
198 </div>
199 <div class="head-actions">
200 <span class="editing-badge" id="editing-badge" hidden>Editing</span>
201 <button class="btn" id="btn-import" type="button" hidden>Update A-P export</button>
202 <input type="file" id="file-import" accept="application/json,.json" hidden>
203 <button class="btn" id="btn-add" type="button" hidden>Add note</button>
204 <button class="btn" id="btn-edit" type="button">Edit</button>
205 </div>
206 </header>
207
208 <section class="years" aria-label="Notes per year">
209 <div class="years-label"><span>Notes per year</span><span id="year-hint">Click a year to filter</span></div>
210 <div class="years-bars" id="years"></div>
211 </section>
212
213 <div class="controls">
214 <input class="search" id="q" type="search" placeholder="Search titles and comments" aria-label="Search">
215 <div class="chips" id="status-chips" role="group" aria-label="Anime-Planet status"></div>
216 <div class="chips" id="flag-chips" role="group" aria-label="Filters"></div>
217 <select class="sort" id="sort" aria-label="Sort">
218 <option value="date">Newest note first</option>
219 <option value="oldest">Oldest note first</option>
220 <option value="title">Title A–Z</option>
221 <option value="rating">Highest rated</option>
222 </select>
223 </div>
224
225 <div class="list" id="list"><div class="state">Loading the list…</div></div>
226 <button class="btn more" id="more" type="button" hidden>Show more</button>
227</div>
228
229<dialog id="dlg-key">
230 <form method="dialog" id="form-key">
231 <h2>Start editing</h2>
232 <p>Paste your Snips API key (Profile → API key). It stays in this browser only.</p>
233 <div class="field"><label for="key">API key</label><input id="key" type="password" autocomplete="off" required></div>
234 <div class="form-error" id="key-error" hidden></div>
235 <div class="dlg-actions">
236 <button class="btn" value="cancel" formnovalidate>Cancel</button>
237 <button class="btn primary" id="key-submit" value="ok">Start editing</button>
238 </div>
239 </form>
240</dialog>
241
242<dialog id="dlg-note">
243 <form method="dialog" id="form-note">
244 <h2 id="note-heading">Edit note</h2>
245 <div class="field">
246 <label for="n-ap">Anime-Planet title</label>
247 <input id="n-ap" list="ap-titles" placeholder="Leave empty if it isn't on Anime-Planet">
248 <datalist id="ap-titles"></datalist>
249 </div>
250 <div class="field"><label for="n-name">Name in your notes</label><input id="n-name" placeholder="Optional, e.g. the original title"></div>
251 <div class="two">
252 <div class="field"><label for="n-date">Date</label><input id="n-date" type="date"></div>
253 <div class="field"><label for="n-type">Type</label><input id="n-type" list="types"><datalist id="types"></datalist></div>
254 </div>
255 <div class="field"><label for="n-comment">Comment</label><textarea id="n-comment"></textarea></div>
256 <div class="form-error" id="note-error" hidden></div>
257 <div class="dlg-actions">
258 <button class="btn danger" id="note-delete" type="button">Delete</button>
259 <span class="spacer"></span>
260 <button class="btn" value="cancel" formnovalidate>Cancel</button>
261 <button class="btn primary" id="note-save" value="ok">Save</button>
262 </div>
263 </form>
264</dialog>
265
266<div class="toast" id="toast" hidden></div>
267
268<script>
269const STATUS = {
270 'watched': { label: 'Watched', c: 'var(--s-watched)' },
271 'watching': { label: 'Watching', c: 'var(--s-watching)' },
272 'stalled': { label: 'Stalled', c: 'var(--s-stalled)' },
273 'dropped': { label: 'Dropped', c: 'var(--s-dropped)' },
274 'want to watch': { label: 'Want to watch', c: 'var(--s-want)' },
275 "won't watch": { label: "Won't watch", c: 'var(--s-wont)' },
276};
277const FLAGS = {
278 notes: { label: 'With notes', test: r => r.notes.length > 0 },
279 check: { label: 'Needs check', test: r => r.notes.some(n => n.check) },
280 orphan: { label: 'Not on A-P', test: r => !r.ap },
281};
282const PAGE = 150;
283
284const state = {
285 exp: null, notes: null, notesRaw: '', rows: [],
286 q: '', statuses: new Set(), flags: new Set(), year: null, sort: 'date', limit: PAGE,
287 key: null, editing: null,
288};
289
290const $ = id => document.getElementById(id);
291const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
292const norm = s => String(s ?? '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase();
293
294// Anime-Planet URL slug, derived from the title (the export has no ids).
295const apSlug = name => norm(name).replace(/'/g, '').replace(/\+/g, ' plus ').replace(/&/g, ' and ')
296 .replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
297
298const store = {
299 get(k) { try { return localStorage.getItem(k); } catch { return null; } },
300 set(k, v) { try { v == null ? localStorage.removeItem(k) : localStorage.setItem(k, v); } catch {} },
301};
302
303function toast(msg) {
304 const t = $('toast');
305 t.textContent = msg; t.hidden = false;
306 clearTimeout(toast.timer);
307 toast.timer = setTimeout(() => { t.hidden = true; }, 3500);
308}
309
310async function api(path, { method = 'GET', body, key = state.key } = {}) {
311 const headers = {};
312 if (key) headers['X-AUTH-TOKEN'] = key;
313 if (body) headers['Content-Type'] = 'application/json';
314 const res = await fetch(CONFIG.SNIPS + '/api' + path, { method, headers, body: body && JSON.stringify(body) });
315 const json = await res.json().catch(() => null);
316 if (!res.ok || !json?.success) {
317 throw new Error(json?.data?.message || json?.message || `Snips answered ${res.status}`);
318 }
319 return json.data;
320}
321
322async function load() {
323 const [exp, notes] = await Promise.all([api(`/snip/${CONFIG.EXPORT_ID}`, { key: null }), api(`/snip/${CONFIG.NOTES_ID}`, { key: null })]);
324 state.exp = JSON.parse(exp.content);
325 state.notesRaw = notes.content;
326 state.notes = JSON.parse(notes.content);
327 build();
328}
329
330// One row per Anime-Planet entry, with the notes linked to it; notes without a match get their own row.
331function build() {
332 const byName = new Map();
333 const rows = state.exp.entries.map(e => {
334 const r = { ap: e, notes: [] };
335 byName.set(e.name, r);
336 return r;
337 });
338 for (const n of state.notes.entries) {
339 const r = n.ap && byName.get(n.ap);
340 if (r) r.notes.push(n);
341 else rows.push({ ap: null, notes: [n] });
342 }
343 for (const r of rows) {
344 r.notes.sort((a, b) => (a.date || '').localeCompare(b.date || ''));
345 const dates = r.notes.map(n => n.date).filter(Boolean);
346 const apDate = r.ap?.completed || r.ap?.started;
347 r.date = dates.length ? dates[dates.length - 1] : (apDate ? apDate.slice(0, 10) : '');
348 r.first = dates.length ? dates[0] : (apDate ? apDate.slice(0, 10) : '');
349 r.title = r.ap?.name || r.notes[0]?.name || '';
350 r.hay = norm([r.title, ...r.notes.flatMap(n => [n.name, n.comment, n.type])].join(' '));
351 }
352 state.rows = rows;
353
354 $('ap-titles').innerHTML = state.exp.entries.map(e => `<option value="${esc(e.name)}">`).join('');
355 const types = [...new Set(state.notes.entries.map(n => n.type).filter(Boolean))].sort();
356 $('types').innerHTML = types.map(t => `<option value="${esc(t)}">`).join('');
357
358 renderSummary();
359 renderYears();
360 renderChips();
361 render();
362}
363
364function renderSummary() {
365 const n = state.notes.entries;
366 const years = n.map(x => x.date?.slice(0, 4)).filter(Boolean).sort();
367 const linked = n.filter(x => x.ap && state.rows.some(r => r.ap?.name === x.ap)).length;
368 const watched = state.exp.entries.filter(e => e.status === 'watched').length;
369 $('since').textContent = years.length ? `since ${years[0]}` : '';
370 $('summary').innerHTML =
371 `<b>${state.exp.entries.length}</b> titles on Anime-Planet, <b>${watched}</b> watched · ` +
372 `<b>${n.length}</b> notes, <b>${linked}</b> linked · export from ${esc(state.exp.export.date.slice(0, 10))}`;
373}
374
375function renderYears() {
376 const counts = {};
377 for (const n of state.notes.entries) {
378 const y = n.date?.slice(0, 4);
379 if (y) counts[y] = (counts[y] || 0) + 1;
380 }
381 const ys = Object.keys(counts).map(Number);
382 if (!ys.length) { $('years').innerHTML = ''; return; }
383 const max = Math.max(...Object.values(counts));
384 let html = '';
385 for (let y = Math.min(...ys); y <= Math.max(...ys); y++) {
386 const c = counts[y] || 0;
387 html += `<button class="ybar${state.year === String(y) ? ' on' : ''}" type="button" data-year="${y}" title="${y}: ${c} ${c === 1 ? 'note' : 'notes'}" aria-pressed="${state.year === String(y)}">` +
388 `<i style="height:${Math.max(2, (c / max) * 52)}px"></i><span>${String(y).slice(2)}</span></button>`;
389 }
390 $('years').innerHTML = html;
391 $('year-hint').textContent = state.year ? `Showing ${state.year} · click again to clear` : 'Click a year to filter';
392}
393
394function renderChips() {
395 const count = s => state.rows.filter(r => r.ap?.status === s).length;
396 $('status-chips').innerHTML = Object.entries(STATUS).map(([k, s]) =>
397 `<button class="chip" type="button" data-status="${esc(k)}" aria-pressed="${state.statuses.has(k)}"><span class="dot" style="--c:${s.c}"></span>${s.label}<em>${count(k)}</em></button>`).join('');
398 $('flag-chips').innerHTML = Object.entries(FLAGS).map(([k, f]) =>
399 `<button class="chip" type="button" data-flag="${k}" aria-pressed="${state.flags.has(k)}">${f.label}<em>${state.rows.filter(f.test).length}</em></button>`).join('');
400}
401
402function filtered() {
403 const q = norm(state.q).trim();
404 let rows = state.rows.filter(r =>
405 (!q || r.hay.includes(q)) &&
406 (!state.statuses.size || state.statuses.has(r.ap?.status)) &&
407 [...state.flags].every(f => FLAGS[f].test(r)) &&
408 (!state.year || r.notes.some(n => n.date?.startsWith(state.year))));
409 const byTitle = (a, b) => a.title.localeCompare(b.title);
410 if (state.sort === 'title') rows.sort(byTitle);
411 else if (state.sort === 'rating') rows.sort((a, b) => (b.ap?.rating || 0) - (a.ap?.rating || 0) || byTitle(a, b));
412 // Oldest first goes by a title's first note, so a rewatch does not move it; undated titles sink.
413 else if (state.sort === 'oldest') rows.sort((a, b) => (!a.first - !b.first) || a.first.localeCompare(b.first) || byTitle(a, b));
414 else rows.sort((a, b) => (b.date || '').localeCompare(a.date || '') || byTitle(a, b));
415 return rows;
416}
417
418function noteHtml(n, r) {
419 const alias = n.name && norm(n.name) !== norm(r.title) ? `<span class="alias">“${esc(n.name)}”</span>` : '';
420 return `<li class="note">` +
421 (n.date && (r.notes.length > 1 || n.date !== r.date) ? `<span class="d">${esc(n.date)}</span>` : '') +
422 (n.type ? `<span class="t">${esc(n.type)}</span>` : '') +
423 (n.check ? `<span class="check" title="Linked automatically, might be the wrong title">check link</span>` : '') +
424 alias + (n.comment ? `<span class="c">${esc(n.comment)}</span>` : '') +
425 (state.key ? `<button class="btn small" type="button" data-edit="${state.notes.entries.indexOf(n)}">Edit</button>` : '') +
426 `</li>`;
427}
428
429function render() {
430 const rows = filtered();
431 const shown = rows.slice(0, state.limit);
432 $('list').innerHTML = shown.length ? shown.map(r => {
433 const s = r.ap && STATUS[r.ap.status];
434 const title = r.ap
435 ? `<a class="title" href="https://www.anime-planet.com/anime/${apSlug(r.ap.name)}" target="_blank" rel="noopener">${esc(r.title)}</a>`
436 : `<span class="title">${esc(r.title)}</span> <span class="orphan">not on A-P</span>`;
437 const meta = r.ap ? [
438 s ? `<span class="status" style="--c:${s.c}"><span class="dot"></span>${s.label}</span>` : '',
439 r.ap.rating ? `<span class="stars" aria-label="${r.ap.rating} out of 5">${r.ap.rating}</span>` : '',
440 r.ap.eps ? `<span class="eps">${r.ap.eps} ep</span>` : '',
441 ].join('') : '';
442 const notes = r.notes.length ? `<ul class="notes">${r.notes.map(n => noteHtml(n, r)).join('')}</ul>` : '';
443 const add = state.key && r.ap ? `<button class="btn small add" type="button" data-add="${esc(r.ap.name)}" aria-label="Add note to ${esc(r.title)}">+ Note</button>` : '';
444 return `<article class="row"><div class="when">${esc((state.sort === 'oldest' ? r.first : r.date) || '—')}</div><div class="title-line">${title}</div><div class="meta">${meta}${add}</div>${notes}</article>`;
445 }).join('') : `<div class="empty">Nothing matches these filters.</div>`;
446 $('more').hidden = rows.length <= state.limit;
447 $('more').textContent = `Show more (${rows.length - state.limit} left)`;
448}
449
450// Writing: re-read the snip first, so an edit from another device is never overwritten.
451async function saveNotes(label) {
452 const current = await api(`/snip/${CONFIG.NOTES_ID}`);
453 if (current.content !== state.notesRaw) {
454 throw new Error('The notes changed somewhere else. Reload the page and try again.');
455 }
456 const content = JSON.stringify(state.notes, null, 1);
457 const saved = await api(`/snip/${CONFIG.NOTES_ID}/content`, { method: 'POST', body: { content, contentName: label } });
458 state.notesRaw = saved.content;
459}
460
461function setEditing(key) {
462 state.key = key;
463 for (const id of ['editing-badge', 'btn-import', 'btn-add']) $(id).hidden = !key;
464 $('btn-edit').textContent = key ? 'Stop editing' : 'Edit';
465 render();
466}
467
468function openNote(note, apName) {
469 state.editing = note;
470 $('note-heading').textContent = note ? 'Edit note' : 'Add note';
471 $('n-ap').value = note ? (note.ap || '') : (apName || '');
472 $('n-name').value = note?.name || '';
473 $('n-date').value = note ? (note.date || '') : new Date().toISOString().slice(0, 10);
474 $('n-type').value = note?.type || '';
475 $('n-comment').value = note?.comment || '';
476 $('note-delete').hidden = !note;
477 $('note-error').hidden = true;
478 $('dlg-note').showModal();
479}
480
481async function commitNote(mutate, label) {
482 const before = JSON.stringify(state.notes);
483 mutate();
484 try {
485 await saveNotes(label);
486 $('dlg-note').close();
487 build();
488 toast('Saved to Snips');
489 } catch (e) {
490 state.notes = JSON.parse(before);
491 $('note-error').textContent = e.message;
492 $('note-error').hidden = false;
493 }
494}
495
496// Events
497$('q').addEventListener('input', e => { state.q = e.target.value; state.limit = PAGE; render(); });
498$('sort').addEventListener('change', e => { state.sort = e.target.value; render(); });
499$('more').addEventListener('click', () => { state.limit += PAGE; render(); });
500$('years').addEventListener('click', e => {
501 const b = e.target.closest('[data-year]'); if (!b) return;
502 state.year = state.year === b.dataset.year ? null : b.dataset.year;
503 state.limit = PAGE; renderYears(); render();
504});
505document.querySelector('.controls').addEventListener('click', e => {
506 const b = e.target.closest('[data-status],[data-flag]'); if (!b) return;
507 const [set, k] = b.dataset.status ? [state.statuses, b.dataset.status] : [state.flags, b.dataset.flag];
508 set.has(k) ? set.delete(k) : set.add(k);
509 state.limit = PAGE; renderChips(); render();
510});
511$('list').addEventListener('click', e => {
512 const ed = e.target.closest('[data-edit]');
513 if (ed) return openNote(state.notes.entries[+ed.dataset.edit]);
514 const add = e.target.closest('[data-add]');
515 if (add) openNote(null, add.dataset.add);
516});
517$('btn-add').addEventListener('click', () => openNote(null, ''));
518
519$('btn-edit').addEventListener('click', () => {
520 if (state.key) { store.set('snips-key', null); setEditing(null); return; }
521 $('key').value = ''; $('key-error').hidden = true; $('dlg-key').showModal();
522});
523$('form-key').addEventListener('submit', async e => {
524 if (e.submitter?.value !== 'ok') return;
525 e.preventDefault();
526 const key = $('key').value.trim();
527 try {
528 await api('/me', { key });
529 store.set('snips-key', key);
530 $('dlg-key').close();
531 setEditing(key);
532 toast('Editing enabled');
533 } catch {
534 $('key-error').textContent = 'Snips did not accept this key. Copy it again from your profile page.';
535 $('key-error').hidden = false;
536 }
537});
538
539$('form-note').addEventListener('submit', e => {
540 if (e.submitter?.value !== 'ok') return;
541 e.preventDefault();
542 const ap = $('n-ap').value.trim();
543 if (ap && !state.exp.entries.some(x => x.name === ap)) {
544 $('note-error').textContent = 'Pick an Anime-Planet title from the list, or leave it empty.';
545 $('note-error').hidden = false;
546 return;
547 }
548 const fields = { ap, name: $('n-name').value.trim(), type: $('n-type').value.trim(), date: $('n-date').value, comment: $('n-comment').value.trim() };
549 const clean = Object.fromEntries(Object.entries(fields).filter(([, v]) => v));
550 if (!clean.ap && !clean.name) {
551 $('note-error').textContent = 'Give the note an Anime-Planet title or a name.';
552 $('note-error').hidden = false;
553 return;
554 }
555 const idx = state.notes.entries.indexOf(state.editing);
556 commitNote(() => {
557 if (idx >= 0) state.notes.entries[idx] = clean; // saving also clears the "check link" flag
558 else state.notes.entries.push(clean);
559 }, `${idx >= 0 ? 'Edit' : 'Add'}: ${clean.ap || clean.name}`);
560});
561$('note-delete').addEventListener('click', () => {
562 const idx = state.notes.entries.indexOf(state.editing);
563 if ($('note-delete').dataset.armed !== '1') {
564 $('note-delete').dataset.armed = '1';
565 $('note-delete').textContent = 'Really delete?';
566 return;
567 }
568 delete $('note-delete').dataset.armed;
569 $('note-delete').textContent = 'Delete';
570 const n = state.notes.entries[idx];
571 commitNote(() => state.notes.entries.splice(idx, 1), `Delete: ${n.ap || n.name}`);
572});
573$('dlg-note').addEventListener('close', () => { delete $('note-delete').dataset.armed; $('note-delete').textContent = 'Delete'; });
574
575$('btn-import').addEventListener('click', () => $('file-import').click());
576$('file-import').addEventListener('change', async e => {
577 const file = e.target.files[0]; e.target.value = '';
578 if (!file) return;
579 try {
580 const text = await file.text();
581 const data = JSON.parse(text);
582 if (data?.export?.type !== 'anime' || !Array.isArray(data.entries)) throw new Error('This is not an Anime-Planet anime export.');
583 await api(`/snip/${CONFIG.EXPORT_ID}/content`, { method: 'POST', body: { content: text, contentName: `Export ${data.export.date.slice(0, 10)}` } });
584 state.exp = data;
585 build();
586 toast(`Export from ${data.export.date.slice(0, 10)} saved, ${data.entries.length} titles`);
587 } catch (err) {
588 toast(err.message);
589 }
590});
591
592load().then(() => {
593 const key = store.get('snips-key');
594 if (key) setEditing(key);
595}).catch(e => {
596 $('summary').textContent = '';
597 $('list').innerHTML = `<div class="state error">Could not load the list from Snips: ${esc(e.message)}</div>`;
598});
599</script>
600</body>
601</html>