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}
+60 -14
View File
@@ -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))
+63 -14
View File
@@ -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: