diff --git a/README.md b/README.md index 230734f..d2026b4 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ bun run content | `f` | Toggle featured on a project | | `p` | Preview a theme in the editor | | `t` | Switch the editor to the next theme | -| `s` | Move a book to another shelf, or make a theme the site default | +| `s` | Move a book to another shelf, set a show or movie to watched, watching or planning, or make a theme the site default | | `d` | Delete | | `K` / `J` | Move a song or entry up or down | | `u` / `U` | Refresh the selected book, or every book, from Goodreads | diff --git a/src/lib/btop/hobbies.ts b/src/lib/btop/hobbies.ts index 03f176b..7850601 100644 --- a/src/lib/btop/hobbies.ts +++ b/src/lib/btop/hobbies.ts @@ -61,12 +61,14 @@ export function bookStats(books: Book[]): BookStats | null { export function screenStats(titles: RatedTitle[]): ScreenStats { const ratings = titles.flatMap((title) => (title.rating === undefined ? [] : [title.rating])); const watching = titles.filter((title) => title.status === 'watching').map((title) => title.name); + const planningCount = titles.filter((title) => title.status === 'planning').length; const groups = new Set(titles.flatMap((title) => (title.collection ? [title.collection] : []))); return { rows: [ { label: 'Titles', value: `${titles.length}` }, { label: 'Watching', value: `${watching.length}` }, + { label: 'Planning', value: `${planningCount}` }, { label: 'Groups', value: `${groups.size}` }, { label: 'Avg rating', value: average(ratings) } ], diff --git a/src/lib/components/about/MediaCard.svelte b/src/lib/components/about/MediaCard.svelte index 59d4164..7ac02f5 100644 --- a/src/lib/components/about/MediaCard.svelte +++ b/src/lib/components/about/MediaCard.svelte @@ -22,7 +22,9 @@ {/if} {#if item.status === 'watching'} - watching + watching + {:else if item.status === 'planning'} + planning {/if} @@ -89,7 +91,7 @@ font-size: 0.78rem; } - .watching { + .status { position: absolute; bottom: 0.4rem; left: 0.4rem; @@ -97,12 +99,19 @@ align-items: center; gap: 0.2rem; padding: 0 0.4rem; - background: var(--blue); color: var(--background); font-size: 0.75rem; font-weight: 700; } + .status.watching { + background: var(--blue); + } + + .status.planning { + background: var(--magenta); + } + .text { display: flex; flex-direction: column; diff --git a/src/lib/components/about/media/MediaCollectionGroup.svelte b/src/lib/components/about/media/MediaCollectionGroup.svelte index 5f3245e..cc52e06 100644 --- a/src/lib/components/about/media/MediaCollectionGroup.svelte +++ b/src/lib/components/about/media/MediaCollectionGroup.svelte @@ -1,6 +1,6 @@ @@ -32,6 +33,9 @@ {#if watchingCount} {watchingCount} watching {/if} + {#if planningCount} + {planningCount} planning + {/if} {#if average !== null} avg {average.toFixed(1)}/10 {/if} diff --git a/src/lib/components/about/media/MediaSection.svelte b/src/lib/components/about/media/MediaSection.svelte index cdc9a2f..d46d0e6 100644 --- a/src/lib/components/about/media/MediaSection.svelte +++ b/src/lib/components/about/media/MediaSection.svelte @@ -4,7 +4,6 @@ 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 { @@ -17,15 +16,6 @@ const collectionLabel = $derived(kind === 'show' ? 'Series' : 'Collections'); -{#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}

@@ -36,8 +26,8 @@ {/if} {#if grouped.standalone.length} - {#if grouped.watching.length || grouped.collections.length} -

Watched {grouped.standalone.length}

+ {#if grouped.collections.length} +

All {kind === 'show' ? 'shows' : 'movies'} {grouped.standalone.length}

{/if} {/if} @@ -56,22 +46,10 @@ 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)); - } - } diff --git a/src/lib/content/types.ts b/src/lib/content/types.ts index f0c6f52..61eed6e 100644 --- a/src/lib/content/types.ts +++ b/src/lib/content/types.ts @@ -133,7 +133,7 @@ export interface Game { url: string; } -export type WatchStatus = 'watched' | 'watching'; +export type WatchStatus = 'watched' | 'watching' | 'planning'; export interface RatedTitle { name: string; diff --git a/src/lib/media/collections.ts b/src/lib/media/collections.ts index 090240c..4d5610c 100644 --- a/src/lib/media/collections.ts +++ b/src/lib/media/collections.ts @@ -7,19 +7,35 @@ export interface MediaCollection { } export interface GroupedMedia { - watching: RatedTitle[]; collections: MediaCollection[]; standalone: RatedTitle[]; } +const statusOrder = { watching: 0, watched: 1, planning: 2 }; + export function isWatching(item: RatedTitle): boolean { return item.status === 'watching'; } +export function isPlanning(item: RatedTitle): boolean { + return item.status === 'planning'; +} + +export function statusTone(item: RatedTitle): string { + if (isWatching(item)) return 'blue'; + if (isPlanning(item)) return 'magenta'; + return 'green'; +} + export function ratingOf(item: RatedTitle): number { return item.rating ?? -1; } +function standaloneOrder(first: RatedTitle, second: RatedTitle): number { + const statusDifference = statusOrder[first.status ?? 'watched'] - statusOrder[second.status ?? 'watched']; + return statusDifference || ratingOf(second) - ratingOf(first); +} + 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; @@ -37,7 +53,7 @@ export function groupMedia(items: RatedTitle[], details: Record 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)) + standalone: [...standalone].sort(standaloneOrder) }; } diff --git a/tools/content/media.py b/tools/content/media.py index df5d2cf..f1f260b 100644 --- a/tools/content/media.py +++ b/tools/content/media.py @@ -6,7 +6,8 @@ MEDIA_KINDS = { "show": {"key": "shows", "label": "TV show", "omdb_type": "series"}, "movie": {"key": "movies", "label": "movie", "omdb_type": "movie"}, } -STATUSES = ["watched", "watching"] +STATUSES = ["watched", "watching", "planning"] +STATUS_ORDER = {"watching": 0, "watched": 1, "planning": 2} def check_rating(rating: int | None) -> int | None: @@ -33,7 +34,7 @@ def status_of(item: dict) -> str: def list_items(kind: str) -> list[dict]: _, items = load_items(kind) 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))) + return sorted(items, key=lambda item: (STATUS_ORDER[status_of(item)], -rating(item))) def collection_names(kind: str) -> list[str]: diff --git a/tools/content/tui/media_pane.py b/tools/content/tui/media_pane.py index 801d834..dc8e8f4 100644 --- a/tools/content/tui/media_pane.py +++ b/tools/content/tui/media_pane.py @@ -8,7 +8,7 @@ import media from tui.content_pane import ContentPane from tui.dialogs import ConfirmDialog, FormDialog, FormField -STATUS_STYLES = {"watching": "blue", "watched": "green"} +STATUS_STYLES = {"watching": "blue", "watched": "green", "planning": "magenta"} def rating_text(item: dict) -> str: @@ -19,7 +19,7 @@ class MediaPane(ContentPane): BINDINGS = [ Binding("a", "add", "Add"), Binding("e", "rate", "Rate"), - Binding("s", "status", "Watching"), + Binding("s", "status", "Status"), Binding("g", "collection", "Group"), Binding("d", "delete", "Delete"), Binding("r", "reload", "Reload"), @@ -113,8 +113,14 @@ class MediaPane(ContentPane): 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) + fields = [FormField("status", f"Status for {item['name']}", kind="select", options=media.STATUSES, value=media.status_of(item))] + + def changed(values: dict | None): + if values: + status = values["status"] + self.run_now(lambda: media.set_status(self.kind, identifier, status), f"{item['name']} is now {status}", identifier) + + self.app.push_screen(FormDialog("Change status", fields, "Save"), changed) def action_collection(self): item = self.selected_item()