media: webtoon metadata (English title, artist, synopsis, years, adaptation) and stored covers with a cover grid
Deploy production / deploy (push) Successful in 1m24s
Deploy production / deploy (push) Successful in 1m24s
This commit is contained in:
1 parent
96d7333fe9
commit
3a2d3cc78c
10 files changed
+46302
-22
No files matched your search
@@ -13,6 +13,11 @@
|
||||
import Star from 'lucide-svelte/icons/star';
|
||||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||
import ExternalLink from 'lucide-svelte/icons/external-link';
|
||||
import LayoutGrid from 'lucide-svelte/icons/layout-grid';
|
||||
import ListIcon from 'lucide-svelte/icons/list';
|
||||
import Tv from 'lucide-svelte/icons/tv';
|
||||
import CalendarRange from 'lucide-svelte/icons/calendar-range';
|
||||
import Tag from 'lucide-svelte/icons/tag';
|
||||
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';
|
||||
@@ -29,7 +34,16 @@
|
||||
|
||||
let status = $state('all');
|
||||
let who = $state<'all' | (typeof MEDIA_WHO)[number]>('all');
|
||||
let sort = $state<'recent' | 'rating' | 'title'>('recent');
|
||||
let sort = $state<'recent' | 'rating' | 'title' | 'year'>('recent');
|
||||
// Cover grid by default when the list has covers (webtoons); rows otherwise.
|
||||
let viewPick = $state<'grid' | 'list' | null>(null);
|
||||
const view = $derived(
|
||||
viewPick ??
|
||||
(data.items.length && data.items.filter((t) => t.coverUrl).length * 2 >= data.items.length
|
||||
? 'grid'
|
||||
: 'list')
|
||||
);
|
||||
const setView = (v: 'grid' | 'list') => (viewPick = v);
|
||||
let query = $state('');
|
||||
|
||||
const counts = $derived.by(() => {
|
||||
@@ -48,12 +62,15 @@
|
||||
(who === 'all' || t.who === who || (who !== 'both' && t.who === 'both')) &&
|
||||
(!q ||
|
||||
t.title.toLowerCase().includes(q) ||
|
||||
(t.titleEn ?? '').toLowerCase().includes(q) ||
|
||||
(t.artist ?? '').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));
|
||||
if (sort === 'year') return list.toSorted((a, b) => (b.yearStart ?? -1) - (a.yearStart ?? -1));
|
||||
return list;
|
||||
});
|
||||
|
||||
@@ -86,6 +103,18 @@
|
||||
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;
|
||||
const years = (t: MediaItem) =>
|
||||
t.yearStart
|
||||
? t.yearEnd && t.yearEnd !== t.yearStart
|
||||
? `${t.yearStart}–${t.yearEnd}`
|
||||
: t.seriesStatus === 'ongoing'
|
||||
? `${t.yearStart}–`
|
||||
: String(t.yearStart)
|
||||
: null;
|
||||
const people = (t: MediaItem) =>
|
||||
t.artist && t.artist !== t.creator
|
||||
? [t.creator, t.artist].filter(Boolean).join(' / ')
|
||||
: t.creator;
|
||||
|
||||
// ---- dialogs
|
||||
let selectedId = $state<number | null>(null);
|
||||
@@ -161,8 +190,16 @@
|
||||
<label class="full"
|
||||
>title <input name="title" required maxlength="300" value={t?.title ?? ''} /></label
|
||||
>
|
||||
{#if cfg.kind === 'webtoon'}
|
||||
<label class="full"
|
||||
>English title <input name="title_en" maxlength="300" value={t?.titleEn ?? ''} /></label
|
||||
>
|
||||
{/if}
|
||||
<label>{cfg.creatorLabel} <input name="creator" maxlength="200" value={t?.creator ?? ''} /></label
|
||||
>
|
||||
{#if cfg.kind === 'webtoon'}
|
||||
<label>artist <input name="artist" maxlength="200" value={t?.artist ?? ''} /></label>
|
||||
{/if}
|
||||
<label
|
||||
>platform
|
||||
<input name="platform" maxlength="60" list="platforms-{cfg.kind}" value={t?.platform ?? ''} />
|
||||
@@ -212,6 +249,23 @@
|
||||
{#each MEDIA_WHO as w (w)}<option value={w}>{whoLabel[w]}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
{#if cfg.kind === 'webtoon'}
|
||||
<label
|
||||
>first year
|
||||
<input name="year_start" type="number" min="1900" max="2100" value={t?.yearStart ?? ''} />
|
||||
</label>
|
||||
<label
|
||||
>last year
|
||||
<input name="year_end" type="number" min="1900" max="2100" value={t?.yearEnd ?? ''} />
|
||||
</label>
|
||||
<label class="full"
|
||||
>adaptation <input name="adaptation" maxlength="300" value={t?.adaptation ?? ''} /></label
|
||||
>
|
||||
<label class="full"
|
||||
>synopsis <textarea name="synopsis" rows="2" maxlength="2000" value={t?.synopsis ?? ''}
|
||||
></textarea>
|
||||
</label>
|
||||
{/if}
|
||||
<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"
|
||||
@@ -219,7 +273,13 @@
|
||||
>
|
||||
<label class="full"
|
||||
>cover image (https)
|
||||
<input name="cover_url" type="url" maxlength="1000" value={t?.coverUrl ?? ''} /></label
|
||||
<input
|
||||
name="cover_url"
|
||||
type="url"
|
||||
maxlength="1000"
|
||||
value={t?.storedCover ? '' : (t?.coverUrl ?? '')}
|
||||
placeholder={t?.storedCover ? 'stored cover in use' : ''}
|
||||
/></label
|
||||
>
|
||||
<label class="full"
|
||||
>notes <textarea name="notes" rows="2" maxlength="4000" value={t?.notes ?? ''}></textarea>
|
||||
@@ -307,11 +367,55 @@
|
||||
<option value="recent">recent</option>
|
||||
<option value="rating">rating</option>
|
||||
<option value="title">title</option>
|
||||
<option value="year">year</option>
|
||||
</select>
|
||||
<div class="views" role="group" aria-label="View">
|
||||
<button
|
||||
type="button"
|
||||
class={{ on: view === 'grid' }}
|
||||
aria-pressed={view === 'grid'}
|
||||
title="Cover grid"
|
||||
onclick={() => setView('grid')}><LayoutGrid size={15} /></button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class={{ on: view === 'list' }}
|
||||
aria-pressed={view === 'list'}
|
||||
title="Rows"
|
||||
onclick={() => setView('list')}><ListIcon size={15} /></button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if filtered.length}
|
||||
{#if filtered.length && view === 'grid'}
|
||||
<ul class="grid">
|
||||
{#each filtered as t (t.id)}
|
||||
<li>
|
||||
<button type="button" class="card" onclick={() => open(t.id)}>
|
||||
<span class="poster" style:--h={hue(t.title)}>
|
||||
{#if t.coverUrl}<img
|
||||
src={t.coverUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>{:else}<span>{initials(t.titleEn ?? t.title)}</span>{/if}
|
||||
{#if t.rating}<span class="badge"
|
||||
><Star size={10} fill="currentColor" />{t.rating}</span
|
||||
>{/if}
|
||||
{#if t.adaptation}<span class="badge adapt" title={t.adaptation}
|
||||
><Tv size={10} /></span
|
||||
>{/if}
|
||||
</span>
|
||||
<span class="card-title">{t.title}</span>
|
||||
{#if t.titleEn}<span class="card-sub">{t.titleEn}</span>{:else if t.creator}<span
|
||||
class="card-sub">{t.creator}</span
|
||||
>{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if filtered.length}
|
||||
<ul class="list">
|
||||
{#each filtered as t (t.id)}
|
||||
{@const p = pct(t)}
|
||||
@@ -324,6 +428,7 @@
|
||||
</span>
|
||||
<span class="item-text">
|
||||
<span class="item-title">{t.title}</span>
|
||||
{#if t.titleEn}<span class="item-sub en">{t.titleEn}</span>{/if}
|
||||
<span class="item-sub">
|
||||
{[t.creator, t.platform, t.form && t.form !== 'channel' ? t.form : null]
|
||||
.filter(Boolean)
|
||||
@@ -372,9 +477,21 @@
|
||||
</span>
|
||||
<div class="d-info">
|
||||
<h2>{selected.title}</h2>
|
||||
{#if selected.titleEn}<p class="d-en">{selected.titleEn}</p>{/if}
|
||||
<p class="item-sub">
|
||||
{[selected.creator, selected.platform, selected.genre].filter(Boolean).join(' · ')}
|
||||
{[people(selected), selected.platform].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
{#if selected.genre || years(selected)}
|
||||
<p class="d-facts">
|
||||
{#if years(selected)}<span
|
||||
><CalendarRange size={12} class="ic-cal" />{years(selected)}</span
|
||||
>{/if}
|
||||
{#if selected.genre}<span><Tag size={12} class="ic-tag" />{selected.genre}</span>{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{#if selected.adaptation}
|
||||
<p class="d-facts"><span><Tv size={12} class="ic-tv" />{selected.adaptation}</span></p>
|
||||
{/if}
|
||||
<p class="item-meta">
|
||||
<span class="pill ph-{phaseOf(data.kind, selected.status)}"
|
||||
>{statusLabel(selected.status)}</span
|
||||
@@ -407,6 +524,7 @@
|
||||
><X size={18} /></button
|
||||
>
|
||||
</div>
|
||||
{#if selected.synopsis}<p class="d-synopsis">{selected.synopsis}</p>{/if}
|
||||
{#if selected.notes}<p class="d-notes">{selected.notes}</p>{/if}
|
||||
{#if data.owner}
|
||||
<form
|
||||
@@ -688,6 +806,140 @@
|
||||
background: transparent;
|
||||
outline: none;
|
||||
}
|
||||
.views {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.6rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.views button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.1rem;
|
||||
color: var(--site-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.views button.on {
|
||||
background: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(7.25rem, 1fr));
|
||||
gap: 0.9rem 0.7rem;
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.75rem 0.5rem;
|
||||
}
|
||||
}
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.poster {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
aspect-ratio: 3 / 4;
|
||||
border-radius: 0.7rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--site-border);
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 1.3rem;
|
||||
background: linear-gradient(150deg, hsl(var(--h) 70% 50%), hsl(calc(var(--h) + 40) 65% 25%));
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
.card:hover .poster {
|
||||
transform: translateY(-2px);
|
||||
border-color: color-mix(in srgb, var(--site-text) 35%, transparent);
|
||||
}
|
||||
.poster img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 0.35rem;
|
||||
right: 0.35rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 999px;
|
||||
background: rgb(0 0 0 / 0.72);
|
||||
backdrop-filter: blur(4px);
|
||||
color: #fbbf24;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.badge.adapt {
|
||||
right: auto;
|
||||
left: 0.35rem;
|
||||
color: #38bdf8;
|
||||
padding: 0.15rem 0.3rem;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-sub {
|
||||
font-size: 0.7rem;
|
||||
color: var(--site-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.item-sub.en {
|
||||
font-style: italic;
|
||||
}
|
||||
.d-en {
|
||||
margin-top: -0.25rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.d-facts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.d-facts span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.d-facts :global(.ic-cal) {
|
||||
color: #a78bfa;
|
||||
}
|
||||
.d-facts :global(.ic-tag) {
|
||||
color: #34d399;
|
||||
}
|
||||
.d-facts :global(.ic-tv) {
|
||||
color: #38bdf8;
|
||||
}
|
||||
.d-synopsis {
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.45;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
|
||||
@@ -978,7 +1230,8 @@
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tile,
|
||||
.item {
|
||||
.item,
|
||||
.poster {
|
||||
transition: none;
|
||||
}
|
||||
.tile:hover,
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { check, date, index, integer, real, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import {
|
||||
check,
|
||||
customType,
|
||||
date,
|
||||
index,
|
||||
integer,
|
||||
real,
|
||||
text,
|
||||
uniqueIndex
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { mediaSchema } from './schemas';
|
||||
import { id, timestamps } from './helpers';
|
||||
import { id, timestamps, timestamptz } from './helpers';
|
||||
|
||||
const bytea = customType<{ data: Buffer }>({ dataType: () => 'bytea' });
|
||||
|
||||
// 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
|
||||
@@ -15,8 +26,12 @@ export const mediaItems = mediaSchema.table(
|
||||
/** `kind:normalised title` (+ `:creator` for videos), the import's dedupe key. */
|
||||
matchKey: text('match_key').notNull(),
|
||||
title: text('title').notNull(),
|
||||
/** English (or romanised) title when the original is e.g. Korean. */
|
||||
titleEn: text('title_en'),
|
||||
/** Author (webtoons), host (podcasts) or channel / creator (videos). */
|
||||
creator: text('creator'),
|
||||
/** Artist when it differs from the author (webtoons). */
|
||||
artist: text('artist'),
|
||||
platform: text('platform'),
|
||||
genre: text('genre'),
|
||||
status: text('status').notNull(),
|
||||
@@ -34,6 +49,14 @@ export const mediaItems = mediaSchema.table(
|
||||
startedOn: date('started_on'),
|
||||
finishedOn: date('finished_on'),
|
||||
notes: text('notes'),
|
||||
/** Short blurb (1-2 sentences) from the series' source page. */
|
||||
synopsis: text('synopsis'),
|
||||
yearStart: integer('year_start'),
|
||||
yearEnd: integer('year_end'),
|
||||
/** Adaptations, e.g. "K-drama (2016)" or "Anime (2024)". */
|
||||
adaptation: text('adaptation'),
|
||||
/** Where the metadata came from, e.g. "naver:720121 mangaupdates:akcevst". */
|
||||
sourceRef: text('source_ref'),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
@@ -65,6 +88,33 @@ export const mediaItems = mediaSchema.table(
|
||||
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://'`)
|
||||
check('items_cover_check', sql`${t.coverUrl} IS NULL OR ${t.coverUrl} ~ '^https://'`),
|
||||
check(
|
||||
'items_years_check',
|
||||
sql`(${t.yearStart} IS NULL OR ${t.yearStart} BETWEEN 1900 AND 2100) AND (${t.yearEnd} IS NULL OR ${t.yearEnd} BETWEEN 1900 AND 2100)`
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
// Cover images stored on our side (never hotlinked), served by /trackers/media/covers/[id].
|
||||
// One per item; bytes live in the DB so the cover follows the row's backups.
|
||||
export const mediaCovers = mediaSchema.table(
|
||||
'covers',
|
||||
{
|
||||
itemId: integer('item_id')
|
||||
.primaryKey()
|
||||
.references(() => mediaItems.id, { onDelete: 'cascade' }),
|
||||
mime: text('mime').notNull(),
|
||||
bytes: bytea('bytes').notNull(),
|
||||
/** Where the image was downloaded from (provenance only, never rendered). */
|
||||
sourceUrl: text('source_url'),
|
||||
updatedAt: timestamptz('updated_at').notNull().defaultNow()
|
||||
},
|
||||
(t) => [
|
||||
check(
|
||||
'covers_mime_check',
|
||||
sql`${t.mime} IN ('image/jpeg','image/png','image/webp','image/gif')`
|
||||
),
|
||||
check('covers_size_check', sql`octet_length(${t.bytes}) BETWEEN 1 AND 2000000`)
|
||||
]
|
||||
);
|
||||
@@ -23,6 +23,14 @@ export type MediaItem = {
|
||||
startedOn: string | null;
|
||||
finishedOn: string | null;
|
||||
notes: string | null;
|
||||
titleEn: string | null;
|
||||
artist: string | null;
|
||||
synopsis: string | null;
|
||||
yearStart: number | null;
|
||||
yearEnd: number | null;
|
||||
adaptation: string | null;
|
||||
/** True when coverUrl points at our own stored cover (/trackers/media/covers/[id]). */
|
||||
storedCover: boolean;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
@@ -39,23 +47,36 @@ const toItem = (r: Record<string, unknown>): MediaItem => ({
|
||||
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,
|
||||
coverUrl: r.cover_v
|
||||
? `/trackers/media/covers/${r.id}?v=${r.cover_v}`
|
||||
: ((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,
|
||||
titleEn: (r.title_en as string) ?? null,
|
||||
artist: (r.artist as string) ?? null,
|
||||
synopsis: (r.synopsis as string) ?? null,
|
||||
yearStart: (r.year_start as number) ?? null,
|
||||
yearEnd: (r.year_end as number) ?? null,
|
||||
adaptation: (r.adaptation as string) ?? null,
|
||||
storedCover: !!r.cover_v,
|
||||
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`;
|
||||
const columns = sql`i.id, i.kind, i.title, i.creator, i.platform, i.genre, i.status, i.progress, i.total,
|
||||
i.series_status, i.form, i.url, i.cover_url, i.rating, i.who, i.started_on::text, i.finished_on::text,
|
||||
i.notes, i.title_en, i.artist, i.synopsis, i.year_start, i.year_end, i.adaptation,
|
||||
extract(epoch from c.updated_at)::bigint::text as cover_v,
|
||||
coalesce(i.updated_at, i.created_at) as updated_at`;
|
||||
const from = sql`media.items i left join media.covers c on c.item_id = i.id`;
|
||||
|
||||
/** 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`;
|
||||
const rows = await sql`select ${columns} from ${from} where i.kind = ${kind}
|
||||
order by coalesce(i.updated_at, i.created_at) desc, i.id desc`;
|
||||
return rows.map(toItem);
|
||||
} catch {
|
||||
return [];
|
||||
@@ -65,8 +86,8 @@ export async function loadItems(kind: MediaKind): Promise<MediaItem[]> {
|
||||
/** 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`;
|
||||
const rows = await sql`select ${columns} from ${from}
|
||||
order by coalesce(i.updated_at, i.created_at) desc, i.id desc`;
|
||||
return rows.map(toItem);
|
||||
} catch {
|
||||
return [];
|
||||
@@ -94,7 +115,13 @@ const formEntry = (form: FormData) => ({
|
||||
who: get(form, 'who'),
|
||||
started: get(form, 'started'),
|
||||
finished: get(form, 'finished'),
|
||||
notes: get(form, 'notes')
|
||||
notes: get(form, 'notes'),
|
||||
title_en: get(form, 'title_en'),
|
||||
artist: get(form, 'artist'),
|
||||
synopsis: get(form, 'synopsis'),
|
||||
year_start: get(form, 'year_start'),
|
||||
year_end: get(form, 'year_end'),
|
||||
adaptation: get(form, 'adaptation')
|
||||
});
|
||||
const today = () =>
|
||||
new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Amsterdam' }).format(new Date());
|
||||
@@ -141,7 +168,9 @@ export async function updateItem(kind: MediaKind, form: FormData): Promise<Resul
|
||||
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()
|
||||
finished_on = ${v.finishedOn}, notes = ${v.notes}, title_en = ${v.titleEn}, artist = ${v.artist},
|
||||
synopsis = ${v.synopsis}, year_start = ${v.yearStart}, year_end = ${v.yearEnd},
|
||||
adaptation = ${v.adaptation}, updated_at = now()
|
||||
where id = ${id} and kind = ${kind}`;
|
||||
} catch (e) {
|
||||
if ((e as { code?: string }).code === '23505')
|
||||
|
||||
@@ -87,7 +87,15 @@ export const importEntry = z.object({
|
||||
who: opt(z.enum(MEDIA_WHO)),
|
||||
started: opt(date),
|
||||
finished: opt(date),
|
||||
notes: opt(text(4000))
|
||||
notes: opt(text(4000)),
|
||||
title_en: opt(text(300)),
|
||||
english_title: opt(text(300)),
|
||||
artist: opt(text(200)),
|
||||
synopsis: opt(text(2000)),
|
||||
year_start: opt(z.coerce.number().int().min(1900).max(2100)),
|
||||
year_end: opt(z.coerce.number().int().min(1900).max(2100)),
|
||||
adaptation: opt(text(300)),
|
||||
source_ref: opt(text(300))
|
||||
});
|
||||
export type ImportEntry = z.infer<typeof importEntry>;
|
||||
|
||||
@@ -109,6 +117,13 @@ export type MediaRow = {
|
||||
startedOn: string | null;
|
||||
finishedOn: string | null;
|
||||
notes: string | null;
|
||||
titleEn: string | null;
|
||||
artist: string | null;
|
||||
synopsis: string | null;
|
||||
yearStart: number | null;
|
||||
yearEnd: number | null;
|
||||
adaptation: string | null;
|
||||
sourceRef: string | null;
|
||||
};
|
||||
|
||||
/** Validate one entry for a kind: aliases folded, status checked against the kind's list. */
|
||||
@@ -147,7 +162,14 @@ export function toRow(
|
||||
who: v.who ?? null,
|
||||
startedOn: v.started ?? null,
|
||||
finishedOn: v.finished ?? null,
|
||||
notes: v.notes ?? null
|
||||
notes: v.notes ?? null,
|
||||
titleEn: v.title_en ?? v.english_title ?? null,
|
||||
artist: v.artist ?? null,
|
||||
synopsis: v.synopsis ?? null,
|
||||
yearStart: v.year_start ?? null,
|
||||
yearEnd: v.year_end ?? null,
|
||||
adaptation: v.adaptation ?? null,
|
||||
sourceRef: v.source_ref ?? null
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -197,10 +219,12 @@ export async function upsertRow(tx: Tx, r: MediaRow): Promise<{ id: number; crea
|
||||
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)
|
||||
series_status, form, url, cover_url, rating, who, started_on, finished_on, notes,
|
||||
title_en, artist, synopsis, year_start, year_end, adaptation, source_ref)
|
||||
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})
|
||||
${r.who}, ${r.startedOn}, ${r.finishedOn}, ${r.notes}, ${r.titleEn}, ${r.artist},
|
||||
${r.synopsis}, ${r.yearStart}, ${r.yearEnd}, ${r.adaptation}, ${r.sourceRef})
|
||||
on conflict (match_key) do update set
|
||||
title = excluded.title,
|
||||
creator = coalesce(excluded.creator, media.items.creator),
|
||||
@@ -218,6 +242,13 @@ export async function upsertRow(tx: Tx, r: MediaRow): Promise<{ id: number; crea
|
||||
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),
|
||||
title_en = coalesce(excluded.title_en, media.items.title_en),
|
||||
artist = coalesce(excluded.artist, media.items.artist),
|
||||
synopsis = coalesce(excluded.synopsis, media.items.synopsis),
|
||||
year_start = coalesce(excluded.year_start, media.items.year_start),
|
||||
year_end = coalesce(excluded.year_end, media.items.year_end),
|
||||
adaptation = coalesce(excluded.adaptation, media.items.adaptation),
|
||||
source_ref = coalesce(excluded.source_ref, media.items.source_ref),
|
||||
updated_at = now()
|
||||
returning id, (xmax = 0) as created`;
|
||||
return { id: row.id, created: row.created };
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Public cover images for media.items (webtoons, podcasts, videos), stored in media.covers so
|
||||
// the page never hotlinks a third-party CDN. Immutable per ?v= (the cover's updated_at).
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { client as sql } from '#lib/server/db/index.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
const [row] =
|
||||
await sql`select mime, bytes from media.covers where item_id = ${Number(params.id)}`;
|
||||
if (!row) error(404, 'Not Found');
|
||||
const body = new Uint8Array(row.bytes as Buffer);
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
'content-type': row.mime as string,
|
||||
'content-length': String(body.byteLength),
|
||||
'cache-control': url.searchParams.has('v')
|
||||
? 'public, max-age=31536000, immutable'
|
||||
: 'public, max-age=86400, stale-while-revalidate=604800',
|
||||
'x-content-type-options': 'nosniff',
|
||||
'content-security-policy': "default-src 'none'; sandbox"
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in new issue
Block a user