- {#each shelves as shelf (shelf.id)}
- {#if shelf.count}
-
- {/if}
+ {#snippet details()}
+
+ {#each shelves as shelf (shelf.id)}
+ {#if shelf.count}
+
+ {/if}
+ {/each}
+
+ {/snippet}
+
+
+ {#each series.books as book (book.goodreadsId)}
+
{/each}
-
- {#if expanded}
-
- {#each series.books as book (book.goodreadsId)}
-
- {/each}
-
- {:else}
-
- {/if}
-
-
-
+
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 @@
+
+
+
+
+
+
+
+ {@render details?.()}
+
+ {#if expanded}
+ {@render children()}
+ {:else}
+
+ {/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: