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

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

No files matched your search

+4
View File
@@ -42,8 +42,12 @@ const staticPaths = [
'/trackers/trains/countries',
'/trackers/wildlife',
'/trackers/wildlife/groups',
'/trackers/media',
'/trackers/music',
'/trackers/movies',
'/trackers/webtoons',
'/trackers/podcasts',
'/trackers/videos',
'/trackers/pc',
'/trackers/games',
...gameTrackers.map((g) => g.href),
+19
View File
@@ -0,0 +1,19 @@
import { isOwner } from '#lib/server/owner.js';
import { loadHub } from '#lib/server/media/hub.js';
import type { PageServerLoad } from './$types';
// The Media hub: one card per child tracker, what we are reading / watching / hearing now,
// and one watchlist across all of them. Public (movies and shows follow their own switch).
export const load: PageServerLoad = async (event) => {
const owner = isOwner(event);
if (owner) event.setHeaders({ 'cache-control': 'private, no-store' });
return {
owner,
...(await loadHub(owner)),
seo: {
title: 'Media',
description:
'Everything we read, watch and listen to: books, movies, shows and anime, webtoons, music, podcasts and videos.'
}
};
};
+463
View File
@@ -0,0 +1,463 @@
<script lang="ts">
// The Media hub: a card per child tracker with its quick stats, what is on right now
// across all of them, and one mixed watchlist. Data comes from #lib/server/media/hub.
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import PrivateLock from '#lib/components/PrivateLock.svelte';
import { mediaTrackers } from '#lib/projects.js';
import type { HubEntry, HubType } from '#lib/server/media/hub.js';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const NAMES: Record<HubType, string> = {
reading: 'reading',
movies: 'movies',
shows: 'shows',
webtoons: 'webtoons',
music: 'music',
podcasts: 'podcasts',
videos: 'videos'
};
const MOVIE_KIDS = new Set(['movies', 'shows']);
const cards = $derived(
mediaTrackers.filter((t) => data.moviesVisible || !MOVIE_KIDS.has(t.name))
);
let typeFilter = $state<HubType | 'all'>('all');
let showAll = $state(false);
const CAP = 24;
const counts = $derived.by(() => {
const c: Partial<Record<HubType, number>> = {};
for (const e of data.watchlist) c[e.type] = (c[e.type] ?? 0) + 1;
return c;
});
const filtered = $derived(
typeFilter === 'all' ? data.watchlist : data.watchlist.filter((e) => e.type === typeFilter)
);
const shown = $derived(showAll ? filtered : filtered.slice(0, CAP));
function initials(s: string) {
return (
s
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0]?.toUpperCase())
.join('') || '?'
);
}
</script>
{#snippet thumb(e: HubEntry)}
<span class="thumb">
{#if e.image}
<img src={e.image} alt="" loading="lazy" referrerpolicy="no-referrer" />
{:else}
<span class="init">{initials(e.title)}</span>
{/if}
<span class="badge"><TrackerIcon name={e.type} class="size-5" /></span>
</span>
{/snippet}
<div class="page">
<a class="back" href="/trackers"><ArrowLeft size={14} /> trackers</a>
<header class="head">
<TrackerIcon name="media" class="size-11" />
<div>
<h1>media</h1>
<p class="blurb">everything we read, watch and listen to, in one place.</p>
</div>
</header>
<section class="cards" aria-label="media trackers">
{#each cards as t (t.name)}
{@const st = data.stats[t.name as HubType]}
<a class="card" href={t.href}>
<span class="card-top">
<TrackerIcon name={t.name} class="size-9" />
<span class="card-name"
>{t.name}{#if data.owner && data.moviesPrivate && MOVIE_KIDS.has(t.name)}<PrivateLock
/>{/if}</span
>
</span>
{#if st?.length}
<span class="stats">
{#each st as s (s.label)}
<span class="stat"><b>{s.value}</b> {s.label}</span>
{/each}
</span>
{/if}
{#if t.name === 'music' && data.music}
<span class="music-line" class:live={data.music.nowPlaying}>
{data.music.nowPlaying ? '▶ ' : 'last: '}{data.music.track}
<span class="dim">· {data.music.artists}</span>
</span>
{:else}
<span class="desc">{t.subtitle}</span>
{/if}
</a>
{/each}
</section>
<section aria-labelledby="now-h">
<h2 id="now-h">currently consuming <span class="count">{data.current.length}</span></h2>
{#if data.current.length}
<ul class="feed">
{#each data.current as e (e.key)}
<li>
<a class="entry" class:live={e.live} href={e.href}>
{@render thumb(e)}
<span class="body">
<span class="title">{e.title}</span>
{#if e.sub}<span class="sub">{e.sub}</span>{/if}
{#if e.progress != null || e.progressText}
<span class="prog">
{#if e.progress != null}
<span class="bar"
><span style:width="{Math.round(e.progress * 100)}%"></span></span
>
{/if}
{#if e.progressText}<span class="ptext">{e.progressText}</span>{/if}
</span>
{/if}
<span class="kind">{NAMES[e.type]}</span>
</span>
</a>
</li>
{/each}
</ul>
{:else}
<p class="empty">nothing on right now.</p>
{/if}
</section>
<section aria-labelledby="wl-h">
<h2 id="wl-h">up next <span class="count">{data.watchlist.length}</span></h2>
{#if data.watchlist.length}
<div class="chips" role="group" aria-label="filter by type">
<button
type="button"
class="chip"
aria-pressed={typeFilter === 'all'}
onclick={() => (typeFilter = 'all')}>all <span>{data.watchlist.length}</span></button
>
{#each cards as t (t.name)}
{@const n = counts[t.name as HubType] ?? 0}
{#if n}
<button
type="button"
class="chip"
aria-pressed={typeFilter === t.name}
onclick={() => (typeFilter = t.name as HubType)}
><TrackerIcon name={t.name} class="size-4" /> {t.name} <span>{n}</span></button
>
{/if}
{/each}
</div>
<ul class="wl">
{#each shown as e (e.key)}
<li>
<a class="entry small" href={e.href}>
{@render thumb(e)}
<span class="body">
<span class="title">{e.title}</span>
{#if e.sub}<span class="sub">{e.sub}</span>{/if}
<span class="kind"
>{NAMES[e.type]}{#if e.progressText}&nbsp;· {e.progressText}{/if}</span
>
</span>
</a>
</li>
{/each}
</ul>
{#if filtered.length > CAP}
<button type="button" class="more" onclick={() => (showAll = !showAll)}>
{showAll ? 'show less' : `show all ${filtered.length}`}
</button>
{/if}
{:else}
<p class="empty">the watchlist is empty.</p>
{/if}
</section>
</div>
<style>
.page {
max-width: 64rem;
margin: 0 auto;
padding: 1.25rem 1rem 4rem;
display: grid;
gap: 2rem;
}
.back {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.85rem;
color: var(--site-muted);
text-decoration: none;
width: fit-content;
}
.back:hover {
color: inherit;
}
.head {
display: flex;
align-items: center;
gap: 0.9rem;
margin-top: -1rem;
}
h1 {
margin: 0;
font-size: 1.9rem;
letter-spacing: -0.02em;
}
.blurb {
margin: 0.15rem 0 0;
color: var(--site-muted);
}
h2 {
font-size: 1.05rem;
margin: 0 0 0.75rem;
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.count {
font-size: 0.8rem;
color: var(--site-muted);
font-weight: 500;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(13.5rem, 1fr));
gap: 0.75rem;
}
.card {
display: grid;
gap: 0.55rem;
align-content: start;
padding: 0.9rem;
border: 1px solid var(--site-border);
border-radius: 0.9rem;
text-decoration: none;
color: inherit;
background: var(--site-surface, transparent);
transition:
border-color 0.15s,
transform 0.15s;
}
.card:hover {
border-color: currentColor;
transform: translateY(-1px);
}
.card-top {
display: flex;
align-items: center;
gap: 0.6rem;
}
.card-name {
font-weight: 650;
font-size: 1.05rem;
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.stats {
display: flex;
flex-wrap: wrap;
gap: 0.2rem 0.75rem;
font-size: 0.85rem;
color: var(--site-muted);
}
.stat b {
color: var(--site-text);
font-variant-numeric: tabular-nums;
}
.desc,
.music-line {
font-size: 0.8rem;
color: var(--site-muted);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.music-line.live {
color: #16a34a;
}
.dim {
opacity: 0.75;
}
.feed,
.wl {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
gap: 0.6rem;
}
.wl {
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
}
.entry {
display: flex;
gap: 0.75rem;
padding: 0.6rem;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
text-decoration: none;
color: inherit;
height: 100%;
box-sizing: border-box;
}
.entry:hover {
border-color: currentColor;
}
.entry.live {
border-color: #16a34a;
}
.thumb {
position: relative;
flex-shrink: 0;
width: 3.4rem;
height: 5rem;
border-radius: 0.45rem;
background: rgb(127 127 127 / 0.15);
}
.small .thumb {
width: 2.6rem;
height: 3.8rem;
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
display: block;
}
.init {
display: grid;
place-items: center;
height: 100%;
font-weight: 700;
color: var(--site-muted);
}
.badge {
position: absolute;
right: -0.35rem;
bottom: -0.35rem;
display: flex;
border-radius: 0.4rem;
box-shadow: 0 0 0 2px var(--site-bg);
}
.body {
display: grid;
align-content: start;
gap: 0.15rem;
min-width: 0;
}
.title {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub,
.kind {
font-size: 0.78rem;
color: var(--site-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.prog {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.75rem;
margin-top: 0.2rem;
}
.bar {
flex: 1;
min-width: 3rem;
height: 0.3rem;
border-radius: 1rem;
background: rgb(127 127 127 / 0.2);
overflow: hidden;
}
.bar span {
display: block;
height: 100%;
background: currentColor;
border-radius: inherit;
}
.ptext {
color: var(--site-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 0.75rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.65rem;
border-radius: 999px;
border: 1px solid var(--site-border);
background: none;
color: inherit;
font: inherit;
font-size: 0.82rem;
cursor: pointer;
}
.chip span {
color: var(--site-muted);
font-variant-numeric: tabular-nums;
}
.chip[aria-pressed='true'] {
background: var(--site-text);
color: var(--site-bg);
border-color: transparent;
}
.chip[aria-pressed='true'] span {
color: inherit;
opacity: 0.7;
}
.more {
margin-top: 0.75rem;
background: none;
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0.4rem 0.9rem;
color: inherit;
font: inherit;
font-size: 0.85rem;
cursor: pointer;
}
.empty {
color: var(--site-muted);
font-size: 0.9rem;
}
@media (max-width: 30rem) {
.cards {
grid-template-columns: 1fr 1fr;
}
.card {
padding: 0.7rem;
}
.desc {
display: none;
}
}
</style>
+44 -7
View File
@@ -50,7 +50,23 @@
let status = $state<'all' | (typeof STATUSES)[number]>(
STATUSES.find((s) => s === initialStatus) ?? 'all'
);
let kind = $state<'all' | 'movie' | 'show'>('all');
// ?type=movie|show|anime (the Media hub's Movies, Shows and Anime links) preselects the type.
const KINDS = ['all', 'movie', 'show', 'anime'] as const;
type Kind = (typeof KINDS)[number];
const initialKind = page.url.searchParams.get('type');
let kind = $state<Kind>(KINDS.find((k) => k === initialKind) ?? 'all');
function pickKind(next: Kind) {
kind = next;
limit = 120;
const params = new SvelteURLSearchParams(page.url.searchParams);
if (next === 'all') params.delete('type');
else params.set('type', next);
const qs = params.toString();
history.replaceState(history.state, '', qs ? `?${qs}` : page.url.pathname);
}
const heading = $derived(
kind === 'movie' ? 'movies' : kind === 'show' ? 'shows' : kind === 'anime' ? 'anime' : 'movies'
);
let who = $state<'all' | (typeof WHO)[number]>('all');
let sort = $state<'recent' | 'rating' | 'title' | 'year'>('recent');
let query = $state('');
@@ -67,7 +83,7 @@
const list = data.titles.filter(
(t) =>
(status === 'all' || t.status === status) &&
(kind === 'all' || t.type === kind) &&
(kind === 'all' || (kind === 'anime' ? t.anime : t.type === kind)) &&
(who === 'all' || t.watchedBy === who || (who !== 'both' && t.watchedBy === 'both')) &&
(!q ||
t.title.toLowerCase().includes(q) ||
@@ -159,15 +175,16 @@
<TrackerIcon name="movies" class="size-10" />
<div>
<h1>
movies {#if data.visibility === 'private'}<PrivateLock size={13} />{/if}
{heading}
{#if data.visibility === 'private'}<PrivateLock size={13} />{/if}
</h1>
<p>Movies and TV shows Juhee and I watch, from Netflix, Plex and the cinema.</p>
</div>
</div>
<div class="mv-head-links">
<a class="tile" href={resolve('/trackers/music')}>
<TrackerIcon name="music" class="size-7" />
<span><strong>music</strong><small>what I listen to</small></span>
<a class="tile" href={resolve('/trackers/media')}>
<TrackerIcon name="media" class="size-7" />
<span><strong>media</strong><small>all we read, watch and hear</small></span>
</a>
{#if data.owner}
<button type="button" class="tile" onclick={() => adder?.showModal()}>
@@ -258,10 +275,15 @@
aria-label="Search"
/>
</label>
<select bind:value={kind} aria-label="Type">
<select
value={kind}
onchange={(e) => pickKind(e.currentTarget.value as Kind)}
aria-label="Type"
>
<option value="all">movies + shows</option>
<option value="movie">movies</option>
<option value="show">shows</option>
<option value="anime">anime</option>
</select>
<select bind:value={who} aria-label="Who watched">
<option value="all">everyone</option>
@@ -583,6 +605,13 @@
value={selected.rewatchCount}
/>
</label>
{#if selected.type === 'show'}
<label class="check"
><input type="checkbox" name="anime" checked={selected.anime} /> anime</label
>
{:else if selected.anime}
<input type="hidden" name="anime" value="true" />
{/if}
<label class="full"
>notes
<textarea name="notes" rows="2" maxlength="2000" value={selected.notes ?? ''}></textarea>
@@ -1461,6 +1490,14 @@
.d-form .full {
grid-column: 1 / -1;
}
.d-form label.check {
flex-direction: row;
align-items: center;
align-self: end;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--site-text);
}
.actions {
display: flex;
flex-wrap: wrap;
@@ -0,0 +1,6 @@
import { mediaActions, mediaLoad } from '#lib/server/media/route.js';
import type { Actions, PageServerLoad } from './$types';
// Podcasts: a Media child (see #lib/media/kinds). Public to read, owner-only to edit.
export const load: PageServerLoad = mediaLoad('podcast');
export const actions: Actions = mediaActions('podcast');
@@ -0,0 +1,7 @@
<script lang="ts">
import MediaTracker from '#lib/media/MediaTracker.svelte';
let { data, form } = $props();
</script>
<MediaTracker {data} {form} />
+10
View File
@@ -0,0 +1,10 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
// Shows live in the movies tracker as a type filter (?type=show; anime is ?type=anime).
// Temporary redirect, so a real /trackers/shows page can replace it later.
export const GET: RequestHandler = ({ url }) => {
const params = new URLSearchParams(url.search);
if (!params.has('type')) params.set('type', 'show');
redirect(307, `/trackers/movies?${params}`);
};
@@ -0,0 +1,6 @@
import { mediaActions, mediaLoad } from '#lib/server/media/route.js';
import type { Actions, PageServerLoad } from './$types';
// Videos: a Media child (see #lib/media/kinds). Public to read, owner-only to edit.
export const load: PageServerLoad = mediaLoad('video');
export const actions: Actions = mediaActions('video');
+7
View File
@@ -0,0 +1,7 @@
<script lang="ts">
import MediaTracker from '#lib/media/MediaTracker.svelte';
let { data, form } = $props();
</script>
<MediaTracker {data} {form} />
@@ -0,0 +1,6 @@
import { mediaActions, mediaLoad } from '#lib/server/media/route.js';
import type { Actions, PageServerLoad } from './$types';
// Webtoons: a Media child (see #lib/media/kinds). Public to read, owner-only to edit.
export const load: PageServerLoad = mediaLoad('webtoon');
export const actions: Actions = mediaActions('webtoon');
@@ -0,0 +1,7 @@
<script lang="ts">
import MediaTracker from '#lib/media/MediaTracker.svelte';
let { data, form } = $props();
</script>
<MediaTracker {data} {form} />