Anime Log - View

Index Raw

  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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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>