diff --git a/src/lib/components/about/MediaCard.svelte b/src/lib/components/about/MediaCard.svelte index 8fbad02..59d4164 100644 --- a/src/lib/components/about/MediaCard.svelte +++ b/src/lib/components/about/MediaCard.svelte @@ -17,10 +17,12 @@ {/if} - {item.rating}/10 + {#if item.rating !== undefined} + {item.rating}/10 + {/if} - {#if details?.plot} -

{details.plot}

+ {#if item.status === 'watching'} + watching {/if} @@ -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; } } diff --git a/src/lib/components/about/books/BookSeriesGroup.svelte b/src/lib/components/about/books/BookSeriesGroup.svelte index 5cbcf65..18ef095 100644 --- a/src/lib/components/about/books/BookSeriesGroup.svelte +++ b/src/lib/components/about/books/BookSeriesGroup.svelte @@ -1,222 +1,70 @@ -
-
- - - {series.name} - - - {#each shelves.filter((shelf) => shelf.count > 0) as shelf (shelf.id)} - {shelf.count} {shelf.label} - {/each} - -
+ + {#snippet summary()} + {#each shelves.filter((shelf) => shelf.count > 0) as shelf (shelf.id)} + {shelf.count} {shelf.label} + {/each} + {/snippet} -
+ diff --git a/src/lib/components/about/media/MediaSection.svelte b/src/lib/components/about/media/MediaSection.svelte new file mode 100644 index 0000000..cdc9a2f --- /dev/null +++ b/src/lib/components/about/media/MediaSection.svelte @@ -0,0 +1,77 @@ + + +{#if grouped.watching.length} +

Currently watching {grouped.watching.length}

+
+ {#each grouped.watching as item (item.url)} + + {/each} +
+{/if} + +{#if grouped.collections.length} +

{collectionLabel} {grouped.collections.length}

+
+ {#each grouped.collections as collection (collection.name)} + + {/each} +
+{/if} + +{#if grouped.standalone.length} + {#if grouped.watching.length || grouped.collections.length} +

Watched {grouped.standalone.length}

+ {/if} + +{/if} + + diff --git a/src/lib/components/ui/CollectionGroup.svelte b/src/lib/components/ui/CollectionGroup.svelte new file mode 100644 index 0000000..344c87d --- /dev/null +++ b/src/lib/components/ui/CollectionGroup.svelte @@ -0,0 +1,190 @@ + + + + +
+
+ {#if href} + + + {name} + + {:else} + {name} + {/if} + {#if summary} + {@render summary()} + {/if} +
+ + {@render details?.()} + + {#if expanded} + {@render children()} + {:else} +
+ {#each covers as cover, index (cover.id)} + + {#if cover.image} + {cover.title} + {:else} + + {/if} + + {/each} +
+ {/if} + + +
+ + diff --git a/src/lib/content/index.ts b/src/lib/content/index.ts index cf452ac..ef505b6 100644 --- a/src/lib/content/index.ts +++ b/src/lib/content/index.ts @@ -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; diff --git a/src/lib/content/types.ts b/src/lib/content/types.ts index e5e1009..d14188d 100644 --- a/src/lib/content/types.ts +++ b/src/lib/content/types.ts @@ -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; diff --git a/src/lib/media/collections.ts b/src/lib/media/collections.ts new file mode 100644 index 0000000..090240c --- /dev/null +++ b/src/lib/media/collections.ts @@ -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): 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): GroupedMedia { + const groups = new Map(); + 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)) + }; +} diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte index 5b94466..2d2870e 100644 --- a/src/routes/about/+page.svelte +++ b/src/routes/about/+page.svelte @@ -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)}
- +
{/each} diff --git a/tools/content/media.py b/tools/content/media.py index cf58c4e..df5d2cf 100644 --- a/tools/content/media.py +++ b/tools/content/media.py @@ -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)) diff --git a/tools/content/tui/media_pane.py b/tools/content/tui/media_pane.py index 53eaef8..801d834 100644 --- a/tools/content/tui/media_pane.py +++ b/tools/content/tui/media_pane.py @@ -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: