trackers: media hub with webtoons, podcasts and videos; shows and anime filters
Deploy production / deploy (push) Successful in 1m22s
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:
1 parent
4ef75f9d25
commit
079399635d
33 files changed
+48352
-44
No files matched your search
+2
-1
@@ -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
@@ -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();
|
||||
}
|
||||
Executable
+11
@@ -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 "$@"
|
||||
@@ -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>
|
||||
@@ -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}`)
|
||||
);
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -36,3 +36,4 @@ export * from './notes';
|
||||
export * from './math';
|
||||
export * from './plant';
|
||||
export * from './radio';
|
||||
export * from './media';
|
||||
@@ -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://'`)
|
||||
]
|
||||
);
|
||||
@@ -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'),
|
||||
|
||||
@@ -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');
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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` };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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} · {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>
|
||||
@@ -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} />
|
||||
@@ -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');
|
||||
@@ -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} />
|
||||
Reference in new issue
Block a user