Add watching status and groups for shows and movies

This commit is contained in:
SirBlob committed 2026-11-01 21:50:05 -05:00
1 parent 7ef3a29ece
commit 90705bc3e2
11 files changed
+568 -244

No files matched your search

+16 -21
View File
@@ -17,10 +17,12 @@
<span class="placeholder"><Icon icon={placeholderIcon} width="36" /></span>
{/if}
<span class="rating"><Icon icon="mdi:star" width="13" />{item.rating}/10</span>
{#if item.rating !== undefined}
<span class="rating"><Icon icon="mdi:star" width="13" />{item.rating}/10</span>
{/if}
{#if details?.plot}
<p class="plot">{details.plot}</p>
{#if item.status === 'watching'}
<span class="watching"><Icon icon="mdi:play" width="13" />watching</span>
{/if}
</div>
@@ -87,24 +89,18 @@
font-size: 0.78rem;
}
.plot {
.watching {
position: absolute;
right: 0;
bottom: 0;
left: 0;
margin: 0;
padding: 0.6rem;
background: color-mix(in srgb, var(--background) 92%, transparent);
border-top: 1px solid var(--accent);
bottom: 0.4rem;
left: 0.4rem;
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0 0.4rem;
background: var(--blue);
color: var(--background);
font-size: 0.75rem;
line-height: 1.45;
transform: translateY(100%);
transition: transform 0.25s ease;
}
.media-card:hover .plot,
.media-card:focus-visible .plot {
transform: translateY(0);
font-weight: 700;
}
.text {
@@ -132,8 +128,7 @@
}
@media (prefers-reduced-motion: reduce) {
.poster img,
.plot {
.poster img {
transition: none;
}
}
@@ -1,222 +1,70 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { BookSeriesGroup } from '#lib/books/series';
import CollectionGroup from '#lib/components/ui/CollectionGroup.svelte';
import BookCard from './BookCard.svelte';
let { series }: { series: BookSeriesGroup } = $props();
let expanded = $state(false);
const shelfTones = { read: 'green', reading: 'blue', 'want-to-read': 'magenta' };
const total = $derived(series.books.length);
const shelves = $derived([
{ id: 'read', label: 'read', count: series.counts.read },
{ id: 'reading', label: 'reading', count: series.counts.reading },
{ id: 'want-to-read', label: 'want to read', count: series.counts['want-to-read'] }
]);
] as const);
const covers = $derived(
series.books.map((book) => ({
id: book.goodreadsId,
href: book.url,
title: `${book.series?.position ?? ''} ${book.title}`.trim(),
image: book.cover,
tone: shelfTones[book.shelf]
}))
);
</script>
<section class="series" class:expanded>
<header class="series-header">
<a class="series-name" href={series.url} target="_blank" rel="noopener noreferrer">
<Icon icon="mdi:bookshelf" width="16" />
{series.name}
</a>
<span class="series-counts">
{#each shelves.filter((shelf) => shelf.count > 0) as shelf (shelf.id)}
<span class="count {shelf.id}">{shelf.count} {shelf.label}</span>
{/each}
</span>
</header>
<CollectionGroup name={series.name} href={series.url} icon="mdi:bookshelf" fallbackIcon="mdi:book-outline" {covers}>
{#snippet summary()}
{#each shelves.filter((shelf) => shelf.count > 0) as shelf (shelf.id)}
<span style:color="var(--{shelfTones[shelf.id]})">{shelf.count} {shelf.label}</span>
{/each}
{/snippet}
<div class="progress" role="img" aria-label="{series.counts.read} of {total} read">
{#each shelves as shelf (shelf.id)}
{#if shelf.count}
<span class="segment {shelf.id}" style:flex-grow={shelf.count}></span>
{/if}
{#snippet details()}
<div class="progress" role="img" aria-label="{series.counts.read} of {total} read">
{#each shelves as shelf (shelf.id)}
{#if shelf.count}
<span class="segment" style:flex-grow={shelf.count} style:background="var(--{shelfTones[shelf.id]})"></span>
{/if}
{/each}
</div>
{/snippet}
<div class="book-grid">
{#each series.books as book (book.goodreadsId)}
<BookCard {book} showShelf />
{/each}
</div>
{#if expanded}
<div class="book-grid">
{#each series.books as book (book.goodreadsId)}
<BookCard {book} showShelf />
{/each}
</div>
{:else}
<div class="cover-stack">
{#each series.books as book, index (book.goodreadsId)}
<a
class="stacked-cover {book.shelf}"
style:z-index={total - index}
href={book.url}
target="_blank"
rel="noopener noreferrer"
title="{book.series?.position ?? ''} {book.title}"
>
{#if book.cover}
<img src={book.cover} alt={book.title} loading="lazy" />
{:else}
<span class="cover-fallback"><Icon icon="mdi:book-outline" width="20" /></span>
{/if}
</a>
{/each}
</div>
{/if}
<button class="toggle" onclick={() => (expanded = !expanded)}>
<Icon icon={expanded ? 'mdi:chevron-up' : 'mdi:chevron-down'} width="16" />
{expanded ? 'Collapse series' : `Expand series (${total})`}
</button>
</section>
</CollectionGroup>
<style>
.series {
padding: 0.75rem;
flex: 1 1 auto;
min-width: min(100%, 280px);
max-width: 100%;
border: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
}
.series.expanded {
flex-basis: 100%;
}
.series-header {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 0.4rem 1rem;
}
.series-name {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: var(--accent);
font-weight: 700;
}
.series-name:hover {
text-decoration: underline;
}
.series-counts {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
font-size: 0.78rem;
}
.count.read {
color: var(--green);
}
.count.reading {
color: var(--blue);
}
.count.want-to-read {
color: var(--magenta);
}
.progress {
display: flex;
gap: 2px;
height: 4px;
margin: 0.5rem 0 0.85rem;
margin-bottom: 0.35rem;
}
.segment {
flex-basis: 0;
}
.segment.read {
background: var(--green);
}
.segment.reading {
background: var(--blue);
}
.segment.want-to-read {
background: var(--magenta);
}
.cover-stack {
display: flex;
padding: 0.5rem 0 0.25rem;
overflow-x: auto;
}
.stacked-cover {
position: relative;
flex-shrink: 0;
width: 84px;
height: 126px;
border-bottom: 3px solid var(--muted);
box-shadow: 4px 0 10px color-mix(in srgb, black 45%, transparent);
transition: transform 0.15s ease;
}
.stacked-cover + .stacked-cover {
margin-left: -52px;
}
.stacked-cover:hover {
z-index: 1000 !important;
transform: translateY(-8px);
}
.stacked-cover.read {
border-bottom-color: var(--green);
}
.stacked-cover.reading {
border-bottom-color: var(--blue);
}
.stacked-cover.want-to-read {
border-bottom-color: var(--magenta);
}
.stacked-cover img,
.cover-fallback {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-fallback {
display: grid;
place-items: center;
background: color-mix(in srgb, var(--foreground) 8%, var(--background));
color: var(--muted);
}
.book-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.9rem 0.75rem;
}
.toggle {
display: inline-flex;
align-items: center;
gap: 0.3rem;
margin-top: 0.75rem;
padding: 0.3rem 0.7rem;
background: none;
border: 1px solid color-mix(in srgb, var(--foreground) 30%, transparent);
color: var(--foreground);
font-size: 0.85rem;
}
.toggle:hover {
border-color: var(--accent);
color: var(--accent);
padding-top: 0.5rem;
}
@container (max-width: 520px) {
@@ -0,0 +1,60 @@
<script lang="ts">
import type { MediaCollection } from '#lib/media/collections';
import { isWatching } from '#lib/media/collections';
import type { MediaDetails } from '#lib/media/types';
import CollectionGroup from '#lib/components/ui/CollectionGroup.svelte';
import MediaCard from '../MediaCard.svelte';
let {
collection,
mediaDetails,
kind
}: { collection: MediaCollection; mediaDetails: Record<string, MediaDetails>; kind: 'show' | 'movie' } = $props();
const icon = $derived(kind === 'show' ? 'mdi:television-classic' : 'mdi:movie-open-outline');
const watchingCount = $derived(collection.items.filter(isWatching).length);
const rated = $derived(collection.items.filter((item) => item.rating !== undefined));
const average = $derived(rated.length ? rated.reduce((total, item) => total + (item.rating ?? 0), 0) / rated.length : null);
const covers = $derived(
collection.items.map((item) => ({
id: item.url,
href: item.url,
title: item.name,
image: mediaDetails[item.url]?.poster ?? null,
tone: isWatching(item) ? 'blue' : 'green'
}))
);
</script>
<CollectionGroup name={collection.name} {icon} fallbackIcon={icon} {covers}>
{#snippet summary()}
<span class="muted">{collection.items.length} {kind === 'show' ? 'shows' : 'movies'}</span>
{#if watchingCount}
<span style:color="var(--blue)">{watchingCount} watching</span>
{/if}
{#if average !== null}
<span style:color="var(--yellow)">avg {average.toFixed(1)}/10</span>
{/if}
{/snippet}
<div class="media-grid">
{#each collection.items as item (item.url)}
<MediaCard {item} details={mediaDetails[item.url]} {kind} />
{/each}
</div>
</CollectionGroup>
<style>
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem 0.85rem;
padding-top: 0.5rem;
}
@container (max-width: 520px) {
.media-grid {
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
}
}
</style>
@@ -0,0 +1,77 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { RatedTitle } from '#lib/content';
import type { MediaDetails } from '#lib/media/types';
import { groupMedia } from '#lib/media/collections';
import MediaGrid from '../MediaGrid.svelte';
import MediaCard from '../MediaCard.svelte';
import MediaCollectionGroup from './MediaCollectionGroup.svelte';
let {
items,
mediaDetails,
kind
}: { items: RatedTitle[]; mediaDetails: Record<string, MediaDetails>; kind: 'show' | 'movie' } = $props();
const grouped = $derived(groupMedia(items, mediaDetails));
const collectionLabel = $derived(kind === 'show' ? 'Series' : 'Collections');
</script>
{#if grouped.watching.length}
<h3 class="subheading"><Icon icon="mdi:play-circle-outline" width="16" /> Currently watching <span class="muted">{grouped.watching.length}</span></h3>
<div class="watching-grid">
{#each grouped.watching as item (item.url)}
<MediaCard {item} details={mediaDetails[item.url]} {kind} />
{/each}
</div>
{/if}
{#if grouped.collections.length}
<h3 class="subheading"><Icon icon="mdi:layers-outline" width="16" /> {collectionLabel} <span class="muted">{grouped.collections.length}</span></h3>
<div class="collection-list">
{#each grouped.collections as collection (collection.name)}
<MediaCollectionGroup {collection} {mediaDetails} {kind} />
{/each}
</div>
{/if}
{#if grouped.standalone.length}
{#if grouped.watching.length || grouped.collections.length}
<h3 class="subheading"><Icon icon="mdi:check-circle-outline" width="16" /> Watched <span class="muted">{grouped.standalone.length}</span></h3>
{/if}
<MediaGrid items={grouped.standalone} {mediaDetails} {kind} />
{/if}
<style>
.subheading {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 1.25rem 0 0.6rem;
color: var(--foreground);
font-size: 0.9rem;
}
.subheading:first-child {
margin-top: 0;
}
.watching-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem 0.85rem;
}
.collection-list {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 0.75rem;
}
@container (max-width: 520px) {
.watching-grid {
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
}
}
</style>
@@ -0,0 +1,190 @@
<script lang="ts" module>
export interface StackCover {
id: string;
href: string;
title: string;
image: string | null;
tone: string;
}
</script>
<script lang="ts">
import Icon from '@iconify/svelte';
import type { Snippet } from 'svelte';
let {
name,
href,
icon,
fallbackIcon,
covers,
summary,
details,
children
}: {
name: string;
href?: string;
icon: string;
fallbackIcon: string;
covers: StackCover[];
summary?: Snippet;
details?: Snippet;
children: Snippet;
} = $props();
let expanded = $state(false);
const total = $derived(covers.length);
</script>
<section class="collection" class:expanded>
<header class="collection-header">
{#if href}
<a class="collection-name" {href} target="_blank" rel="noopener noreferrer">
<Icon {icon} width="16" />
{name}
</a>
{:else}
<span class="collection-name"><Icon {icon} width="16" />{name}</span>
{/if}
{#if summary}
<span class="collection-summary">{@render summary()}</span>
{/if}
</header>
{@render details?.()}
{#if expanded}
{@render children()}
{:else}
<div class="cover-stack">
{#each covers as cover, index (cover.id)}
<a
class="stacked-cover"
style:z-index={total - index}
style:border-bottom-color="var(--{cover.tone})"
href={cover.href}
target="_blank"
rel="noopener noreferrer"
title={cover.title}
>
{#if cover.image}
<img src={cover.image} alt={cover.title} loading="lazy" />
{:else}
<span class="cover-fallback"><Icon icon={fallbackIcon} width="20" /></span>
{/if}
</a>
{/each}
</div>
{/if}
<button class="toggle" onclick={() => (expanded = !expanded)}>
<Icon icon={expanded ? 'mdi:chevron-up' : 'mdi:chevron-down'} width="16" />
{expanded ? 'Collapse' : `Expand (${total})`}
</button>
</section>
<style>
.collection {
flex: 1 1 auto;
min-width: min(100%, 280px);
max-width: 100%;
padding: 0.75rem;
border: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
}
.collection.expanded {
flex-basis: 100%;
}
.collection-header {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 0.4rem 1rem;
margin-bottom: 0.5rem;
}
.collection-name {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: var(--accent);
font-weight: 700;
}
a.collection-name:hover {
text-decoration: underline;
}
.collection-summary {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
font-size: 0.78rem;
}
.cover-stack {
display: flex;
padding: 0.5rem 0 0.25rem;
overflow-x: auto;
}
.stacked-cover {
position: relative;
flex-shrink: 0;
width: 84px;
height: 126px;
border-bottom: 3px solid var(--muted);
box-shadow: 4px 0 10px color-mix(in srgb, black 45%, transparent);
transition: transform 0.15s ease;
}
.stacked-cover + .stacked-cover {
margin-left: -52px;
}
.stacked-cover:hover {
z-index: 1000 !important;
transform: translateY(-8px);
}
.stacked-cover img,
.cover-fallback {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-fallback {
display: grid;
place-items: center;
background: color-mix(in srgb, var(--foreground) 8%, var(--background));
color: var(--muted);
}
.toggle {
display: inline-flex;
align-items: center;
gap: 0.3rem;
margin-top: 0.75rem;
padding: 0.3rem 0.7rem;
background: none;
border: 1px solid color-mix(in srgb, var(--foreground) 30%, transparent);
color: var(--foreground);
font-size: 0.85rem;
}
.toggle:hover {
border-color: var(--accent);
color: var(--accent);
}
@media (prefers-reduced-motion: reduce) {
.stacked-cover {
transition: none;
}
}
</style>
+1 -1
View File
@@ -33,7 +33,7 @@ export type * from './types';
export const profile: Profile = profileData;
export const experience: Experience[] = experienceData;
export const leadership: Leadership[] = leadershipData;
export const about: AboutContent = aboutData;
export const about: AboutContent = aboutData as AboutContent;
export const desktop: DesktopContent = desktopData;
export const blogSettings: BlogSettings = blogData;
export const playlist: Playlist = musicData;
+6 -1
View File
@@ -133,10 +133,14 @@ export interface Game {
url: string;
}
export type WatchStatus = 'watched' | 'watching';
export interface RatedTitle {
name: string;
rating: number;
rating?: number;
url: string;
status?: WatchStatus;
collection?: string;
}
export type BookShelf = 'read' | 'reading' | 'want-to-read';
@@ -147,6 +151,7 @@ export interface Book {
title: string;
authors: string[];
cover: string | null;
coverSource?: string | null;
description: string | null;
pages: number | null;
published: string | null;
+57
View File
@@ -0,0 +1,57 @@
import type { RatedTitle } from '#lib/content';
import type { MediaDetails } from './types';
export interface MediaCollection {
name: string;
items: RatedTitle[];
}
export interface GroupedMedia {
watching: RatedTitle[];
collections: MediaCollection[];
standalone: RatedTitle[];
}
export function isWatching(item: RatedTitle): boolean {
return item.status === 'watching';
}
export function ratingOf(item: RatedTitle): number {
return item.rating ?? -1;
}
function releaseYear(item: RatedTitle, details: Record<string, MediaDetails>): number {
const year = Number.parseInt(details[item.url]?.year ?? '', 10);
return Number.isNaN(year) ? Number.MAX_SAFE_INTEGER : year;
}
function averageRating(items: RatedTitle[]): number {
const rated = items.filter((item) => item.rating !== undefined);
return rated.length ? rated.reduce((total, item) => total + ratingOf(item), 0) / rated.length : -1;
}
export function groupMedia(items: RatedTitle[], details: Record<string, MediaDetails>): GroupedMedia {
const groups = new Map<string, RatedTitle[]>();
const standalone: RatedTitle[] = [];
for (const item of items) {
if (item.collection) {
groups.set(item.collection, [...(groups.get(item.collection) ?? []), item]);
} else if (!isWatching(item)) {
standalone.push(item);
}
}
const collections = [...groups.entries()]
.map(([name, groupItems]) => ({
name,
items: [...groupItems].sort((first, second) => releaseYear(first, details) - releaseYear(second, details))
}))
.sort((first, second) => averageRating(second.items) - averageRating(first.items) || first.name.localeCompare(second.name));
return {
watching: items.filter(isWatching),
collections,
standalone: [...standalone].sort((first, second) => ratingOf(second) - ratingOf(first))
};
}
+4 -7
View File
@@ -6,7 +6,7 @@
import Section from '#lib/components/ui/Section.svelte';
import Panel from '#lib/components/ui/Panel.svelte';
import AniListSection from '#lib/components/about/AniListSection.svelte';
import MediaGrid from '#lib/components/about/MediaGrid.svelte';
import MediaSection from '#lib/components/about/media/MediaSection.svelte';
import GameCard from '#lib/components/about/GameCard.svelte';
import BooksSection from '#lib/components/about/books/BooksSection.svelte';
import SetupSection from '#lib/components/about/setup/SetupSection.svelte';
@@ -15,12 +15,9 @@
let { data }: PageProps = $props();
const byRating = (first: { rating: number }, second: { rating: number }) => second.rating - first.rating;
const shows = [...about.shows].sort(byRating);
const movies = [...about.movies].sort(byRating);
const mediaGroups = [
{ id: 'shows', title: 'TV Shows', items: shows, kind: 'show' as const },
{ id: 'movies', title: 'Movies', items: movies, kind: 'movie' as const }
{ id: 'shows', title: 'TV Shows', items: about.shows, kind: 'show' as const },
{ id: 'movies', title: 'Movies', items: about.movies, kind: 'movie' as const }
];
const sections = $derived([
@@ -68,7 +65,7 @@
{#each mediaGroups as group (group.id)}
<Section title={group.title} id={group.id}>
<MediaGrid items={group.items} mediaDetails={data.mediaDetails} kind={group.kind} />
<MediaSection items={group.items} mediaDetails={data.mediaDetails} kind={group.kind} />
</Section>
{/each}