Add watching status and groups for shows and movies
This commit is contained in:
1 parent
7ef3a29ece
commit
90705bc3e2
11 files changed
+555
-231
No files matched your search
@@ -17,10 +17,12 @@
|
||||
<span class="placeholder"><Icon icon={placeholderIcon} width="36" /></span>
|
||||
{/if}
|
||||
|
||||
{#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">
|
||||
<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 class="count {shelf.id}">{shelf.count} {shelf.label}</span>
|
||||
<span style:color="var(--{shelfTones[shelf.id]})">{shelf.count} {shelf.label}</span>
|
||||
{/each}
|
||||
</span>
|
||||
</header>
|
||||
{/snippet}
|
||||
|
||||
{#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 {shelf.id}" style:flex-grow={shelf.count}></span>
|
||||
<span class="segment" style:flex-grow={shelf.count} style:background="var(--{shelfTones[shelf.id]})"></span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#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}
|
||||
|
||||
|
||||
+60
-14
@@ -6,22 +6,39 @@ MEDIA_KINDS = {
|
||||
"show": {"key": "shows", "label": "TV show", "omdb_type": "series"},
|
||||
"movie": {"key": "movies", "label": "movie", "omdb_type": "movie"},
|
||||
}
|
||||
STATUSES = ["watched", "watching"]
|
||||
|
||||
|
||||
def check_rating(rating: int) -> int:
|
||||
if not 0 <= rating <= 10:
|
||||
def check_rating(rating: int | None) -> int | None:
|
||||
if rating is not None and not 0 <= rating <= 10:
|
||||
raise ValueError("Rating must be between 0 and 10")
|
||||
return rating
|
||||
|
||||
|
||||
def check_status(status: str) -> str:
|
||||
if status not in STATUSES:
|
||||
raise ValueError(f"Status must be one of {', '.join(STATUSES)}")
|
||||
return status
|
||||
|
||||
|
||||
def load_items(kind: str) -> tuple[dict, list[dict]]:
|
||||
data = jsonfile.load(ABOUT_FILE)
|
||||
return data, data.setdefault(MEDIA_KINDS[kind]["key"], [])
|
||||
|
||||
|
||||
def status_of(item: dict) -> str:
|
||||
return item.get("status", "watched")
|
||||
|
||||
|
||||
def list_items(kind: str) -> list[dict]:
|
||||
_, items = load_items(kind)
|
||||
return sorted(items, key=lambda item: -item.get("rating", 0))
|
||||
rating = lambda item: item["rating"] if item.get("rating") is not None else -1
|
||||
return sorted(items, key=lambda item: (status_of(item) != "watching", -rating(item)))
|
||||
|
||||
|
||||
def collection_names(kind: str) -> list[str]:
|
||||
_, items = load_items(kind)
|
||||
return sorted({item["collection"] for item in items if item.get("collection")})
|
||||
|
||||
|
||||
def find_index(items: list[dict], text: str) -> int | None:
|
||||
@@ -34,27 +51,53 @@ def find_index(items: list[dict], text: str) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def look_up_name(kind: str, identifier: str) -> str:
|
||||
def look_up_name(identifier: str) -> str:
|
||||
details = imdb.lookup(identifier)
|
||||
if not details:
|
||||
raise ValueError("Could not look up the title on OMDb. Enter a name, or set OMDB_API_KEY in .env")
|
||||
return details.get("Title") or identifier
|
||||
|
||||
|
||||
def add_item(kind: str, text: str, rating: int, name: str | None = None) -> dict:
|
||||
def with_optional(item: dict, key: str, value) -> dict:
|
||||
updated = {name: field for name, field in item.items() if name != key}
|
||||
if value is not None and value != "":
|
||||
updated[key] = value
|
||||
return updated
|
||||
|
||||
|
||||
def build_item(name: str, url: str, rating: int | None, status: str, collection: str | None) -> dict:
|
||||
item = {"name": name, "url": url}
|
||||
item = with_optional(item, "rating", rating)
|
||||
if status != "watched":
|
||||
item["status"] = status
|
||||
return with_optional(item, "collection", collection)
|
||||
|
||||
|
||||
def add_item(kind: str, text: str, rating: int | None, name: str | None = None, status: str = "watched", collection: str | None = None) -> dict:
|
||||
identifier = imdb.imdb_id(text)
|
||||
if not identifier:
|
||||
raise ValueError(f"{text} is not an IMDb link or ID")
|
||||
check_rating(rating)
|
||||
check_status(status)
|
||||
data, items = load_items(kind)
|
||||
if find_index(items, identifier) is not None:
|
||||
raise ValueError(f"{identifier} is already listed")
|
||||
item = {"name": name or look_up_name(kind, identifier), "rating": rating, "url": imdb.title_url(identifier)}
|
||||
item = build_item(name or look_up_name(identifier), imdb.title_url(identifier), rating, status, collection)
|
||||
items.append(item)
|
||||
jsonfile.save(ABOUT_FILE, data)
|
||||
return item
|
||||
|
||||
|
||||
def update_item(kind: str, text: str, change) -> dict:
|
||||
data, items = load_items(kind)
|
||||
index = find_index(items, text)
|
||||
if index is None:
|
||||
raise ValueError(f"{text} is not listed")
|
||||
items[index] = change(items[index])
|
||||
jsonfile.save(ABOUT_FILE, data)
|
||||
return items[index]
|
||||
|
||||
|
||||
def remove_item(kind: str, text: str) -> dict:
|
||||
data, items = load_items(kind)
|
||||
index = find_index(items, text)
|
||||
@@ -65,12 +108,15 @@ def remove_item(kind: str, text: str) -> dict:
|
||||
return removed
|
||||
|
||||
|
||||
def rate_item(kind: str, text: str, rating: int) -> dict:
|
||||
def rate_item(kind: str, text: str, rating: int | None) -> dict:
|
||||
check_rating(rating)
|
||||
data, items = load_items(kind)
|
||||
index = find_index(items, text)
|
||||
if index is None:
|
||||
raise ValueError(f"{text} is not listed")
|
||||
items[index]["rating"] = rating
|
||||
jsonfile.save(ABOUT_FILE, data)
|
||||
return items[index]
|
||||
return update_item(kind, text, lambda item: with_optional(item, "rating", rating))
|
||||
|
||||
|
||||
def set_status(kind: str, text: str, status: str) -> dict:
|
||||
check_status(status)
|
||||
return update_item(kind, text, lambda item: with_optional(item, "status", None if status == "watched" else status))
|
||||
|
||||
|
||||
def set_collection(kind: str, text: str, collection: str | None) -> dict:
|
||||
return update_item(kind, text, lambda item: with_optional(item, "collection", collection))
|
||||
@@ -1,3 +1,4 @@
|
||||
from rich.text import Text
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.widgets import DataTable
|
||||
@@ -7,11 +8,19 @@ import media
|
||||
from tui.content_pane import ContentPane
|
||||
from tui.dialogs import ConfirmDialog, FormDialog, FormField
|
||||
|
||||
STATUS_STYLES = {"watching": "blue", "watched": "green"}
|
||||
|
||||
|
||||
def rating_text(item: dict) -> str:
|
||||
return f"{item['rating']}/10" if item.get("rating") is not None else "-"
|
||||
|
||||
|
||||
class MediaPane(ContentPane):
|
||||
BINDINGS = [
|
||||
Binding("a", "add", "Add"),
|
||||
Binding("e", "rate", "Rate"),
|
||||
Binding("s", "status", "Watching"),
|
||||
Binding("g", "collection", "Group"),
|
||||
Binding("d", "delete", "Delete"),
|
||||
Binding("r", "reload", "Reload"),
|
||||
]
|
||||
@@ -29,8 +38,10 @@ class MediaPane(ContentPane):
|
||||
|
||||
def on_mount(self):
|
||||
table = self.table()
|
||||
table.add_column("Status", key="status")
|
||||
table.add_column("Rating", key="rating")
|
||||
table.add_column("Title", key="title")
|
||||
table.add_column("Group", key="collection")
|
||||
table.add_column("IMDb", key="imdb")
|
||||
self.reload()
|
||||
|
||||
@@ -39,13 +50,31 @@ class MediaPane(ContentPane):
|
||||
table.clear()
|
||||
for item in media.list_items(self.kind):
|
||||
identifier = imdb.imdb_id(item.get("url", "")) or item.get("name", "")
|
||||
table.add_row(f"{item.get('rating', '?')}/10", item.get("name", ""), identifier, key=identifier)
|
||||
status = media.status_of(item)
|
||||
table.add_row(
|
||||
Text(status, style=STATUS_STYLES[status]),
|
||||
rating_text(item),
|
||||
item.get("name", ""),
|
||||
item.get("collection", ""),
|
||||
identifier,
|
||||
key=identifier,
|
||||
)
|
||||
self.restore_cursor(focus_key)
|
||||
|
||||
def selected_item(self) -> dict | None:
|
||||
identifier = self.selected_key()
|
||||
return next((item for item in media.list_items(self.kind) if imdb.imdb_id(item.get("url", "")) == identifier), None)
|
||||
|
||||
def collection_hint(self) -> str:
|
||||
names = media.collection_names(self.kind)
|
||||
return f"Existing: {', '.join(names)}" if names else "e.g. Star Wars"
|
||||
|
||||
def action_add(self):
|
||||
fields = [
|
||||
FormField("link", "IMDb link or ID", placeholder="https://www.imdb.com/title/tt0816692/", required=True),
|
||||
FormField("rating", "Your rating (0 to 10)", kind="rating", required=True),
|
||||
FormField("status", "Status", kind="select", options=media.STATUSES, value="watched"),
|
||||
FormField("rating", "Your rating (0 to 10, optional)", kind="rating"),
|
||||
FormField("collection", "Group (optional, same name groups titles together)", placeholder=self.collection_hint()),
|
||||
FormField("name", "Name (looked up on OMDb when empty)"),
|
||||
]
|
||||
self.app.push_screen(FormDialog(f"Add a {self.label}", fields, "Add"), self.add_item)
|
||||
@@ -57,31 +86,51 @@ class MediaPane(ContentPane):
|
||||
|
||||
def added(item: dict):
|
||||
self.reload(identifier)
|
||||
self.notify(f"Added {item['name']} ({item['rating']}/10)")
|
||||
self.notify(f"Added {item['name']} ({media.status_of(item)})")
|
||||
|
||||
self.run_in_background(
|
||||
f"Adding {identifier or values['link']}...",
|
||||
lambda: media.add_item(self.kind, values["link"], values["rating"], values["name"]),
|
||||
lambda: media.add_item(self.kind, values["link"], values["rating"], values["name"], values["status"], values["collection"]),
|
||||
added,
|
||||
)
|
||||
|
||||
def action_rate(self):
|
||||
identifier = self.selected_key()
|
||||
if not identifier:
|
||||
item = self.selected_item()
|
||||
if not item:
|
||||
return
|
||||
current = next((item for item in media.list_items(self.kind) if imdb.imdb_id(item.get("url", "")) == identifier), {})
|
||||
fields = [FormField("rating", f"Rating for {current.get('name', identifier)}", kind="rating", required=True, value=str(current.get("rating", "")))]
|
||||
identifier = imdb.imdb_id(item["url"])
|
||||
current = "" if item.get("rating") is None else str(item["rating"])
|
||||
fields = [FormField("rating", f"Rating for {item['name']} (empty to clear)", kind="rating", value=current)]
|
||||
|
||||
def rated(values: dict | None):
|
||||
if values:
|
||||
self.run_now(
|
||||
lambda: media.rate_item(self.kind, identifier, values["rating"]),
|
||||
lambda item: f"Rated {item['name']} {item['rating']}/10",
|
||||
identifier,
|
||||
)
|
||||
if values is not None:
|
||||
self.run_now(lambda: media.rate_item(self.kind, identifier, values["rating"]), f"Updated {item['name']}", identifier)
|
||||
|
||||
self.app.push_screen(FormDialog("Change rating", fields), rated)
|
||||
|
||||
def action_status(self):
|
||||
item = self.selected_item()
|
||||
if not item:
|
||||
return
|
||||
identifier = imdb.imdb_id(item["url"])
|
||||
next_status = "watched" if media.status_of(item) == "watching" else "watching"
|
||||
self.run_now(lambda: media.set_status(self.kind, identifier, next_status), f"{item['name']} is now {next_status}", identifier)
|
||||
|
||||
def action_collection(self):
|
||||
item = self.selected_item()
|
||||
if not item:
|
||||
return
|
||||
identifier = imdb.imdb_id(item["url"])
|
||||
fields = [FormField("collection", f"Group for {item['name']} (empty to remove)", value=item.get("collection", ""), placeholder=self.collection_hint())]
|
||||
|
||||
def grouped(values: dict | None):
|
||||
if values is not None:
|
||||
collection = values["collection"]
|
||||
message = f"Added {item['name']} to {collection}" if collection else f"Removed {item['name']} from its group"
|
||||
self.run_now(lambda: media.set_collection(self.kind, identifier, collection), message, identifier)
|
||||
|
||||
self.app.push_screen(FormDialog("Group titles", fields), grouped)
|
||||
|
||||
def action_delete(self):
|
||||
identifier = self.selected_key()
|
||||
if not identifier:
|
||||
|
||||
Reference in new issue
Block a user