Add watching status and groups for shows and movies
This commit is contained in:
1 parent
7ef3a29ece
commit
90705bc3e2
11 files changed
+568
-244
No files matched your search
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in new issue
Block a user