trackers: media hub with webtoons, podcasts and videos; shows and anime filters
Deploy production / deploy (push) Successful in 1m22s

- /trackers/media: one card per child (reading, movies, shows, webtoons, music, podcasts,
  videos) with quick stats, a combined currently-consuming feed and a mixed watchlist
- webtoons, podcasts, videos: new owner-editable trackers on one generic media.items table,
  JSON import (owner form or scripts/media-run.sh)
- shows: /trackers/shows -> /trackers/movies?type=show; anime flag on movies.titles
  (backfilled, set from TMDB, owner toggle) and ?type=anime filter
- nav: media replaces the top-level reading, movies and music entries; their URLs are kept
This commit is contained in:
stan committed 2026-10-26 23:34:22 +00:00
1 parent 4ef75f9d25
commit 079399635d
33 files changed
+48352 -44

No files matched your search

+2 -1
View File
@@ -38,7 +38,8 @@ export default defineConfig({
'notes',
'math',
'plant',
'radio'
'radio',
'media'
],
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
verbose: true,
@@ -0,0 +1,63 @@
CREATE SCHEMA "media";
--> statement-breakpoint
CREATE TABLE "media"."items" (
"id" serial PRIMARY KEY,
"kind" text NOT NULL,
"match_key" text NOT NULL,
"title" text NOT NULL,
"creator" text,
"platform" text,
"genre" text,
"status" text NOT NULL,
"progress" integer,
"total" integer,
"series_status" text,
"form" text,
"url" text,
"cover_url" text,
"rating" real,
"who" text,
"started_on" date,
"finished_on" date,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "items_kind_check" CHECK ("kind" IN ('webtoon','podcast','video')),
CONSTRAINT "items_status_check" CHECK (("kind" = 'webtoon' AND "status" IN ('reading','completed','on_hold','dropped','plan'))
OR ("kind" = 'podcast' AND "status" IN ('listening','caught_up','paused','dropped','plan'))
OR ("kind" = 'video' AND "status" IN ('watching','following','watched','dropped','plan'))),
CONSTRAINT "items_title_check" CHECK (length(btrim("title")) > 0),
CONSTRAINT "items_series_check" CHECK ("series_status" IS NULL OR "series_status" IN ('ongoing','completed')),
CONSTRAINT "items_form_check" CHECK ("form" IS NULL OR "form" IN ('channel','video','playlist')),
CONSTRAINT "items_who_check" CHECK ("who" IS NULL OR "who" IN ('stan','juhee','both')),
CONSTRAINT "items_rating_check" CHECK ("rating" IS NULL OR ("rating" >= 0.5 AND "rating" <= 5 AND "rating" * 2 = round("rating" * 2))),
CONSTRAINT "items_progress_check" CHECK (("progress" IS NULL OR "progress" >= 0) AND ("total" IS NULL OR "total" >= 0)),
CONSTRAINT "items_url_check" CHECK ("url" IS NULL OR "url" ~ '^https?://'),
CONSTRAINT "items_cover_check" CHECK ("cover_url" IS NULL OR "cover_url" ~ '^https://')
);
--> statement-breakpoint
ALTER TABLE "movies"."titles" ADD COLUMN "anime" boolean DEFAULT false NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "items_match_key" ON "media"."items" ("match_key");--> statement-breakpoint
CREATE INDEX "items_kind_status_idx" ON "media"."items" ("kind","status");--> statement-breakpoint
-- Anime is a filter inside Shows: Japanese animation (kana/kanji original title) plus the two
-- with Latin original titles. New TMDB matches set it from original_language = 'ja'.
UPDATE "movies"."titles" SET "anime" = true
WHERE "type" = 'show' AND 'Animation' = ANY("genres")
AND ("original_title" ~ '[ぁ-ゟ゠-ヿ一-鿿]' OR "title" IN ('Bleach', 'Hunter x Hunter'));
--> statement-breakpoint
-- Grants: app role read/write only (like movies; no personal_ro). Prod only, where migrations
-- run as `personal`.
REVOKE ALL ON SCHEMA media FROM PUBLIC;
--> statement-breakpoint
DO $$
BEGIN
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT USAGE ON SCHEMA media TO personal_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA media TO personal_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA media TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA media
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA media
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
END IF;
END $$;
File diff suppressed because it is too large. Load diff
+67
View File
@@ -0,0 +1,67 @@
/**
* Import entries into the media trackers' generic children (webtoons, podcasts, videos;
* media.items, /trackers/webtoons|podcasts|videos). Idempotent: rows are keyed by kind +
* normalised title (+ creator for videos); re-running updates the given fields, never duplicates.
* The owner can paste the same JSON on each tracker page (import tile).
*
* On stan-ovh (DB_URL is loaded for you, nothing printed):
* scripts/media-run.sh ~/imports/webtoons.json --kind webtoons
* scripts/media-run.sh ~/imports/media.json # object keyed by kind
* Add --dry-run to parse and print a summary without touching the database.
*
* Format: an array of entries (for --kind, or each with its own "kind"), or an object
* { "webtoons": [...], "podcasts": [...], "videos": [...] }. Entry fields (only title required):
* title, creator | author | host | channel, platform, genre, status, progress | episode | chapter,
* total | total_episodes, series_status (ongoing|completed, webtoons), form (channel|video|playlist,
* videos), url, cover_url (https), rating (0.5–5), who (stan|juhee|both), started, finished
* (YYYY-MM-DD), notes.
* Statuses: webtoons reading|completed|on_hold|dropped|plan; podcasts listening|caught_up|paused|
* dropped|plan; videos watching|following|watched|dropped|plan ("plan to read" etc. map to plan).
*/
import { readFile } from 'node:fs/promises';
import postgres from 'postgres';
import { importRows, parseImport } from '../src/lib/server/media/store';
import { KINDS, MEDIA_KINDS, type MediaKind } from '../src/lib/media/kinds';
const args = process.argv.slice(2);
const flag = (name: string) => {
const i = args.indexOf(name);
return i >= 0 ? args.splice(i, 2)[1] : undefined;
};
const dry = args.includes('--dry-run');
const kindArg = flag('--kind');
const file = args.find((a) => !a.startsWith('--'));
if (!file)
throw new Error(
'usage: media-import.ts <file.json> [--kind webtoons|podcasts|videos] [--dry-run]'
);
const kind: MediaKind | undefined = kindArg
? (MEDIA_KINDS.find((k) => k === kindArg) ??
Object.values(KINDS).find((c) => c.name === kindArg)?.kind)
: undefined;
if (kindArg && !kind) throw new Error(`unknown --kind ${kindArg}`);
const { rows, errors } = parseImport(JSON.parse(await readFile(file, 'utf8')), kind);
const byKind = Object.groupBy(rows, (r) => r.kind);
console.log(
`parsed ${rows.length} entries: ${
Object.entries(byKind)
.map(([k, v]) => `${k} ${v?.length ?? 0}`)
.join(', ') || 'none'
}`
);
if (errors.length) {
for (const e of errors.slice(0, 50)) console.error(` ${e}`);
console.error(`${errors.length} invalid entries; nothing imported`);
process.exit(1);
}
if (dry || !rows.length) process.exit(0);
if (!process.env.DB_URL)
throw new Error('DB_URL is required (use scripts/media-run.sh on stan-ovh)');
const sql = postgres(process.env.DB_URL, { max: 1, onnotice: () => {} });
try {
const r = await importRows(sql, rows);
console.log(`imported ${r.total}: ${r.created} new, ${r.updated} updated`);
} finally {
await sql.end();
}
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Run scripts/media-import.ts on stan-ovh against prod, without printing secrets.
# scripts/media-run.sh ~/imports/webtoons.json --kind webtoons [--dry-run]
# DB_URL (app role) comes from the running app container (db:5432 → 127.0.0.1:5440).
set -euo pipefail
cd "$(dirname "$0")/.."
DB_URL=$(sudo -n docker inspect personal-website-app-1 --format '{{range .Config.Env}}{{println .}}{{end}}' \
| sed -n 's/^DB_URL=//p' | sed 's/@db:5432/@127.0.0.1:5440/')
[ -n "$DB_URL" ] || { echo "no DB_URL from the app container" >&2; exit 1; }
export DB_URL
exec bun scripts/media-import.ts "$@"
+989
View File
@@ -0,0 +1,989 @@
<script lang="ts">
// One page for every media.items kind (webtoons, podcasts, videos): library with status
// chips, search and sort, a detail dialog (edit form for the owner), add and JSON import.
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import Search from 'lucide-svelte/icons/search';
import Plus from 'lucide-svelte/icons/plus';
import Minus from 'lucide-svelte/icons/minus';
import Upload from 'lucide-svelte/icons/upload';
import X from 'lucide-svelte/icons/x';
import Star from 'lucide-svelte/icons/star';
import Trash2 from 'lucide-svelte/icons/trash-2';
import ExternalLink from 'lucide-svelte/icons/external-link';
import { KINDS, MEDIA_WHO, SERIES_STATUSES, VIDEO_FORMS, phaseOf, statusOf } from './kinds';
import type { MediaKind } from './kinds';
import type { MediaItem } from '#lib/server/media/site.js';
type Props = {
data: { owner: boolean; kind: MediaKind; items: MediaItem[] };
form?: { message?: string; ok?: boolean } | null;
};
let { data, form }: Props = $props();
const cfg = $derived(KINDS[data.kind]);
const RATINGS = [0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5];
const whoLabel: Record<string, string> = { stan: 'Stan', juhee: 'Juhee', both: 'together' };
let status = $state('all');
let who = $state<'all' | (typeof MEDIA_WHO)[number]>('all');
let sort = $state<'recent' | 'rating' | 'title'>('recent');
let query = $state('');
const counts = $derived.by(() => {
const c: Record<string, number> = { all: data.items.length };
for (const t of data.items) c[t.status] = (c[t.status] ?? 0) + 1;
return c;
});
const phaseCount = (phase: string) =>
data.items.filter((t) => phaseOf(data.kind, t.status) === phase).length;
const filtered = $derived.by(() => {
const q = query.trim().toLowerCase();
const list = data.items.filter(
(t) =>
(status === 'all' || t.status === status) &&
(who === 'all' || t.who === who || (who !== 'both' && t.who === 'both')) &&
(!q ||
t.title.toLowerCase().includes(q) ||
(t.creator ?? '').toLowerCase().includes(q) ||
(t.genre ?? '').toLowerCase().includes(q) ||
(t.platform ?? '').toLowerCase().includes(q))
);
if (sort === 'rating') return list.toSorted((a, b) => (b.rating ?? -1) - (a.rating ?? -1));
if (sort === 'title') return list.toSorted((a, b) => a.title.localeCompare(b.title));
return list;
});
function hue(s: string) {
let h = 0;
for (const ch of s) h = (h * 31 + ch.charCodeAt(0)) % 360;
return h;
}
const initials = (s: string) =>
s
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0]?.toUpperCase())
.join('');
const fmtDate = (d: string | null) =>
d
? new Date(`${d}T12:00:00`).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric'
})
: null;
const progressText = (t: MediaItem) =>
t.progress != null
? `${cfg.unit} ${t.progress}${t.total ? ` / ${t.total}` : ''}`
: t.total
? `${t.total} ${cfg.unit}s`
: null;
const pct = (t: MediaItem) =>
t.progress != null && t.total ? Math.min(1, t.progress / t.total) : null;
const statusLabel = (s: string) => statusOf(data.kind, s)?.label ?? s;
// ---- dialogs
let selectedId = $state<number | null>(null);
const selected = $derived(data.items.find((t) => t.id === selectedId) ?? null);
let detail = $state<HTMLDialogElement>();
let adder = $state<HTMLDialogElement>();
let importer = $state<HTMLDialogElement>();
let busy = $state(false);
function open(id: number) {
selectedId = id;
detail?.showModal();
}
const closeOnSuccess = (dialog: () => HTMLDialogElement | undefined) => () => {
busy = true;
return async ({
result,
update
}: {
result: { type: string };
update: (o?: { reset?: boolean }) => Promise<void>;
}) => {
busy = false;
await update({ reset: result.type === 'success' });
if (result.type === 'success') dialog()?.close();
};
};
const example = $derived(
JSON.stringify(
[
data.kind === 'webtoon'
? {
title: 'Tower of God',
author: 'SIU',
platform: 'Webtoon',
genre: 'Fantasy',
status: 'reading',
episode: 120,
total: 640,
series_status: 'ongoing',
rating: 4.5,
who: 'both',
started: '2026-09-01',
notes: 'Juhee got me into it'
}
: data.kind === 'podcast'
? {
title: 'Lex Fridman Podcast',
host: 'Lex Fridman',
platform: 'Spotify',
status: 'listening',
episode: 412,
rating: 4,
who: 'stan'
}
: {
title: 'Kurzgesagt',
channel: 'Kurzgesagt – In a Nutshell',
platform: 'YouTube',
form: 'channel',
url: 'https://www.youtube.com/@kurzgesagt',
status: 'following',
who: 'both'
}
],
null,
2
)
);
</script>
{#snippet fields(t: MediaItem | null)}
<label class="full"
>title <input name="title" required maxlength="300" value={t?.title ?? ''} /></label
>
<label>{cfg.creatorLabel} <input name="creator" maxlength="200" value={t?.creator ?? ''} /></label
>
<label
>platform
<input name="platform" maxlength="60" list="platforms-{cfg.kind}" value={t?.platform ?? ''} />
</label>
{#if cfg.fields.form}
<label
>kind
<select name="form" value={t?.form ?? 'channel'}>
{#each VIDEO_FORMS as f (f)}<option value={f}>{f}</option>{/each}
</select>
</label>
{/if}
<label
>status
<select name="status" value={t?.status ?? cfg.statuses[0].id}>
{#each cfg.statuses as s (s.id)}<option value={s.id}>{s.label}</option>{/each}
</select>
</label>
{#if cfg.fields.genre}
<label>genre <input name="genre" maxlength="120" value={t?.genre ?? ''} /></label>
{/if}
<label
>{cfg.progressLabel}
<input name="progress" type="number" min="0" value={t?.progress ?? ''} />
</label>
<label>total <input name="total" type="number" min="0" value={t?.total ?? ''} /></label>
{#if cfg.fields.series}
<label
>series
<select name="series_status" value={t?.seriesStatus ?? ''}>
<option value="">–</option>
{#each SERIES_STATUSES as s (s)}<option value={s}>{s}</option>{/each}
</select>
</label>
{/if}
<label
>rating
<select name="rating" value={t?.rating ?? ''}>
<option value="">–</option>
{#each RATINGS as r (r)}<option value={r}>{r}</option>{/each}
</select>
</label>
<label
>who
<select name="who" value={t?.who ?? ''}>
<option value="">–</option>
{#each MEDIA_WHO as w (w)}<option value={w}>{whoLabel[w]}</option>{/each}
</select>
</label>
<label>started <input name="started" type="date" value={t?.startedOn ?? ''} /></label>
<label>finished <input name="finished" type="date" value={t?.finishedOn ?? ''} /></label>
<label class="full"
>link <input name="url" type="url" maxlength="1000" value={t?.url ?? ''} /></label
>
<label class="full"
>cover image (https)
<input name="cover_url" type="url" maxlength="1000" value={t?.coverUrl ?? ''} /></label
>
<label class="full"
>notes <textarea name="notes" rows="2" maxlength="4000" value={t?.notes ?? ''}></textarea>
</label>
{/snippet}
<datalist id="platforms-{cfg.kind}">
{#each cfg.platforms as p (p)}<option value={p}></option>{/each}
</datalist>
<div class="md-page">
<a href={resolve('/trackers/media')} class="back"
><ArrowLeft size={14} aria-hidden="true" /> media</a
>
<header class="md-head">
<div class="md-title">
<TrackerIcon name={cfg.name} class="size-10" />
<div>
<h1>{cfg.title}</h1>
<p>{cfg.blurb}</p>
</div>
</div>
{#if data.owner}
<div class="md-head-links">
<button type="button" class="tile" onclick={() => adder?.showModal()}>
<span class="tile-icon add"><Plus size={16} /></span>
<span><strong>add</strong><small>{cfg.title.replace(/s$/, '')}</small></span>
</button>
<button type="button" class="tile" onclick={() => importer?.showModal()}>
<span class="tile-icon imp"><Upload size={16} /></span>
<span><strong>import</strong><small>JSON</small></span>
</button>
</div>
{/if}
</header>
{#if form?.message}
<p class={['flash', { bad: !form.ok }]} role="status">{form.message}</p>
{/if}
<section class="md-totals" aria-label="Totals">
<div><b>{phaseCount('current')}</b> {cfg.verbs.current}</div>
<div><b>{phaseCount('done')}</b> {cfg.verbs.done}</div>
<div><b>{phaseCount('plan')}</b> planned</div>
<div><b>{data.items.length}</b> total</div>
</section>
{#if !data.items.length}
<div class="empty">
<TrackerIcon name={cfg.name} class="size-12" />
<p>Nothing here yet.</p>
{#if data.owner}
<p class="hint">Add one, or import a JSON list.</p>
{/if}
</div>
{:else}
<div class="md-filters">
<div class="chips" role="group" aria-label="Status">
{#each [{ id: 'all', label: 'all' }, ...cfg.statuses] as s (s.id)}
<button
type="button"
class={['chip', { on: status === s.id }]}
aria-pressed={status === s.id}
onclick={() => (status = s.id)}>{s.label}<span>{counts[s.id] ?? 0}</span></button
>
{/each}
</div>
<div class="row">
<label class="search">
<Search size={15} />
<input
type="search"
placeholder="title, {cfg.creatorLabel.split(' ')[0]}, genre or platform"
bind:value={query}
aria-label="Search"
/>
</label>
<select bind:value={who} aria-label="Who">
<option value="all">everyone</option>
<option value="stan">Stan</option>
<option value="juhee">Juhee</option>
<option value="both">together</option>
</select>
<select bind:value={sort} aria-label="Sort">
<option value="recent">recent</option>
<option value="rating">rating</option>
<option value="title">title</option>
</select>
</div>
</div>
{#if filtered.length}
<ul class="list">
{#each filtered as t (t.id)}
{@const p = pct(t)}
<li class="item">
<button type="button" class="item-main" onclick={() => open(t.id)}>
<span class="cover" style:--h={hue(t.title)}>
{#if t.coverUrl}<img src={t.coverUrl} alt="" loading="lazy" />{:else}<span
>{initials(t.title)}</span
>{/if}
</span>
<span class="item-text">
<span class="item-title">{t.title}</span>
<span class="item-sub">
{[t.creator, t.platform, t.form && t.form !== 'channel' ? t.form : null]
.filter(Boolean)
.join(' · ')}
</span>
<span class="item-meta">
<span class="pill ph-{phaseOf(data.kind, t.status)}">{statusLabel(t.status)}</span
>
{#if progressText(t)}<span class="prog">{progressText(t)}</span>{/if}
{#if t.seriesStatus}<span class="muted">{t.seriesStatus}</span>{/if}
{#if t.rating}<span class="stars"
><Star size={11} fill="currentColor" />{t.rating}</span
>{/if}
{#if t.who}<span class="pill who-{t.who}">{whoLabel[t.who]}</span>{/if}
</span>
{#if p !== null}<span class="bar"><span style:width="{p * 100}%"></span></span>{/if}
</span>
</button>
{#if data.owner && phaseOf(data.kind, t.status) !== 'dropped'}
<form method="POST" action="?/step" use:enhance class="step">
<input type="hidden" name="id" value={t.id} />
<button
name="by"
value="1"
title="+1 {cfg.unit}"
aria-label="Next {cfg.progressLabel}"><Plus size={15} /></button
>
</form>
{/if}
</li>
{/each}
</ul>
{:else}
<p class="empty-filter">Nothing matches.</p>
{/if}
{/if}
</div>
<dialog bind:this={detail} class="md-dialog" onclose={() => (selectedId = null)}>
{#if selected}
<div class="d-head">
<span class="cover big" style:--h={hue(selected.title)}>
{#if selected.coverUrl}<img src={selected.coverUrl} alt="" />{:else}<span
>{initials(selected.title)}</span
>{/if}
</span>
<div class="d-info">
<h2>{selected.title}</h2>
<p class="item-sub">
{[selected.creator, selected.platform, selected.genre].filter(Boolean).join(' · ')}
</p>
<p class="item-meta">
<span class="pill ph-{phaseOf(data.kind, selected.status)}"
>{statusLabel(selected.status)}</span
>
{#if progressText(selected)}<span class="prog">{progressText(selected)}</span>{/if}
{#if selected.seriesStatus}<span class="muted">series {selected.seriesStatus}</span>{/if}
{#if selected.rating}<span class="stars"
><Star size={11} fill="currentColor" />{selected.rating}</span
>{/if}
{#if selected.who}<span class="pill who-{selected.who}">{whoLabel[selected.who]}</span
>{/if}
</p>
{#if selected.startedOn || selected.finishedOn}
<p class="muted small">
{#if selected.startedOn}started {fmtDate(
selected.startedOn
)}{/if}{#if selected.startedOn && selected.finishedOn}
·
{/if}{#if selected.finishedOn}finished {fmtDate(selected.finishedOn)}{/if}
</p>
{/if}
{#if selected.url}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a class="ext" href={selected.url} target="_blank" rel="noopener noreferrer"
><ExternalLink size={13} /> open</a
>
{/if}
</div>
<button type="button" class="d-close" aria-label="Close" onclick={() => detail?.close()}
><X size={18} /></button
>
</div>
{#if selected.notes}<p class="d-notes">{selected.notes}</p>{/if}
{#if data.owner}
<form
method="POST"
action="?/update"
class="d-form"
use:enhance={closeOnSuccess(() => detail)}
>
<input type="hidden" name="id" value={selected.id} />
{@render fields(selected)}
<div class="full actions">
<button class="primary" disabled={busy}>save</button>
<button class="ghost" formaction="?/step" name="by" value="-1" disabled={busy}
><Minus size={14} /> {cfg.unit}</button
>
<button
class="danger"
formaction="?/delete"
disabled={busy}
onclick={(e) => {
if (!confirm(`Delete “${selected?.title}”?`)) e.preventDefault();
}}><Trash2 size={14} /> delete</button
>
</div>
</form>
{/if}
{/if}
</dialog>
{#if data.owner}
<dialog bind:this={adder} class="md-dialog">
<div class="d-head">
<h2>add {cfg.title.replace(/s$/, '')}</h2>
<button type="button" class="d-close" aria-label="Close" onclick={() => adder?.close()}
><X size={18} /></button
>
</div>
<form method="POST" action="?/add" class="d-form" use:enhance={closeOnSuccess(() => adder)}>
{@render fields(null)}
<div class="full actions"><button class="primary" disabled={busy}>add</button></div>
</form>
</dialog>
<dialog bind:this={importer} class="md-dialog">
<div class="d-head">
<h2>import {cfg.title}</h2>
<button type="button" class="d-close" aria-label="Close" onclick={() => importer?.close()}
><X size={18} /></button
>
</div>
<p class="muted small">
A JSON array. Only <code>title</code> is required; re-importing a title updates it instead of
adding it twice. Statuses: {cfg.statuses.map((s) => s.id).join(', ')}.
</p>
<pre class="example">{example}</pre>
<form
method="POST"
action="?/import"
class="d-form one"
use:enhance={closeOnSuccess(() => importer)}
>
<textarea name="json" rows="10" spellcheck="false" placeholder={`[ { "title": "…" } ]`}
></textarea>
<div class="actions"><button class="primary" disabled={busy}>import</button></div>
</form>
</dialog>
{/if}
<style>
.md-page {
max-width: 60rem;
margin: 0 auto;
padding: 2rem 1rem 3rem;
display: flex;
flex-direction: column;
gap: 1.1rem;
}
.back {
display: inline-flex;
align-items: center;
gap: 0.25rem;
width: fit-content;
font-size: 0.85rem;
color: var(--site-muted);
}
.back:hover {
color: var(--site-text);
}
.md-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.md-title {
display: flex;
align-items: center;
gap: 0.85rem;
}
h1 {
font-size: 1.6rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.md-title p,
.muted {
color: var(--site-muted);
}
.md-title p {
font-size: 0.9rem;
}
.small {
font-size: 0.8rem;
}
.md-head-links {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tile {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.45rem 0.8rem 0.45rem 0.5rem;
border: 1px solid var(--site-border);
border-radius: 0.8rem;
color: var(--site-text);
cursor: pointer;
transition:
transform 0.15s ease,
border-color 0.15s ease;
}
.tile:hover {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--site-text) 35%, transparent);
}
.tile span:last-child {
display: flex;
flex-direction: column;
line-height: 1.15;
text-align: left;
}
.tile strong {
font-size: 0.88rem;
}
.tile small {
font-size: 0.72rem;
color: var(--site-muted);
}
.tile-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.5rem;
color: white;
}
.tile-icon.add {
background: linear-gradient(135deg, #34d399, #059669);
}
.tile-icon.imp {
background: linear-gradient(135deg, #60a5fa, #2563eb);
}
.flash {
padding: 0.5rem 0.75rem;
border: 1px solid color-mix(in srgb, #22c55e 45%, transparent);
border-radius: 0.6rem;
font-size: 0.85rem;
}
.flash.bad {
border-color: color-mix(in srgb, #ef4444 50%, transparent);
color: #ef4444;
}
.md-totals {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.5rem;
}
.md-totals div {
padding: 0.6rem 0.75rem;
border: 1px solid var(--site-border);
border-radius: 0.8rem;
font-size: 0.85rem;
color: var(--site-muted);
}
.md-totals b {
color: var(--site-text);
font-size: 1.05rem;
margin-right: 0.15rem;
}
@media (max-width: 540px) {
.md-totals {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 3rem 1rem;
border: 1px dashed var(--site-border);
border-radius: 1rem;
color: var(--site-muted);
text-align: center;
}
.empty .hint {
font-size: 0.8rem;
}
.empty-filter {
color: var(--site-muted);
font-size: 0.9rem;
}
.md-filters {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.chip {
display: inline-flex;
gap: 0.35rem;
align-items: center;
padding: 0.25rem 0.65rem;
border: 1px solid var(--site-border);
border-radius: 999px;
font-size: 0.8rem;
color: var(--site-muted);
cursor: pointer;
}
.chip span {
font-variant-numeric: tabular-nums;
opacity: 0.7;
}
.chip:hover {
color: var(--site-text);
}
.chip.on {
background: var(--site-text);
color: var(--site-bg);
border-color: var(--site-text);
}
.row {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.search {
flex: 1 1 14rem;
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0 0.6rem;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
color: var(--site-muted);
}
select,
input,
textarea {
background: var(--site-bg);
color: var(--site-text);
border: 1px solid var(--site-border);
border-radius: 0.6rem;
padding: 0.4rem 0.55rem;
font-size: 0.85rem;
min-width: 0;
}
.search input {
flex: 1;
border: 0;
padding: 0.4rem 0;
background: transparent;
outline: none;
}
.list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
gap: 0.6rem;
}
.item {
position: relative;
display: flex;
border: 1px solid var(--site-border);
border-radius: 0.9rem;
transition:
border-color 0.15s ease,
transform 0.15s ease;
}
.item:hover {
border-color: color-mix(in srgb, var(--site-text) 30%, transparent);
transform: translateY(-1px);
}
.item-main {
flex: 1;
min-width: 0;
display: flex;
gap: 0.75rem;
padding: 0.65rem;
text-align: left;
cursor: pointer;
}
.cover {
flex-shrink: 0;
width: 3.2rem;
height: 4.2rem;
border-radius: 0.5rem;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 700;
background: linear-gradient(150deg, hsl(var(--h) 70% 50%), hsl(calc(var(--h) + 40) 65% 25%));
}
.cover.big {
width: 5.5rem;
height: 7.3rem;
font-size: 1.4rem;
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.item-text {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
flex: 1;
}
.item-title {
font-weight: 650;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-sub {
font-size: 0.78rem;
color: var(--site-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
font-size: 0.72rem;
}
.prog {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.pill {
padding: 0.05rem 0.45rem;
border-radius: 999px;
font-weight: 600;
border: 1px solid var(--site-border);
}
.ph-current {
background: #16a34a;
border-color: #16a34a;
color: white;
}
.ph-done {
background: #7c3aed;
border-color: #7c3aed;
color: white;
}
.ph-plan {
background: #2563eb;
border-color: #2563eb;
color: white;
}
.ph-paused {
background: #d97706;
border-color: #d97706;
color: white;
}
.ph-dropped {
background: #71717a;
border-color: #71717a;
color: white;
}
.who-both {
color: #ec4899;
}
.stars {
display: inline-flex;
align-items: center;
gap: 0.15rem;
color: #f59e0b;
font-weight: 700;
}
.bar {
height: 3px;
border-radius: 999px;
background: color-mix(in srgb, var(--site-text) 12%, transparent);
overflow: hidden;
margin-top: 0.15rem;
}
.bar span {
display: block;
height: 100%;
background: linear-gradient(90deg, #22c55e, #16a34a);
}
.step {
display: flex;
}
.step button {
align-self: center;
margin-right: 0.6rem;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
color: var(--site-muted);
cursor: pointer;
}
.step button:hover {
color: var(--site-text);
border-color: var(--site-text);
}
.md-dialog {
width: min(40rem, calc(100vw - 1.5rem));
max-height: calc(100dvh - 2rem);
margin: auto;
padding: 1rem;
border: 1px solid var(--site-border);
border-radius: 1rem;
background: var(--site-bg);
color: var(--site-text);
overflow-y: auto;
}
.md-dialog::backdrop {
background: rgb(0 0 0 / 0.6);
backdrop-filter: blur(3px);
}
.d-head {
position: relative;
display: flex;
gap: 0.9rem;
align-items: flex-start;
padding-right: 2rem;
margin-bottom: 0.6rem;
}
.d-head > h2 {
font-size: 1.05rem;
font-weight: 650;
}
.d-info {
display: flex;
flex-direction: column;
gap: 0.4rem;
min-width: 0;
}
.d-info h2 {
font-size: 1.2rem;
font-weight: 700;
line-height: 1.2;
}
.ext {
display: inline-flex;
align-items: center;
gap: 0.3rem;
width: fit-content;
font-size: 0.8rem;
text-decoration: underline;
}
.d-close {
position: absolute;
top: 0;
right: 0;
padding: 0.25rem;
border-radius: 0.5rem;
color: var(--site-muted);
cursor: pointer;
}
.d-close:hover {
color: var(--site-text);
}
.d-notes {
font-size: 0.88rem;
white-space: pre-wrap;
}
.d-form {
margin-top: 0.8rem;
padding-top: 0.9rem;
border-top: 1px solid var(--site-border);
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
gap: 0.6rem;
}
.d-form.one {
grid-template-columns: 1fr;
}
.d-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.72rem;
color: var(--site-muted);
}
.d-form .full {
grid-column: 1 / -1;
}
textarea {
font-family: inherit;
}
.d-form.one textarea {
font-family: ui-monospace, monospace;
font-size: 0.78rem;
}
.example {
margin-top: 0.5rem;
padding: 0.6rem;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
font-size: 0.72rem;
overflow-x: auto;
color: var(--site-muted);
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.primary,
.danger,
.ghost {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.4rem 0.95rem;
border-radius: 0.6rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
.primary {
background: var(--site-text);
color: var(--site-bg);
}
.ghost {
border: 1px solid var(--site-border);
}
.danger {
margin-left: auto;
border: 1px solid color-mix(in srgb, #ef4444 50%, transparent);
color: #ef4444;
}
button:disabled {
opacity: 0.5;
cursor: default;
}
@media (prefers-reduced-motion: reduce) {
.tile,
.item {
transition: none;
}
.tile:hover,
.item:hover {
transform: none;
}
}
</style>
+113
View File
@@ -0,0 +1,113 @@
// The Media tracker's own children (webtoons, podcasts, videos): one table (media.items) with a
// `kind`, one generic page (#lib/media/MediaTracker.svelte) and this config per kind. Reading,
// movies, shows and music keep their own trackers; /trackers/media is the hub over all of them.
// Client-safe (no server imports): the page, the hub and the server validation all read it.
export const MEDIA_KINDS = ['webtoon', 'podcast', 'video'] as const;
export type MediaKind = (typeof MEDIA_KINDS)[number];
export const MEDIA_WHO = ['stan', 'juhee', 'both'] as const;
export type MediaWho = (typeof MEDIA_WHO)[number];
export const SERIES_STATUSES = ['ongoing', 'completed'] as const;
export const VIDEO_FORMS = ['channel', 'video', 'playlist'] as const;
/** Where a status sits across kinds (hub feed, counts): current, done, paused, dropped, planned. */
export type Phase = 'current' | 'done' | 'paused' | 'dropped' | 'plan';
export type KindConfig = {
kind: MediaKind;
/** Tracker name (projects.ts, tracker icon key) and URL slug under /trackers. */
name: 'webtoons' | 'podcasts' | 'videos';
href: string;
title: string;
blurb: string;
/** Statuses in display order with their label and phase. */
statuses: { id: string; label: string; phase: Phase }[];
/** Suggested platforms (free text is allowed too). */
platforms: string[];
creatorLabel: string;
progressLabel: string;
/** Short unit for progress lines ("ep 12 / 140"). */
unit: string;
verbs: { current: string; done: string };
fields: {
genre: boolean;
series: boolean;
url: boolean;
form: boolean;
total: boolean;
};
};
export const KINDS: Record<MediaKind, KindConfig> = {
webtoon: {
kind: 'webtoon',
name: 'webtoons',
href: '/trackers/webtoons',
title: 'webtoons',
blurb: 'Webtoons and manhwa we read: where we are, what is next and what we thought.',
statuses: [
{ id: 'reading', label: 'reading', phase: 'current' },
{ id: 'completed', label: 'completed', phase: 'done' },
{ id: 'on_hold', label: 'on hold', phase: 'paused' },
{ id: 'dropped', label: 'dropped', phase: 'dropped' },
{ id: 'plan', label: 'plan to read', phase: 'plan' }
],
platforms: ['Webtoon', 'Naver', 'Kakao', 'Tapas', 'Lezhin', 'Tappytoon', 'Manta', 'Other'],
creatorLabel: 'author',
progressLabel: 'episode / chapter',
unit: 'ep',
verbs: { current: 'reading', done: 'read' },
fields: { genre: true, series: true, url: true, form: false, total: true }
},
podcast: {
kind: 'podcast',
name: 'podcasts',
href: '/trackers/podcasts',
title: 'podcasts',
blurb: 'Podcasts we listen to: what we follow, where we are and what we are caught up on.',
statuses: [
{ id: 'listening', label: 'listening', phase: 'current' },
{ id: 'caught_up', label: 'caught up', phase: 'done' },
{ id: 'paused', label: 'paused', phase: 'paused' },
{ id: 'dropped', label: 'dropped', phase: 'dropped' },
{ id: 'plan', label: 'plan to listen', phase: 'plan' }
],
platforms: ['Spotify', 'Apple Podcasts', 'YouTube', 'Pocket Casts', 'Overcast', 'Other'],
creatorLabel: 'host',
progressLabel: 'episode',
unit: 'ep',
verbs: { current: 'listening', done: 'caught up' },
fields: { genre: true, series: true, url: true, form: false, total: true }
},
video: {
kind: 'video',
name: 'videos',
href: '/trackers/videos',
title: 'videos',
blurb: 'YouTube channels, single videos and playlists worth following or saving.',
statuses: [
{ id: 'watching', label: 'watching', phase: 'current' },
{ id: 'following', label: 'following', phase: 'current' },
{ id: 'watched', label: 'watched', phase: 'done' },
{ id: 'dropped', label: 'dropped', phase: 'dropped' },
{ id: 'plan', label: 'watch later', phase: 'plan' }
],
platforms: ['YouTube', 'Twitch', 'Vimeo', 'TikTok', 'Nebula', 'Other'],
creatorLabel: 'channel / creator',
progressLabel: 'video',
unit: 'vid',
verbs: { current: 'watching', done: 'watched' },
fields: { genre: true, series: false, url: true, form: true, total: true }
}
};
export const kindByName = (name: string) => Object.values(KINDS).find((k) => k.name === name);
export const statusOf = (kind: MediaKind, status: string) =>
KINDS[kind].statuses.find((s) => s.id === status);
export const phaseOf = (kind: MediaKind, status: string): Phase =>
statusOf(kind, status)?.phase ?? 'plan';
/** Every (kind, status) pair, for the DB check constraint and validation. */
export const ALL_STATUSES = Object.values(KINDS).flatMap((k) =>
k.statuses.map((s) => `${k.kind}:${s.id}`)
);
+26 -4
View File
@@ -2,18 +2,18 @@ import { describe, expect, it } from 'vitest';
import { activeGames, pokemonEntryFor, sortedGames, visibleTrackers } from './projects';
describe('trackers', () => {
it('lists tasks, money, reading first for the owner', () => {
it('lists tasks, money, media first for the owner', () => {
expect(
visibleTrackers(true)
.map((t) => t.name)
.slice(0, 4)
).toEqual(['tasks', 'money', 'reading', 'spotting']);
).toEqual(['tasks', 'money', 'media', 'spotting']);
});
it('hides private trackers when logged out', () => {
const names = visibleTrackers(false).map((t) => t.name);
expect(names).not.toContain('tasks');
expect(names).not.toContain('health');
expect(names.slice(0, 3)).toEqual(['money', 'reading', 'spotting']);
expect(names.slice(0, 3)).toEqual(['money', 'media', 'spotting']);
});
it('uses short single-word names', () => {
for (const t of visibleTrackers(true)) expect(t.name).toMatch(/^[a-z]+$/);
@@ -26,7 +26,29 @@ describe('trackers', () => {
expect(t.subtitle).not.toMatch(/^private:/);
}
expect(owner.find((t) => t.name === 'money')?.locked).toBe(true);
expect(owner.find((t) => t.name === 'reading')?.locked).toBeUndefined();
expect(owner.find((t) => t.name === 'media')?.locked).toBeUndefined();
});
it('nests reading, movies, shows, webtoons, music, podcasts and videos under media', () => {
const media = visibleTrackers(false).find((t) => t.name === 'media')!;
expect(media.href).toBe('/trackers/media');
expect(media.children!.map((c) => c.name)).toEqual([
'reading',
'movies',
'shows',
'webtoons',
'music',
'podcasts',
'videos'
]);
expect(media.children!.find((c) => c.name === 'music')!.href).toBe('/trackers/music');
const top = visibleTrackers(true).map((t) => t.name);
for (const n of ['reading', 'movies', 'music']) expect(top).not.toContain(n);
});
it('locks movies and shows inside media when the movies tracker is private', () => {
const pub = visibleTrackers(false, [], ['movies', 'shows']).find((t) => t.name === 'media')!;
expect(pub.children!.map((c) => c.name)).not.toContain('movies');
const own = visibleTrackers(true, [], ['movies', 'shows']).find((t) => t.name === 'media')!;
expect(own.children!.find((c) => c.name === 'shows')?.locked).toBe(true);
});
it('splits games into active first and non-active (flagged) after', () => {
const off = ['lol', 'black-ops', 'pokemon-x-y'];
+63 -21
View File
@@ -142,6 +142,55 @@ export const spottingTrackers: readonly Tracker[] = [
}
];
// Media: everything we read, watch and listen to, grouped under one "media" item like spotting.
// Reading, movies and music keep their own trackers and URLs; shows and anime are views of the
// movies tracker (/trackers/shows redirects to /trackers/movies?type=show); webtoons, podcasts
// and videos share media.items (#lib/media/kinds). /trackers/media is the hub.
export const mediaTrackers: readonly Tracker[] = [
{
name: 'reading',
subtitle: 'books, scientific papers and articles I am reading, have read and want to read',
href: '/trackers/books',
external: false
},
{
name: 'movies',
subtitle: 'movies we watch: Netflix, Plex and cinema, ratings and stats',
href: '/trackers/movies',
external: false
},
{
name: 'shows',
subtitle: 'TV shows and anime we watch, episode by episode',
href: '/trackers/shows',
external: false
},
{
name: 'webtoons',
subtitle: 'webtoons and manhwa we read, chapter by chapter',
href: '/trackers/webtoons',
external: false
},
{
name: 'music',
subtitle: 'everything I listen to: top artists, tracks, albums and streaks',
href: '/trackers/music',
external: false
},
{
name: 'podcasts',
subtitle: 'podcasts we follow and where we are in them',
href: '/trackers/podcasts',
external: false
},
{
name: 'videos',
subtitle: 'YouTube channels, videos and playlists worth watching',
href: '/trackers/videos',
external: false
}
];
// Games I play (plus speedcubing), grouped under "games" like spotting: every game has its
// own page at /trackers/games/<slug>, one click away from the header flyout and the chips.
// `featured` games (my main game and the ones I actively play) show first on tiles; the
@@ -334,10 +383,11 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
private: false
},
{
name: 'reading',
subtitle: 'books, scientific papers and articles I am reading, have read and want to read',
href: '/trackers/books',
name: 'media',
subtitle: 'reading, movies, shows, webtoons, music, podcasts and videos',
href: '/trackers/media',
external: false,
children: mediaTrackers,
private: false
},
{
@@ -348,20 +398,6 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
children: spottingTrackers,
private: false
},
{
name: 'music',
subtitle: 'everything I listen to: top artists, tracks, albums and streaks',
href: '/trackers/music',
external: false,
private: false
},
{
name: 'movies',
subtitle: 'movies and TV shows we watch: Netflix, Plex and cinema, ratings and stats',
href: '/trackers/movies',
external: false,
private: false
},
{
name: 'pc',
subtitle: 'my PC setup: every part, my devices, the next build and mouse sensitivities',
@@ -495,10 +531,16 @@ export function visibleTrackers(
hidden: readonly string[] = []
): readonly Tracker[] {
let list = isOwner ? ownerTrackers : trackers;
if (hidden.length)
list = isOwner
? list.map((t) => (hidden.includes(t.name) ? { ...t, locked: true } : t))
: list.filter((t) => !hidden.includes(t.name));
if (hidden.length) {
// Hidden trackers can sit at the top level or inside a group (movies and shows in media).
const hide = (items: readonly Tracker[]): readonly Tracker[] =>
isOwner
? items.map((t) => (hidden.includes(t.name) ? { ...t, locked: true } : t))
: items.filter((t) => !hidden.includes(t.name));
list = hide(list).map((t) =>
t.children && t.children !== gameTrackers ? { ...t, children: hide(t.children) } : t
);
}
if (!inactiveGames.length) return list;
const games = sortedGames(inactiveGames);
return list.map((t) => (t.children === gameTrackers ? { ...t, children: games } : t));
+1
View File
@@ -36,3 +36,4 @@ export * from './notes';
export * from './math';
export * from './plant';
export * from './radio';
export * from './media';
+70
View File
@@ -0,0 +1,70 @@
import { sql } from 'drizzle-orm';
import { check, date, index, integer, real, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { mediaSchema } from './schemas';
import { id, timestamps } from './helpers';
// Media tracker children that have no tracker of their own (/trackers/webtoons, /podcasts,
// /videos): one row per series, show or channel, typed by `kind` (#lib/media/kinds has the
// statuses per kind). Rows come from the owner's forms and the idempotent JSON import
// (scripts/media-import.ts or the page's import box), deduped by `match_key`.
export const mediaItems = mediaSchema.table(
'items',
{
id: id(),
kind: text('kind').notNull(),
/** `kind:normalised title` (+ `:creator` for videos), the import's dedupe key. */
matchKey: text('match_key').notNull(),
title: text('title').notNull(),
/** Author (webtoons), host (podcasts) or channel / creator (videos). */
creator: text('creator'),
platform: text('platform'),
genre: text('genre'),
status: text('status').notNull(),
/** Current episode / chapter / video number. */
progress: integer('progress'),
total: integer('total'),
/** Is the series itself still running (ongoing) or finished (completed)? */
seriesStatus: text('series_status'),
/** Videos only: channel, single video or playlist. */
form: text('form'),
url: text('url'),
coverUrl: text('cover_url'),
rating: real('rating'),
who: text('who'),
startedOn: date('started_on'),
finishedOn: date('finished_on'),
notes: text('notes'),
...timestamps
},
(t) => [
uniqueIndex('items_match_key').on(t.matchKey),
index('items_kind_status_idx').on(t.kind, t.status),
check('items_kind_check', sql`${t.kind} IN ('webtoon','podcast','video')`),
check(
'items_status_check',
sql`(${t.kind} = 'webtoon' AND ${t.status} IN ('reading','completed','on_hold','dropped','plan'))
OR (${t.kind} = 'podcast' AND ${t.status} IN ('listening','caught_up','paused','dropped','plan'))
OR (${t.kind} = 'video' AND ${t.status} IN ('watching','following','watched','dropped','plan'))`
),
check('items_title_check', sql`length(btrim(${t.title})) > 0`),
check(
'items_series_check',
sql`${t.seriesStatus} IS NULL OR ${t.seriesStatus} IN ('ongoing','completed')`
),
check(
'items_form_check',
sql`${t.form} IS NULL OR ${t.form} IN ('channel','video','playlist')`
),
check('items_who_check', sql`${t.who} IS NULL OR ${t.who} IN ('stan','juhee','both')`),
check(
'items_rating_check',
sql`${t.rating} IS NULL OR (${t.rating} >= 0.5 AND ${t.rating} <= 5 AND ${t.rating} * 2 = round(${t.rating} * 2))`
),
check(
'items_progress_check',
sql`(${t.progress} IS NULL OR ${t.progress} >= 0) AND (${t.total} IS NULL OR ${t.total} >= 0)`
),
check('items_url_check', sql`${t.url} IS NULL OR ${t.url} ~ '^https?://'`),
check('items_cover_check', sql`${t.coverUrl} IS NULL OR ${t.coverUrl} ~ '^https://'`)
]
);
+3 -1
View File
@@ -1,5 +1,5 @@
import { sql } from 'drizzle-orm';
import { check, date, index, integer, real, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { boolean, check, date, index, integer, real, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { moviesSchema } from './schemas';
import { id, timestamps, timestamptz } from './helpers';
@@ -44,6 +44,8 @@ export const movieTitles = moviesSchema.table(
source: text('source').notNull(),
sources: text('sources').array().notNull().default([]),
notes: text('notes'),
/** Anime: a filter inside Shows (set from TMDB: Japanese animation; the owner can toggle it). */
anime: boolean('anime').notNull().default(false),
// TMDB metadata cache.
matchStatus: text('match_status').notNull().default('pending'),
posterPath: text('poster_path'),
+1
View File
@@ -32,3 +32,4 @@ export const notesSchema = pgSchema('notes');
export const mathSchema = pgSchema('math');
export const plantSchema = pgSchema('plant');
export const radioSchema = pgSchema('radio');
export const mediaSchema = pgSchema('media');
+219
View File
@@ -0,0 +1,219 @@
// /trackers/media: the hub over every media tracker. Quick stats per child, a combined
// "currently consuming" feed and a mixed watchlist. Each source fails soft (null / empty), so
// one broken tracker never takes the hub down. Movies and shows follow the movies tracker's
// visibility switch (hidden from everyone else when it is private).
import { client as sql } from '#lib/server/db/index.js';
import { currentYear, libraryCards, libraryStats } from '#lib/server/books/library.js';
import { getVisibility, loadTitles, type Title } from '#lib/server/movies/site.js';
import { getListening } from '#lib/server/music/stats.js';
import { KINDS, phaseOf } from '#lib/media/kinds.js';
import { loadAllItems } from './site';
export type HubType = 'reading' | 'movies' | 'shows' | 'webtoons' | 'music' | 'podcasts' | 'videos';
export type HubEntry = {
key: string;
type: HubType;
title: string;
sub: string | null;
image: string | null;
href: string;
/** 0–1 when known. */
progress: number | null;
progressText: string | null;
at: string | null;
live?: boolean;
};
export type HubStat = { value: number | string; label: string }[];
const safe = <T>(p: Promise<T>, fallback: T) => p.catch(() => fallback);
const poster = (p: string | null) => (p ? `https://image.tmdb.org/t/p/w185${p}` : null);
async function musicPlaysThisYear(year: number): Promise<number | null> {
const [r] = await sql`select count(*)::int as n from music.plays
where played_at >= make_timestamptz(${year}, 1, 1, 0, 0, 0, 'Europe/Amsterdam')`;
return r?.n ?? null;
}
export async function loadHub(owner: boolean) {
const year = currentYear();
const ys = String(year);
const [visibility, books, bookStats, titles, items, listening, plays] = await Promise.all([
safe(getVisibility(), 'public' as const),
safe(libraryCards(false), []),
safe(libraryStats(), null),
safe(loadTitles(), [] as Title[]),
loadAllItems(),
safe(getListening(), null),
safe(musicPlaysThisYear(year), null)
]);
const moviesVisible = owner || visibility === 'public';
const allTitles = moviesVisible ? titles : [];
const movies = allTitles.filter((t) => t.type === 'movie');
const shows = allTitles.filter((t) => t.type === 'show');
// ---- quick stats per child
const stats: Partial<Record<HubType, HubStat>> = {};
if (bookStats)
stats.reading = [
{ value: bookStats.reading, label: 'reading' },
{ value: bookStats.finishedThisYear, label: `finished in ${ys}` }
];
if (moviesVisible) {
stats.movies = [
{
value: movies.filter((t) => t.status === 'watched' && t.lastWatched?.startsWith(ys)).length,
label: `watched in ${ys}`
},
{ value: movies.filter((t) => t.status === 'watchlist').length, label: 'on the watchlist' }
];
stats.shows = [
{ value: shows.filter((t) => t.status === 'watching').length, label: 'watching' },
{
value: shows.filter((t) => t.status === 'watched' && t.lastWatched?.startsWith(ys)).length,
label: `finished in ${ys}`
},
{ value: shows.filter((t) => t.anime).length, label: 'anime' }
];
}
for (const cfg of Object.values(KINDS)) {
const mine = items.filter((i) => i.kind === cfg.kind);
stats[cfg.name] = [
{
value: mine.filter((i) => phaseOf(cfg.kind, i.status) === 'current').length,
label: cfg.verbs.current
},
cfg.kind === 'podcast'
? {
value: mine.filter((i) => phaseOf(cfg.kind, i.status) === 'done').length,
label: 'caught up'
}
: {
value: mine.filter(
(i) => phaseOf(cfg.kind, i.status) === 'done' && i.finishedOn?.startsWith(ys)
).length,
label: `finished in ${ys}`
}
];
}
if (plays != null)
stats.music = [{ value: plays.toLocaleString('en-GB'), label: `plays in ${ys}` }];
// ---- currently consuming
const current: HubEntry[] = [];
if (listening?.nowPlaying)
current.push({
key: 'music:now',
type: 'music',
title: listening.track,
sub: listening.artists,
image: listening.image,
href: '/trackers/music',
progress:
listening.progressMs != null && listening.durationMs
? Math.min(1, listening.progressMs / listening.durationMs)
: null,
progressText: 'playing now',
at: listening.at,
live: true
});
for (const b of books.filter((b) => b.status === 'reading'))
current.push({
key: `reading:${b.id}`,
type: 'reading',
title: b.title,
sub: b.authors.join(', ') || b.site,
image: b.cover,
href: `/trackers/books/${b.id}`,
progress: b.percent ? b.percent / 100 : null,
progressText: b.percent ? `${Math.round(b.percent)}%` : null,
at: b.lastReadAt ?? b.addedAt
});
for (const t of allTitles.filter((t) => t.status === 'watching'))
current.push({
key: `${t.type === 'show' ? 'shows' : 'movies'}:${t.id}`,
type: t.type === 'show' ? 'shows' : 'movies',
title: t.title,
sub: [t.anime ? 'anime' : null, t.platform].filter(Boolean).join(' · ') || null,
image: poster(t.posterPath),
href: `/trackers/movies?type=${t.type}`,
progress: t.totalEpisodes ? Math.min(1, t.episodesWatched / t.totalEpisodes) : null,
progressText: t.episodesWatched
? `${t.episodesWatched}${t.totalEpisodes ? ` / ${t.totalEpisodes}` : ''} eps`
: null,
at: t.lastWatched
});
for (const i of items.filter((i) => phaseOf(i.kind, i.status) === 'current')) {
const cfg = KINDS[i.kind];
current.push({
key: `${cfg.name}:${i.id}`,
type: cfg.name,
title: i.title,
sub: [i.creator, i.platform].filter(Boolean).join(' · ') || null,
image: i.coverUrl,
href: cfg.href,
progress: i.progress != null && i.total ? Math.min(1, i.progress / i.total) : null,
progressText:
i.progress != null ? `${cfg.unit} ${i.progress}${i.total ? ` / ${i.total}` : ''}` : null,
at: i.updatedAt
});
}
current.sort(
(a, b) => Number(!!b.live) - Number(!!a.live) || (b.at ?? '').localeCompare(a.at ?? '')
);
// ---- mixed watchlist
const watchlist: HubEntry[] = [];
for (const b of books.filter((b) => b.status === 'want_to_read'))
watchlist.push({
key: `reading:${b.id}`,
type: 'reading',
title: b.title,
sub: b.authors.join(', ') || b.site || b.kind,
image: b.cover,
href: `/trackers/books/${b.id}`,
progress: null,
progressText: b.kind === 'book' ? null : b.kind,
at: b.addedAt
});
for (const t of allTitles.filter((t) => t.status === 'watchlist'))
watchlist.push({
key: `${t.type === 'show' ? 'shows' : 'movies'}:${t.id}`,
type: t.type === 'show' ? 'shows' : 'movies',
title: t.title,
sub: [t.year, t.genres.slice(0, 2).join(', ')].filter(Boolean).join(' · ') || null,
image: poster(t.posterPath),
href: `/trackers/movies?type=${t.type}&status=watchlist`,
progress: null,
progressText: t.anime ? 'anime' : null,
at: null
});
for (const i of items.filter((i) => phaseOf(i.kind, i.status) === 'plan')) {
const cfg = KINDS[i.kind];
watchlist.push({
key: `${cfg.name}:${i.id}`,
type: cfg.name,
title: i.title,
sub: [i.creator, i.platform].filter(Boolean).join(' · ') || null,
image: i.coverUrl,
href: cfg.href,
progress: null,
progressText: i.genre,
at: i.updatedAt
});
}
const music = listening
? { nowPlaying: listening.nowPlaying, track: listening.track, artists: listening.artists }
: null;
return {
year,
stats,
current,
watchlist,
music,
moviesVisible,
moviesPrivate: visibility === 'private'
};
}
+41
View File
@@ -0,0 +1,41 @@
// Shared load + actions for the media children pages (/trackers/webtoons, /podcasts, /videos).
// Public to read; every action is owner-only (requireOwner, and ownerWritePrefixes in hooks).
import { fail, type RequestEvent, type ServerLoadEvent } from '@sveltejs/kit';
import { isOwner, requireOwner } from '#lib/server/owner.js';
import { KINDS, type MediaKind } from '#lib/media/kinds.js';
import { addItem, deleteItem, importJson, loadItems, stepItem, updateItem } from './site';
export function mediaLoad(kind: MediaKind) {
return async (event: ServerLoadEvent) => {
const owner = isOwner(event);
if (owner) event.setHeaders({ 'cache-control': 'private, no-store' });
const cfg = KINDS[kind];
return {
owner,
kind,
items: await loadItems(kind),
seo: {
title: `${cfg.title[0].toUpperCase()}${cfg.title.slice(1)} tracker`,
description: cfg.blurb
}
};
};
}
type Fn = (kind: MediaKind, form: FormData) => Promise<{ ok: boolean; message?: string }>;
const run = (kind: MediaKind, fn: Fn) => async (event: RequestEvent) => {
requireOwner(event);
const res = await fn(kind, await event.request.formData());
if (!res.ok) return fail(400, { message: res.message ?? 'Invalid' });
return { ok: true, message: res.message };
};
export function mediaActions(kind: MediaKind) {
return {
add: run(kind, addItem),
update: run(kind, updateItem),
step: run(kind, stepItem),
delete: run(kind, deleteItem),
import: run(kind, importJson)
};
}
+202
View File
@@ -0,0 +1,202 @@
// Media children (/trackers/webtoons, /podcasts, /videos): page reads and the owner's actions.
// Writes go through ./store (shared with scripts/media-import.ts).
import { client as sql } from '#lib/server/db/index.js';
import { KINDS, phaseOf, type MediaKind } from '#lib/media/kinds.js';
import { importRows, matchKey, parseImport, toRow } from './store';
export type MediaItem = {
id: number;
kind: MediaKind;
title: string;
creator: string | null;
platform: string | null;
genre: string | null;
status: string;
progress: number | null;
total: number | null;
seriesStatus: string | null;
form: string | null;
url: string | null;
coverUrl: string | null;
rating: number | null;
who: string | null;
startedOn: string | null;
finishedOn: string | null;
notes: string | null;
updatedAt: string;
};
const toItem = (r: Record<string, unknown>): MediaItem => ({
id: r.id as number,
kind: r.kind as MediaKind,
title: r.title as string,
creator: (r.creator as string) ?? null,
platform: (r.platform as string) ?? null,
genre: (r.genre as string) ?? null,
status: r.status as string,
progress: (r.progress as number) ?? null,
total: (r.total as number) ?? null,
seriesStatus: (r.series_status as string) ?? null,
form: (r.form as string) ?? null,
url: (r.url as string) ?? null,
coverUrl: (r.cover_url as string) ?? null,
rating: (r.rating as number) ?? null,
who: (r.who as string) ?? null,
startedOn: (r.started_on as string) ?? null,
finishedOn: (r.finished_on as string) ?? null,
notes: (r.notes as string) ?? null,
updatedAt: new Date(r.updated_at as string).toISOString()
});
const columns = sql`id, kind, title, creator, platform, genre, status, progress, total, series_status, form,
url, cover_url, rating, who, started_on::text, finished_on::text, notes, coalesce(updated_at, created_at) as updated_at`;
/** All items of one kind, most recently touched first. Empty (not an error) before the migration. */
export async function loadItems(kind: MediaKind): Promise<MediaItem[]> {
try {
const rows = await sql`select ${columns} from media.items where kind = ${kind}
order by coalesce(updated_at, created_at) desc, id desc`;
return rows.map(toItem);
} catch {
return [];
}
}
/** Every item (the hub). */
export async function loadAllItems(): Promise<MediaItem[]> {
try {
const rows = await sql`select ${columns} from media.items
order by coalesce(updated_at, created_at) desc, id desc`;
return rows.map(toItem);
} catch {
return [];
}
}
type Result = { ok: true; message?: string } | { ok: false; message: string };
const get = (form: FormData, key: string) => {
const v = form.get(key);
return typeof v === 'string' && v.trim() !== '' ? v.trim() : null;
};
const formEntry = (form: FormData) => ({
title: get(form, 'title') ?? '',
creator: get(form, 'creator'),
platform: get(form, 'platform'),
genre: get(form, 'genre'),
status: get(form, 'status') ?? undefined,
progress: get(form, 'progress'),
total: get(form, 'total'),
series_status: get(form, 'series_status'),
form: get(form, 'form'),
url: get(form, 'url'),
cover_url: get(form, 'cover_url'),
rating: get(form, 'rating'),
who: get(form, 'who'),
started: get(form, 'started'),
finished: get(form, 'finished'),
notes: get(form, 'notes')
});
const today = () =>
new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Amsterdam' }).format(new Date());
const idOf = (form: FormData) => {
const n = Number(form.get('id'));
return Number.isInteger(n) && n > 0 ? n : null;
};
export async function addItem(kind: MediaKind, form: FormData): Promise<Result> {
const r = toRow(kind, formEntry(form));
if (!r.ok) return r;
const row = r.row;
const phase = phaseOf(kind, row.status ?? 'plan');
if (phase === 'current' && !row.startedOn) row.startedOn = today();
if (phase === 'done' && !row.finishedOn && kind !== 'podcast') row.finishedOn = today();
await importRows(sql, [row]);
return { ok: true };
}
/** The edit form: every field is set as given (empty clears it). */
export async function updateItem(kind: MediaKind, form: FormData): Promise<Result> {
const id = idOf(form);
if (!id) return { ok: false, message: 'missing id' };
const r = toRow(kind, formEntry(form));
if (!r.ok) return r;
const v = r.row;
const [prev] = await sql`select status from media.items where id = ${id} and kind = ${kind}`;
if (!prev) return { ok: false, message: 'not found' };
const status = v.status ?? prev.status;
// Moving into "done" stamps today's date unless one is given; starting stamps the start.
const phase = phaseOf(kind, status);
if (
phase === 'done' &&
!v.finishedOn &&
phaseOf(kind, prev.status) !== 'done' &&
kind !== 'podcast'
)
v.finishedOn = today();
if (phase === 'current' && !v.startedOn && phaseOf(kind, prev.status) === 'plan')
v.startedOn = today();
try {
await sql`update media.items set
match_key = ${matchKey(kind, v.title, v.creator)}, title = ${v.title}, creator = ${v.creator},
platform = ${v.platform}, genre = ${v.genre}, status = ${status}, progress = ${v.progress},
total = ${v.total}, series_status = ${v.seriesStatus}, form = ${v.form}, url = ${v.url},
cover_url = ${v.coverUrl}, rating = ${v.rating}, who = ${v.who}, started_on = ${v.startedOn},
finished_on = ${v.finishedOn}, notes = ${v.notes}, updated_at = now()
where id = ${id} and kind = ${kind}`;
} catch (e) {
if ((e as { code?: string }).code === '23505')
return { ok: false, message: `“${v.title}” is already on the list` };
throw e;
}
return { ok: true };
}
/** +1 / −1 on the current episode; reaching the total of a finished series completes it. */
export async function stepItem(kind: MediaKind, form: FormData): Promise<Result> {
const id = idOf(form);
if (!id) return { ok: false, message: 'missing id' };
const by = form.get('by') === '-1' ? -1 : 1;
const cfg = KINDS[kind];
const current = cfg.statuses.find((s) => s.phase === 'current')!.id;
const done = cfg.statuses.find((s) => s.phase === 'done')!.id;
await sql`update media.items set
progress = greatest(0, coalesce(progress, 0) + ${by}),
status = case
when ${by} > 0 and total is not null and coalesce(progress, 0) + 1 >= total
and (series_status = 'completed' or ${kind} = 'podcast') then ${done}
when status = 'plan' and ${by} > 0 then ${current}
else status end,
started_on = case when started_on is null and ${by} > 0 then ${today()}::date else started_on end,
updated_at = now()
where id = ${id} and kind = ${kind}`;
return { ok: true };
}
export async function deleteItem(kind: MediaKind, form: FormData): Promise<Result> {
const id = idOf(form);
if (!id) return { ok: false, message: 'missing id' };
await sql`delete from media.items where id = ${id} and kind = ${kind}`;
return { ok: true };
}
/** The page's import box: same JSON as scripts/media-import.ts, all or nothing. */
export async function importJson(kind: MediaKind, form: FormData): Promise<Result> {
const text = String(form.get('json') ?? '').trim();
if (!text) return { ok: false, message: 'paste a JSON array first' };
if (text.length > 2_000_000) return { ok: false, message: 'that is too big (2 MB max)' };
let json: unknown;
try {
json = JSON.parse(text);
} catch (e) {
return { ok: false, message: `not valid JSON: ${(e as Error).message}` };
}
const { rows, errors } = parseImport(json, kind);
if (errors.length)
return {
ok: false,
message: `${errors.length} problem${errors.length === 1 ? '' : 's'}, nothing imported: ${errors.slice(0, 3).join('; ')}`
};
if (rows.length > 5000) return { ok: false, message: 'max 5000 entries per import' };
const s = await importRows(sql, rows);
return { ok: true, message: `imported ${s.total}: ${s.created} new, ${s.updated} updated` };
}
+235
View File
@@ -0,0 +1,235 @@
// Media tracker children (webtoons, podcasts, videos): JSON import parsing and idempotent
// upserts into media.items. Takes a postgres.js client (no SvelteKit imports), so the site's
// owner forms and scripts/media-import.ts share it. Rows are keyed by `match_key`
// (kind + normalised title, plus the creator for videos), so re-running an import only
// updates the given fields and never duplicates a title.
import type { Sql, TransactionSql } from 'postgres';
import * as z from 'zod';
import {
KINDS,
MEDIA_KINDS,
MEDIA_WHO,
SERIES_STATUSES,
VIDEO_FORMS,
type MediaKind
} from '../../media/kinds';
type Tx = Sql | TransactionSql;
export const normTitle = (s: string) =>
s
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/&/g, ' and ')
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim();
export const matchKey = (kind: MediaKind, title: string, creator?: string | null) =>
kind === 'video' && creator
? `${kind}:${normTitle(title)}:${normTitle(creator)}`
: `${kind}:${normTitle(title)}`;
const blank = (v: unknown) => (v === '' || v === null || v === undefined ? null : v);
const opt = <T extends z.ZodType>(s: T) => z.preprocess(blank, s.nullable().optional());
const text = (max: number) => z.string().trim().max(max);
const date = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'dates are YYYY-MM-DD');
const count = z.coerce.number().int().min(0).max(1_000_000);
export const ratingSchema = z.coerce
.number()
.min(0.5)
.max(5)
.refine((n) => Number.isInteger(n * 2), 'rating is 0.5–5 in steps of 0.5');
/** Statuses accept spaces or underscores ("on hold", "plan to read" → plan). */
const normStatus = (v: unknown) =>
typeof v === 'string'
? v
.trim()
.toLowerCase()
.replace(/^plan to \w+$|^watch later$|^planned$/, 'plan')
.replace(/[\s-]+/g, '_')
: v;
/** One import entry (also the shape the owner's forms produce). */
export const importEntry = z.object({
title: text(300).min(1, 'title is required'),
creator: opt(text(200)),
author: opt(text(200)),
host: opt(text(200)),
channel: opt(text(200)),
platform: opt(text(60)),
genre: opt(text(120)),
status: z.preprocess(normStatus, z.string()).optional(),
progress: opt(count),
episode: opt(count),
chapter: opt(count),
total: opt(count),
total_episodes: opt(count),
series_status: opt(z.enum(SERIES_STATUSES)),
form: opt(z.enum(VIDEO_FORMS)),
url: opt(
z
.string()
.trim()
.url()
.max(1000)
.regex(/^https?:\/\//, 'url must be http(s)')
),
cover_url: opt(
z
.string()
.trim()
.url()
.max(1000)
.regex(/^https:\/\//, 'cover must be https')
),
rating: opt(ratingSchema),
who: opt(z.enum(MEDIA_WHO)),
started: opt(date),
finished: opt(date),
notes: opt(text(4000))
});
export type ImportEntry = z.infer<typeof importEntry>;
export type MediaRow = {
kind: MediaKind;
title: string;
creator: string | null;
platform: string | null;
genre: string | null;
status: string | null;
progress: number | null;
total: number | null;
seriesStatus: string | null;
form: string | null;
url: string | null;
coverUrl: string | null;
rating: number | null;
who: string | null;
startedOn: string | null;
finishedOn: string | null;
notes: string | null;
};
/** Validate one entry for a kind: aliases folded, status checked against the kind's list. */
export function toRow(
kind: MediaKind,
raw: unknown
): { ok: true; row: MediaRow } | { ok: false; message: string } {
const p = importEntry.safeParse(raw);
if (!p.success) {
const i = p.error.issues[0];
return { ok: false, message: `${i?.path.join('.') || 'entry'}: ${i?.message ?? 'invalid'}` };
}
const v = p.data;
const cfg = KINDS[kind];
if (v.status && !cfg.statuses.some((s) => s.id === v.status))
return {
ok: false,
message: `status "${v.status}" is not one of ${cfg.statuses.map((s) => s.id).join(', ')}`
};
return {
ok: true,
row: {
kind,
title: v.title,
creator: v.creator ?? v.author ?? v.host ?? v.channel ?? null,
platform: v.platform ?? null,
genre: v.genre ?? null,
status: v.status ?? null,
progress: v.progress ?? v.episode ?? v.chapter ?? null,
total: v.total ?? v.total_episodes ?? null,
seriesStatus: cfg.fields.series ? (v.series_status ?? null) : null,
form: cfg.fields.form ? (v.form ?? null) : null,
url: v.url ?? null,
coverUrl: v.cover_url ?? null,
rating: v.rating ?? null,
who: v.who ?? null,
startedOn: v.started ?? null,
finishedOn: v.finished ?? null,
notes: v.notes ?? null
}
};
}
/**
* Parse an import file: an array of entries (each may carry "kind"), or an object keyed by
* kind ({"webtoons": [...], "podcasts": [...], "videos": [...]}). `kind` is the default.
*/
export function parseImport(
json: unknown,
kind?: MediaKind
): { rows: MediaRow[]; errors: string[] } {
const rows: MediaRow[] = [];
const errors: string[] = [];
const take = (k: MediaKind | undefined, list: unknown, where: string) => {
if (!Array.isArray(list)) return errors.push(`${where}: expected an array`);
list.forEach((raw, i) => {
const own = (raw as { kind?: unknown })?.kind;
const k2 = typeof own === 'string' ? toKind(own) : k;
if (!k2) return errors.push(`${where}[${i}]: unknown kind`);
const r = toRow(k2, raw);
if (r.ok) rows.push(r.row);
else errors.push(`${where}[${i}] ${(raw as { title?: string })?.title ?? ''}: ${r.message}`);
});
};
if (Array.isArray(json)) take(kind, json, 'entries');
else if (json && typeof json === 'object')
for (const [key, list] of Object.entries(json)) {
const k = toKind(key);
if (!k) errors.push(`${key}: unknown kind (webtoons, podcasts or videos)`);
else take(k, list, key);
}
else errors.push('expected a JSON array or an object keyed by kind');
return { rows, errors };
}
function toKind(s: string): MediaKind | undefined {
const k = s.trim().toLowerCase();
return MEDIA_KINDS.find((m) => m === k) ?? Object.values(KINDS).find((c) => c.name === k)?.kind;
}
/**
* Insert or update one row. `mode: 'set'` (forms, JSON import) lets given fields win and keeps
* the rest; new rows default to the kind's plan status.
*/
export async function upsertRow(tx: Tx, r: MediaRow): Promise<{ id: number; created: boolean }> {
const key = matchKey(r.kind, r.title, r.creator);
const [row] = await tx`
insert into media.items (kind, match_key, title, creator, platform, genre, status, progress, total,
series_status, form, url, cover_url, rating, who, started_on, finished_on, notes)
values (${r.kind}, ${key}, ${r.title}, ${r.creator}, ${r.platform}, ${r.genre}, ${r.status ?? 'plan'},
${r.progress}, ${r.total}, ${r.seriesStatus}, ${r.form}, ${r.url}, ${r.coverUrl}, ${r.rating},
${r.who}, ${r.startedOn}, ${r.finishedOn}, ${r.notes})
on conflict (match_key) do update set
title = excluded.title,
creator = coalesce(excluded.creator, media.items.creator),
platform = coalesce(excluded.platform, media.items.platform),
genre = coalesce(excluded.genre, media.items.genre),
status = coalesce(${r.status}, media.items.status),
progress = coalesce(excluded.progress, media.items.progress),
total = coalesce(excluded.total, media.items.total),
series_status = coalesce(excluded.series_status, media.items.series_status),
form = coalesce(excluded.form, media.items.form),
url = coalesce(excluded.url, media.items.url),
cover_url = coalesce(excluded.cover_url, media.items.cover_url),
rating = coalesce(excluded.rating, media.items.rating),
who = coalesce(excluded.who, media.items.who),
started_on = coalesce(excluded.started_on, media.items.started_on),
finished_on = coalesce(excluded.finished_on, media.items.finished_on),
notes = coalesce(excluded.notes, media.items.notes),
updated_at = now()
returning id, (xmax = 0) as created`;
return { id: row.id, created: row.created };
}
export async function importRows(
sql: Sql,
rows: MediaRow[]
): Promise<{ total: number; created: number; updated: number }> {
let created = 0;
await sql.begin(async (tx) => {
for (const r of rows) if ((await upsertRow(tx, r)).created) created++;
});
return { total: rows.length, created, updated: rows.length - created };
}
+10 -5
View File
@@ -25,7 +25,7 @@ export async function getVisibility(fresh = false): Promise<Visibility> {
}
/** Tracker names hidden from the public navigation (root layout → visibleTrackers). */
export async function loadHiddenTrackers(): Promise<string[]> {
return (await getVisibility()) === 'private' ? ['movies'] : [];
return (await getVisibility()) === 'private' ? ['movies', 'shows'] : [];
}
export type Title = {
@@ -55,13 +55,15 @@ export type Title = {
episodesWatched: number;
seasons: { season: number; watched: number }[];
matchStatus: string;
/** Anime (shows): the Shows tracker's anime filter. */
anime: boolean;
};
export async function loadTitles(): Promise<Title[]> {
const rows = await sql`
select t.id, t.type, t.tmdb_id, t.title, t.year, t.status, t.rating, t.rewatch_count,
t.first_watched::text, t.last_watched::text, t.watched_by, t.platform, t.source, t.sources, t.notes,
t.poster_path, t.overview, t.genres, t."cast", t.runtime_min, t.total_seasons, t.total_episodes, t.match_status,
t.poster_path, t.overview, t.genres, t."cast", t.runtime_min, t.total_seasons, t.total_episodes, t.match_status, t.anime,
coalesce(p.plays, 0)::int as plays, coalesce(e.watched, 0)::int as episodes_watched, coalesce(e.seasons, '[]') as seasons
from movies.titles t
left join (select title_id, count(*) as plays from movies.plays group by title_id) p on p.title_id = t.id
@@ -98,7 +100,8 @@ export async function loadTitles(): Promise<Title[]> {
plays: r.plays,
episodesWatched: r.episodes_watched,
seasons: r.seasons,
matchStatus: r.match_status
matchStatus: r.match_status,
anime: r.anime
}));
}
@@ -173,7 +176,8 @@ const updateInput = z.object({
watched_by: opt(z.enum(MOVIE_WHO)),
platform: opt(z.enum(MOVIE_PLATFORMS)),
rewatch_count: z.coerce.number().int().min(0).max(1000),
notes: opt(z.string().trim().max(2000))
notes: opt(z.string().trim().max(2000)),
anime: z.preprocess((v) => v === 'on' || v === 'true', z.boolean())
});
export async function updateTitle(form: FormData): Promise<Result> {
@@ -181,7 +185,8 @@ export async function updateTitle(form: FormData): Promise<Result> {
if (!p.success) return { ok: false, message: p.error.issues[0]?.message ?? 'Invalid' };
const v = p.data;
await sql`update movies.titles set status = ${v.status}, rating = ${v.rating}, watched_by = ${v.watched_by},
platform = ${v.platform}, rewatch_count = ${v.rewatch_count}, notes = ${v.notes} where id = ${v.id}`;
platform = ${v.platform}, rewatch_count = ${v.rewatch_count}, notes = ${v.notes},
anime = ${v.anime} where id = ${v.id}`;
return { ok: true, id: v.id };
}
+7 -1
View File
@@ -51,6 +51,7 @@ type SearchHit = {
name?: string;
original_title?: string;
original_name?: string;
original_language?: string;
release_date?: string;
first_air_date?: string;
popularity?: number;
@@ -109,6 +110,7 @@ type Details = {
name?: string;
original_title?: string;
original_name?: string;
original_language?: string;
release_date?: string;
first_air_date?: string;
overview?: string;
@@ -145,13 +147,17 @@ async function saveDetails(
.sort((a, b) => a.order - b.order)
.slice(0, 8)
.map((c) => c.name);
const genres = (d.genres ?? []).map((g) => g.name);
// Anime (a filter inside Shows): Japanese animated shows. Never unsets the owner's toggle.
const anime = type === 'show' && d.original_language === 'ja' && genres.includes('Animation');
await tx`
update movies.titles set
anime = anime or ${anime},
tmdb_id = ${d.id}, match_status = ${status},
original_title = ${d.original_title ?? d.original_name ?? null},
year = coalesce(${yearOf(d.release_date ?? d.first_air_date)}::int, year),
overview = ${d.overview || null}, poster_path = ${d.poster_path ?? null}, backdrop_path = ${d.backdrop_path ?? null},
genres = ${(d.genres ?? []).map((g) => g.name)}, "cast" = ${cast}, runtime_min = ${runtime || null},
genres = ${genres}, "cast" = ${cast}, runtime_min = ${runtime || null},
total_seasons = ${d.number_of_seasons ?? null}, total_episodes = ${d.number_of_episodes ?? null},
metadata_at = now()
where id = ${titleId}`;
+4
View File
@@ -75,6 +75,10 @@ export const ownerWritePrefixes = [
'/trackers/notes',
'/trackers/ai',
'/trackers/movies',
'/trackers/media',
'/trackers/webtoons',
'/trackers/podcasts',
'/trackers/videos',
'/trackers/math',
'/trackers/cargill',
'/trackers/radio'
+6 -4
View File
@@ -2,11 +2,13 @@ import { describe, expect, it } from 'vitest';
import { existsSync } from 'node:fs';
import { gameLogos } from './game-logos';
import { trackerIcons } from './tracker-icons';
import { gameTrackers, spottingTrackers, visibleTrackers } from './projects';
import { gameTrackers, mediaTrackers, spottingTrackers, visibleTrackers } from './projects';
describe('tracker icons', () => {
it('gives every tracker its own colour', () => {
const names = [...visibleTrackers(true), ...spottingTrackers].map((t) => t.name);
const names = [...visibleTrackers(true), ...spottingTrackers, ...mediaTrackers].map(
(t) => t.name
);
for (const name of names) expect(trackerIcons[name], name).toBeDefined();
const colours = names.map((n) => trackerIcons[n].to);
expect(new Set(colours).size).toBe(colours.length);
@@ -36,11 +38,11 @@ describe('tracker icons', () => {
expect(gameTrackers[0].name).toBe('osu!');
});
it('keeps tasks, money and reading first', () => {
it('keeps tasks, money and media first', () => {
expect(
visibleTrackers(true)
.slice(0, 3)
.map((t) => t.name)
).toEqual(['tasks', 'money', 'reading']);
).toEqual(['tasks', 'money', 'media']);
});
});
+47
View File
@@ -236,6 +236,53 @@ export const trackerIcons: Record<string, TrackerIcon> = {
'M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12'
]
},
// Media hub (/trackers/media) and its children without an icon of their own.
media: {
from: '#f472b6',
to: '#9333ea',
ink: WHITE,
// library shelf
paths: ['m16 6 4 14', 'M12 6v14', 'M8 8v12', 'M4 4v16']
},
shows: {
from: '#38bdf8',
to: '#0e6fb0',
ink: WHITE,
// tv
paths: [
'M4 7h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2z',
'm17 2-5 5-5-5'
]
},
webtoons: {
from: '#4ade80',
to: '#16a34a',
ink: WHITE,
// phone with stacked comic panels
paths: [
'M7 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z',
'M5 8.5h14',
'M5 15h14',
'M12 8.5V15'
]
},
podcasts: {
from: '#c084fc',
to: '#9d4edd',
ink: WHITE,
// microphone
paths: ['M12 19v3', 'M19 10v2a7 7 0 0 1-14 0v-2', 'M9 5a3 3 0 0 1 6 0v6a3 3 0 0 1-6 0Z']
},
videos: {
from: '#f87171',
to: '#e02424',
ink: WHITE,
// play button (YouTube-like)
paths: [
'M2.5 17a24 24 0 0 1 0-10 2 2 0 0 1 1.4-1.4 49.6 49.6 0 0 1 16.2 0A2 2 0 0 1 21.5 7a24 24 0 0 1 0 10 2 2 0 0 1-1.4 1.4 49.6 49.6 0 0 1-16.2 0A2 2 0 0 1 2.5 17',
'm10 15 5-3-5-3z'
]
},
spotting: {
from: '#fbbf24',
to: '#e11d48',
+4
View File
@@ -42,8 +42,12 @@ const staticPaths = [
'/trackers/trains/countries',
'/trackers/wildlife',
'/trackers/wildlife/groups',
'/trackers/media',
'/trackers/music',
'/trackers/movies',
'/trackers/webtoons',
'/trackers/podcasts',
'/trackers/videos',
'/trackers/pc',
'/trackers/games',
...gameTrackers.map((g) => g.href),
+19
View File
@@ -0,0 +1,19 @@
import { isOwner } from '#lib/server/owner.js';
import { loadHub } from '#lib/server/media/hub.js';
import type { PageServerLoad } from './$types';
// The Media hub: one card per child tracker, what we are reading / watching / hearing now,
// and one watchlist across all of them. Public (movies and shows follow their own switch).
export const load: PageServerLoad = async (event) => {
const owner = isOwner(event);
if (owner) event.setHeaders({ 'cache-control': 'private, no-store' });
return {
owner,
...(await loadHub(owner)),
seo: {
title: 'Media',
description:
'Everything we read, watch and listen to: books, movies, shows and anime, webtoons, music, podcasts and videos.'
}
};
};
+463
View File
@@ -0,0 +1,463 @@
<script lang="ts">
// The Media hub: a card per child tracker with its quick stats, what is on right now
// across all of them, and one mixed watchlist. Data comes from #lib/server/media/hub.
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import PrivateLock from '#lib/components/PrivateLock.svelte';
import { mediaTrackers } from '#lib/projects.js';
import type { HubEntry, HubType } from '#lib/server/media/hub.js';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const NAMES: Record<HubType, string> = {
reading: 'reading',
movies: 'movies',
shows: 'shows',
webtoons: 'webtoons',
music: 'music',
podcasts: 'podcasts',
videos: 'videos'
};
const MOVIE_KIDS = new Set(['movies', 'shows']);
const cards = $derived(
mediaTrackers.filter((t) => data.moviesVisible || !MOVIE_KIDS.has(t.name))
);
let typeFilter = $state<HubType | 'all'>('all');
let showAll = $state(false);
const CAP = 24;
const counts = $derived.by(() => {
const c: Partial<Record<HubType, number>> = {};
for (const e of data.watchlist) c[e.type] = (c[e.type] ?? 0) + 1;
return c;
});
const filtered = $derived(
typeFilter === 'all' ? data.watchlist : data.watchlist.filter((e) => e.type === typeFilter)
);
const shown = $derived(showAll ? filtered : filtered.slice(0, CAP));
function initials(s: string) {
return (
s
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0]?.toUpperCase())
.join('') || '?'
);
}
</script>
{#snippet thumb(e: HubEntry)}
<span class="thumb">
{#if e.image}
<img src={e.image} alt="" loading="lazy" referrerpolicy="no-referrer" />
{:else}
<span class="init">{initials(e.title)}</span>
{/if}
<span class="badge"><TrackerIcon name={e.type} class="size-5" /></span>
</span>
{/snippet}
<div class="page">
<a class="back" href="/trackers"><ArrowLeft size={14} /> trackers</a>
<header class="head">
<TrackerIcon name="media" class="size-11" />
<div>
<h1>media</h1>
<p class="blurb">everything we read, watch and listen to, in one place.</p>
</div>
</header>
<section class="cards" aria-label="media trackers">
{#each cards as t (t.name)}
{@const st = data.stats[t.name as HubType]}
<a class="card" href={t.href}>
<span class="card-top">
<TrackerIcon name={t.name} class="size-9" />
<span class="card-name"
>{t.name}{#if data.owner && data.moviesPrivate && MOVIE_KIDS.has(t.name)}<PrivateLock
/>{/if}</span
>
</span>
{#if st?.length}
<span class="stats">
{#each st as s (s.label)}
<span class="stat"><b>{s.value}</b> {s.label}</span>
{/each}
</span>
{/if}
{#if t.name === 'music' && data.music}
<span class="music-line" class:live={data.music.nowPlaying}>
{data.music.nowPlaying ? '▶ ' : 'last: '}{data.music.track}
<span class="dim">· {data.music.artists}</span>
</span>
{:else}
<span class="desc">{t.subtitle}</span>
{/if}
</a>
{/each}
</section>
<section aria-labelledby="now-h">
<h2 id="now-h">currently consuming <span class="count">{data.current.length}</span></h2>
{#if data.current.length}
<ul class="feed">
{#each data.current as e (e.key)}
<li>
<a class="entry" class:live={e.live} href={e.href}>
{@render thumb(e)}
<span class="body">
<span class="title">{e.title}</span>
{#if e.sub}<span class="sub">{e.sub}</span>{/if}
{#if e.progress != null || e.progressText}
<span class="prog">
{#if e.progress != null}
<span class="bar"
><span style:width="{Math.round(e.progress * 100)}%"></span></span
>
{/if}
{#if e.progressText}<span class="ptext">{e.progressText}</span>{/if}
</span>
{/if}
<span class="kind">{NAMES[e.type]}</span>
</span>
</a>
</li>
{/each}
</ul>
{:else}
<p class="empty">nothing on right now.</p>
{/if}
</section>
<section aria-labelledby="wl-h">
<h2 id="wl-h">up next <span class="count">{data.watchlist.length}</span></h2>
{#if data.watchlist.length}
<div class="chips" role="group" aria-label="filter by type">
<button
type="button"
class="chip"
aria-pressed={typeFilter === 'all'}
onclick={() => (typeFilter = 'all')}>all <span>{data.watchlist.length}</span></button
>
{#each cards as t (t.name)}
{@const n = counts[t.name as HubType] ?? 0}
{#if n}
<button
type="button"
class="chip"
aria-pressed={typeFilter === t.name}
onclick={() => (typeFilter = t.name as HubType)}
><TrackerIcon name={t.name} class="size-4" /> {t.name} <span>{n}</span></button
>
{/if}
{/each}
</div>
<ul class="wl">
{#each shown as e (e.key)}
<li>
<a class="entry small" href={e.href}>
{@render thumb(e)}
<span class="body">
<span class="title">{e.title}</span>
{#if e.sub}<span class="sub">{e.sub}</span>{/if}
<span class="kind"
>{NAMES[e.type]}{#if e.progressText}&nbsp;· {e.progressText}{/if}</span
>
</span>
</a>
</li>
{/each}
</ul>
{#if filtered.length > CAP}
<button type="button" class="more" onclick={() => (showAll = !showAll)}>
{showAll ? 'show less' : `show all ${filtered.length}`}
</button>
{/if}
{:else}
<p class="empty">the watchlist is empty.</p>
{/if}
</section>
</div>
<style>
.page {
max-width: 64rem;
margin: 0 auto;
padding: 1.25rem 1rem 4rem;
display: grid;
gap: 2rem;
}
.back {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.85rem;
color: var(--site-muted);
text-decoration: none;
width: fit-content;
}
.back:hover {
color: inherit;
}
.head {
display: flex;
align-items: center;
gap: 0.9rem;
margin-top: -1rem;
}
h1 {
margin: 0;
font-size: 1.9rem;
letter-spacing: -0.02em;
}
.blurb {
margin: 0.15rem 0 0;
color: var(--site-muted);
}
h2 {
font-size: 1.05rem;
margin: 0 0 0.75rem;
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.count {
font-size: 0.8rem;
color: var(--site-muted);
font-weight: 500;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(13.5rem, 1fr));
gap: 0.75rem;
}
.card {
display: grid;
gap: 0.55rem;
align-content: start;
padding: 0.9rem;
border: 1px solid var(--site-border);
border-radius: 0.9rem;
text-decoration: none;
color: inherit;
background: var(--site-surface, transparent);
transition:
border-color 0.15s,
transform 0.15s;
}
.card:hover {
border-color: currentColor;
transform: translateY(-1px);
}
.card-top {
display: flex;
align-items: center;
gap: 0.6rem;
}
.card-name {
font-weight: 650;
font-size: 1.05rem;
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.stats {
display: flex;
flex-wrap: wrap;
gap: 0.2rem 0.75rem;
font-size: 0.85rem;
color: var(--site-muted);
}
.stat b {
color: var(--site-text);
font-variant-numeric: tabular-nums;
}
.desc,
.music-line {
font-size: 0.8rem;
color: var(--site-muted);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.music-line.live {
color: #16a34a;
}
.dim {
opacity: 0.75;
}
.feed,
.wl {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
gap: 0.6rem;
}
.wl {
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
}
.entry {
display: flex;
gap: 0.75rem;
padding: 0.6rem;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
text-decoration: none;
color: inherit;
height: 100%;
box-sizing: border-box;
}
.entry:hover {
border-color: currentColor;
}
.entry.live {
border-color: #16a34a;
}
.thumb {
position: relative;
flex-shrink: 0;
width: 3.4rem;
height: 5rem;
border-radius: 0.45rem;
background: rgb(127 127 127 / 0.15);
}
.small .thumb {
width: 2.6rem;
height: 3.8rem;
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
display: block;
}
.init {
display: grid;
place-items: center;
height: 100%;
font-weight: 700;
color: var(--site-muted);
}
.badge {
position: absolute;
right: -0.35rem;
bottom: -0.35rem;
display: flex;
border-radius: 0.4rem;
box-shadow: 0 0 0 2px var(--site-bg);
}
.body {
display: grid;
align-content: start;
gap: 0.15rem;
min-width: 0;
}
.title {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub,
.kind {
font-size: 0.78rem;
color: var(--site-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.prog {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.75rem;
margin-top: 0.2rem;
}
.bar {
flex: 1;
min-width: 3rem;
height: 0.3rem;
border-radius: 1rem;
background: rgb(127 127 127 / 0.2);
overflow: hidden;
}
.bar span {
display: block;
height: 100%;
background: currentColor;
border-radius: inherit;
}
.ptext {
color: var(--site-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 0.75rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.65rem;
border-radius: 999px;
border: 1px solid var(--site-border);
background: none;
color: inherit;
font: inherit;
font-size: 0.82rem;
cursor: pointer;
}
.chip span {
color: var(--site-muted);
font-variant-numeric: tabular-nums;
}
.chip[aria-pressed='true'] {
background: var(--site-text);
color: var(--site-bg);
border-color: transparent;
}
.chip[aria-pressed='true'] span {
color: inherit;
opacity: 0.7;
}
.more {
margin-top: 0.75rem;
background: none;
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0.4rem 0.9rem;
color: inherit;
font: inherit;
font-size: 0.85rem;
cursor: pointer;
}
.empty {
color: var(--site-muted);
font-size: 0.9rem;
}
@media (max-width: 30rem) {
.cards {
grid-template-columns: 1fr 1fr;
}
.card {
padding: 0.7rem;
}
.desc {
display: none;
}
}
</style>
+44 -7
View File
@@ -50,7 +50,23 @@
let status = $state<'all' | (typeof STATUSES)[number]>(
STATUSES.find((s) => s === initialStatus) ?? 'all'
);
let kind = $state<'all' | 'movie' | 'show'>('all');
// ?type=movie|show|anime (the Media hub's Movies, Shows and Anime links) preselects the type.
const KINDS = ['all', 'movie', 'show', 'anime'] as const;
type Kind = (typeof KINDS)[number];
const initialKind = page.url.searchParams.get('type');
let kind = $state<Kind>(KINDS.find((k) => k === initialKind) ?? 'all');
function pickKind(next: Kind) {
kind = next;
limit = 120;
const params = new SvelteURLSearchParams(page.url.searchParams);
if (next === 'all') params.delete('type');
else params.set('type', next);
const qs = params.toString();
history.replaceState(history.state, '', qs ? `?${qs}` : page.url.pathname);
}
const heading = $derived(
kind === 'movie' ? 'movies' : kind === 'show' ? 'shows' : kind === 'anime' ? 'anime' : 'movies'
);
let who = $state<'all' | (typeof WHO)[number]>('all');
let sort = $state<'recent' | 'rating' | 'title' | 'year'>('recent');
let query = $state('');
@@ -67,7 +83,7 @@
const list = data.titles.filter(
(t) =>
(status === 'all' || t.status === status) &&
(kind === 'all' || t.type === kind) &&
(kind === 'all' || (kind === 'anime' ? t.anime : t.type === kind)) &&
(who === 'all' || t.watchedBy === who || (who !== 'both' && t.watchedBy === 'both')) &&
(!q ||
t.title.toLowerCase().includes(q) ||
@@ -159,15 +175,16 @@
<TrackerIcon name="movies" class="size-10" />
<div>
<h1>
movies {#if data.visibility === 'private'}<PrivateLock size={13} />{/if}
{heading}
{#if data.visibility === 'private'}<PrivateLock size={13} />{/if}
</h1>
<p>Movies and TV shows Juhee and I watch, from Netflix, Plex and the cinema.</p>
</div>
</div>
<div class="mv-head-links">
<a class="tile" href={resolve('/trackers/music')}>
<TrackerIcon name="music" class="size-7" />
<span><strong>music</strong><small>what I listen to</small></span>
<a class="tile" href={resolve('/trackers/media')}>
<TrackerIcon name="media" class="size-7" />
<span><strong>media</strong><small>all we read, watch and hear</small></span>
</a>
{#if data.owner}
<button type="button" class="tile" onclick={() => adder?.showModal()}>
@@ -258,10 +275,15 @@
aria-label="Search"
/>
</label>
<select bind:value={kind} aria-label="Type">
<select
value={kind}
onchange={(e) => pickKind(e.currentTarget.value as Kind)}
aria-label="Type"
>
<option value="all">movies + shows</option>
<option value="movie">movies</option>
<option value="show">shows</option>
<option value="anime">anime</option>
</select>
<select bind:value={who} aria-label="Who watched">
<option value="all">everyone</option>
@@ -583,6 +605,13 @@
value={selected.rewatchCount}
/>
</label>
{#if selected.type === 'show'}
<label class="check"
><input type="checkbox" name="anime" checked={selected.anime} /> anime</label
>
{:else if selected.anime}
<input type="hidden" name="anime" value="true" />
{/if}
<label class="full"
>notes
<textarea name="notes" rows="2" maxlength="2000" value={selected.notes ?? ''}></textarea>
@@ -1461,6 +1490,14 @@
.d-form .full {
grid-column: 1 / -1;
}
.d-form label.check {
flex-direction: row;
align-items: center;
align-self: end;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--site-text);
}
.actions {
display: flex;
flex-wrap: wrap;
@@ -0,0 +1,6 @@
import { mediaActions, mediaLoad } from '#lib/server/media/route.js';
import type { Actions, PageServerLoad } from './$types';
// Podcasts: a Media child (see #lib/media/kinds). Public to read, owner-only to edit.
export const load: PageServerLoad = mediaLoad('podcast');
export const actions: Actions = mediaActions('podcast');
@@ -0,0 +1,7 @@
<script lang="ts">
import MediaTracker from '#lib/media/MediaTracker.svelte';
let { data, form } = $props();
</script>
<MediaTracker {data} {form} />
+10
View File
@@ -0,0 +1,10 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
// Shows live in the movies tracker as a type filter (?type=show; anime is ?type=anime).
// Temporary redirect, so a real /trackers/shows page can replace it later.
export const GET: RequestHandler = ({ url }) => {
const params = new URLSearchParams(url.search);
if (!params.has('type')) params.set('type', 'show');
redirect(307, `/trackers/movies?${params}`);
};
@@ -0,0 +1,6 @@
import { mediaActions, mediaLoad } from '#lib/server/media/route.js';
import type { Actions, PageServerLoad } from './$types';
// Videos: a Media child (see #lib/media/kinds). Public to read, owner-only to edit.
export const load: PageServerLoad = mediaLoad('video');
export const actions: Actions = mediaActions('video');
+7
View File
@@ -0,0 +1,7 @@
<script lang="ts">
import MediaTracker from '#lib/media/MediaTracker.svelte';
let { data, form } = $props();
</script>
<MediaTracker {data} {form} />
@@ -0,0 +1,6 @@
import { mediaActions, mediaLoad } from '#lib/server/media/route.js';
import type { Actions, PageServerLoad } from './$types';
// Webtoons: a Media child (see #lib/media/kinds). Public to read, owner-only to edit.
export const load: PageServerLoad = mediaLoad('webtoon');
export const actions: Actions = mediaActions('webtoon');
@@ -0,0 +1,7 @@
<script lang="ts">
import MediaTracker from '#lib/media/MediaTracker.svelte';
let { data, form } = $props();
</script>
<MediaTracker {data} {form} />