Add planning to shows and movies in one merged grid
This commit is contained in:
1 parent
cd9a8a186a
commit
d473aebb79
9 files changed
+56
-41
No files matched your search
@@ -121,7 +121,7 @@ bun run content
|
|||||||
| `f` | Toggle featured on a project |
|
| `f` | Toggle featured on a project |
|
||||||
| `p` | Preview a theme in the editor |
|
| `p` | Preview a theme in the editor |
|
||||||
| `t` | Switch the editor to the next theme |
|
| `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 |
|
| `d` | Delete |
|
||||||
| `K` / `J` | Move a song or entry up or down |
|
| `K` / `J` | Move a song or entry up or down |
|
||||||
| `u` / `U` | Refresh the selected book, or every book, from Goodreads |
|
| `u` / `U` | Refresh the selected book, or every book, from Goodreads |
|
||||||
|
|||||||
@@ -61,12 +61,14 @@ export function bookStats(books: Book[]): BookStats | null {
|
|||||||
export function screenStats(titles: RatedTitle[]): ScreenStats {
|
export function screenStats(titles: RatedTitle[]): ScreenStats {
|
||||||
const ratings = titles.flatMap((title) => (title.rating === undefined ? [] : [title.rating]));
|
const ratings = titles.flatMap((title) => (title.rating === undefined ? [] : [title.rating]));
|
||||||
const watching = titles.filter((title) => title.status === 'watching').map((title) => title.name);
|
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] : [])));
|
const groups = new Set(titles.flatMap((title) => (title.collection ? [title.collection] : [])));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: [
|
rows: [
|
||||||
{ label: 'Titles', value: `${titles.length}` },
|
{ label: 'Titles', value: `${titles.length}` },
|
||||||
{ label: 'Watching', value: `${watching.length}` },
|
{ label: 'Watching', value: `${watching.length}` },
|
||||||
|
{ label: 'Planning', value: `${planningCount}` },
|
||||||
{ label: 'Groups', value: `${groups.size}` },
|
{ label: 'Groups', value: `${groups.size}` },
|
||||||
{ label: 'Avg rating', value: average(ratings) }
|
{ label: 'Avg rating', value: average(ratings) }
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -22,7 +22,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if item.status === 'watching'}
|
{#if item.status === 'watching'}
|
||||||
<span class="watching"><Icon icon="mdi:play" width="13" />watching</span>
|
<span class="status watching"><Icon icon="mdi:play" width="13" />watching</span>
|
||||||
|
{:else if item.status === 'planning'}
|
||||||
|
<span class="status planning"><Icon icon="mdi:bookmark-outline" width="13" />planning</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -89,7 +91,7 @@
|
|||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.watching {
|
.status {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0.4rem;
|
bottom: 0.4rem;
|
||||||
left: 0.4rem;
|
left: 0.4rem;
|
||||||
@@ -97,12 +99,19 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
padding: 0 0.4rem;
|
padding: 0 0.4rem;
|
||||||
background: var(--blue);
|
|
||||||
color: var(--background);
|
color: var(--background);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status.watching {
|
||||||
|
background: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.planning {
|
||||||
|
background: var(--magenta);
|
||||||
|
}
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { MediaCollection } from '#lib/media/collections';
|
import type { MediaCollection } from '#lib/media/collections';
|
||||||
import { isWatching } from '#lib/media/collections';
|
import { isPlanning, isWatching, statusTone } from '#lib/media/collections';
|
||||||
import type { MediaDetails } from '#lib/media/types';
|
import type { MediaDetails } from '#lib/media/types';
|
||||||
import CollectionGroup from '#lib/components/ui/CollectionGroup.svelte';
|
import CollectionGroup from '#lib/components/ui/CollectionGroup.svelte';
|
||||||
import MediaCard from '../MediaCard.svelte';
|
import MediaCard from '../MediaCard.svelte';
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
const icon = $derived(kind === 'show' ? 'mdi:television-classic' : 'mdi:movie-open-outline');
|
const icon = $derived(kind === 'show' ? 'mdi:television-classic' : 'mdi:movie-open-outline');
|
||||||
const watchingCount = $derived(collection.items.filter(isWatching).length);
|
const watchingCount = $derived(collection.items.filter(isWatching).length);
|
||||||
|
const planningCount = $derived(collection.items.filter(isPlanning).length);
|
||||||
const rated = $derived(collection.items.filter((item) => item.rating !== undefined));
|
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 average = $derived(rated.length ? rated.reduce((total, item) => total + (item.rating ?? 0), 0) / rated.length : null);
|
||||||
const covers = $derived(
|
const covers = $derived(
|
||||||
@@ -21,7 +22,7 @@
|
|||||||
href: item.url,
|
href: item.url,
|
||||||
title: item.name,
|
title: item.name,
|
||||||
image: mediaDetails[item.url]?.poster ?? null,
|
image: mediaDetails[item.url]?.poster ?? null,
|
||||||
tone: isWatching(item) ? 'blue' : 'green'
|
tone: statusTone(item)
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
@@ -32,6 +33,9 @@
|
|||||||
{#if watchingCount}
|
{#if watchingCount}
|
||||||
<span style:color="var(--blue)">{watchingCount} watching</span>
|
<span style:color="var(--blue)">{watchingCount} watching</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if planningCount}
|
||||||
|
<span style:color="var(--magenta)">{planningCount} planning</span>
|
||||||
|
{/if}
|
||||||
{#if average !== null}
|
{#if average !== null}
|
||||||
<span style:color="var(--yellow)">avg {average.toFixed(1)}/10</span>
|
<span style:color="var(--yellow)">avg {average.toFixed(1)}/10</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
import type { MediaDetails } from '#lib/media/types';
|
import type { MediaDetails } from '#lib/media/types';
|
||||||
import { groupMedia } from '#lib/media/collections';
|
import { groupMedia } from '#lib/media/collections';
|
||||||
import MediaGrid from '../MediaGrid.svelte';
|
import MediaGrid from '../MediaGrid.svelte';
|
||||||
import MediaCard from '../MediaCard.svelte';
|
|
||||||
import MediaCollectionGroup from './MediaCollectionGroup.svelte';
|
import MediaCollectionGroup from './MediaCollectionGroup.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -17,15 +16,6 @@
|
|||||||
const collectionLabel = $derived(kind === 'show' ? 'Series' : 'Collections');
|
const collectionLabel = $derived(kind === 'show' ? 'Series' : 'Collections');
|
||||||
</script>
|
</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}
|
{#if grouped.collections.length}
|
||||||
<h3 class="subheading"><Icon icon="mdi:layers-outline" width="16" /> {collectionLabel} <span class="muted">{grouped.collections.length}</span></h3>
|
<h3 class="subheading"><Icon icon="mdi:layers-outline" width="16" /> {collectionLabel} <span class="muted">{grouped.collections.length}</span></h3>
|
||||||
<div class="collection-list">
|
<div class="collection-list">
|
||||||
@@ -36,8 +26,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if grouped.standalone.length}
|
{#if grouped.standalone.length}
|
||||||
{#if grouped.watching.length || grouped.collections.length}
|
{#if grouped.collections.length}
|
||||||
<h3 class="subheading"><Icon icon="mdi:check-circle-outline" width="16" /> Watched <span class="muted">{grouped.standalone.length}</span></h3>
|
<h3 class="subheading"><Icon icon="mdi:view-grid-outline" width="16" /> All {kind === 'show' ? 'shows' : 'movies'} <span class="muted">{grouped.standalone.length}</span></h3>
|
||||||
{/if}
|
{/if}
|
||||||
<MediaGrid items={grouped.standalone} {mediaDetails} {kind} />
|
<MediaGrid items={grouped.standalone} {mediaDetails} {kind} />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -56,22 +46,10 @@
|
|||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.watching-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
|
||||||
gap: 1rem 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collection-list {
|
.collection-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@container (max-width: 520px) {
|
|
||||||
.watching-grid {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -133,7 +133,7 @@ export interface Game {
|
|||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WatchStatus = 'watched' | 'watching';
|
export type WatchStatus = 'watched' | 'watching' | 'planning';
|
||||||
|
|
||||||
export interface RatedTitle {
|
export interface RatedTitle {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -7,19 +7,35 @@ export interface MediaCollection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupedMedia {
|
export interface GroupedMedia {
|
||||||
watching: RatedTitle[];
|
|
||||||
collections: MediaCollection[];
|
collections: MediaCollection[];
|
||||||
standalone: RatedTitle[];
|
standalone: RatedTitle[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const statusOrder = { watching: 0, watched: 1, planning: 2 };
|
||||||
|
|
||||||
export function isWatching(item: RatedTitle): boolean {
|
export function isWatching(item: RatedTitle): boolean {
|
||||||
return item.status === 'watching';
|
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 {
|
export function ratingOf(item: RatedTitle): number {
|
||||||
return item.rating ?? -1;
|
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<string, MediaDetails>): number {
|
function releaseYear(item: RatedTitle, details: Record<string, MediaDetails>): number {
|
||||||
const year = Number.parseInt(details[item.url]?.year ?? '', 10);
|
const year = Number.parseInt(details[item.url]?.year ?? '', 10);
|
||||||
return Number.isNaN(year) ? Number.MAX_SAFE_INTEGER : year;
|
return Number.isNaN(year) ? Number.MAX_SAFE_INTEGER : year;
|
||||||
@@ -37,7 +53,7 @@ export function groupMedia(items: RatedTitle[], details: Record<string, MediaDet
|
|||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.collection) {
|
if (item.collection) {
|
||||||
groups.set(item.collection, [...(groups.get(item.collection) ?? []), item]);
|
groups.set(item.collection, [...(groups.get(item.collection) ?? []), item]);
|
||||||
} else if (!isWatching(item)) {
|
} else {
|
||||||
standalone.push(item);
|
standalone.push(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,8 +66,7 @@ export function groupMedia(items: RatedTitle[], details: Record<string, MediaDet
|
|||||||
.sort((first, second) => averageRating(second.items) - averageRating(first.items) || first.name.localeCompare(second.name));
|
.sort((first, second) => averageRating(second.items) - averageRating(first.items) || first.name.localeCompare(second.name));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
watching: items.filter(isWatching),
|
|
||||||
collections,
|
collections,
|
||||||
standalone: [...standalone].sort((first, second) => ratingOf(second) - ratingOf(first))
|
standalone: [...standalone].sort(standaloneOrder)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,8 @@ MEDIA_KINDS = {
|
|||||||
"show": {"key": "shows", "label": "TV show", "omdb_type": "series"},
|
"show": {"key": "shows", "label": "TV show", "omdb_type": "series"},
|
||||||
"movie": {"key": "movies", "label": "movie", "omdb_type": "movie"},
|
"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:
|
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]:
|
def list_items(kind: str) -> list[dict]:
|
||||||
_, items = load_items(kind)
|
_, items = load_items(kind)
|
||||||
rating = lambda item: item["rating"] if item.get("rating") is not None else -1
|
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]:
|
def collection_names(kind: str) -> list[str]:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import media
|
|||||||
from tui.content_pane import ContentPane
|
from tui.content_pane import ContentPane
|
||||||
from tui.dialogs import ConfirmDialog, FormDialog, FormField
|
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:
|
def rating_text(item: dict) -> str:
|
||||||
@@ -19,7 +19,7 @@ class MediaPane(ContentPane):
|
|||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
Binding("a", "add", "Add"),
|
Binding("a", "add", "Add"),
|
||||||
Binding("e", "rate", "Rate"),
|
Binding("e", "rate", "Rate"),
|
||||||
Binding("s", "status", "Watching"),
|
Binding("s", "status", "Status"),
|
||||||
Binding("g", "collection", "Group"),
|
Binding("g", "collection", "Group"),
|
||||||
Binding("d", "delete", "Delete"),
|
Binding("d", "delete", "Delete"),
|
||||||
Binding("r", "reload", "Reload"),
|
Binding("r", "reload", "Reload"),
|
||||||
@@ -113,8 +113,14 @@ class MediaPane(ContentPane):
|
|||||||
if not item:
|
if not item:
|
||||||
return
|
return
|
||||||
identifier = imdb.imdb_id(item["url"])
|
identifier = imdb.imdb_id(item["url"])
|
||||||
next_status = "watched" if media.status_of(item) == "watching" else "watching"
|
fields = [FormField("status", f"Status for {item['name']}", kind="select", options=media.STATUSES, value=media.status_of(item))]
|
||||||
self.run_now(lambda: media.set_status(self.kind, identifier, next_status), f"{item['name']} is now {next_status}", identifier)
|
|
||||||
|
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):
|
def action_collection(self):
|
||||||
item = self.selected_item()
|
item = self.selected_item()
|
||||||
|
|||||||
Reference in new issue
Block a user