234 lines
6.0 KiB
Svelte
234 lines
6.0 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import Icon from '@iconify/svelte';
|
|
import type { LibraryAppName } from '#lib/content';
|
|
import { libraryApps, statusOf } from '#lib/library/apps';
|
|
import { averageRating, filterEntries, finishedThisYear, groupEntries, type LibraryFilter } from '#lib/library/filter';
|
|
import { libraryStore } from '#lib/library/store.svelte';
|
|
import LibraryToolbar from './LibraryToolbar.svelte';
|
|
import LibraryCard from './LibraryCard.svelte';
|
|
import LibraryDetail from './LibraryDetail.svelte';
|
|
import LibraryHistory from './LibraryHistory.svelte';
|
|
import LibraryGroup from './LibraryGroup.svelte';
|
|
|
|
type LibraryTab = 'shelf' | 'history';
|
|
|
|
let { name }: { name: LibraryAppName } = $props();
|
|
|
|
const tabs: { id: LibraryTab; label: string; icon: string }[] = [
|
|
{ id: 'shelf', label: 'shelf', icon: 'mdi:view-grid-outline' },
|
|
{ id: 'history', label: 'history', icon: 'mdi:timeline-clock-outline' }
|
|
];
|
|
|
|
let tab = $state<LibraryTab>('shelf');
|
|
let filter = $state<LibraryFilter>({ status: null, query: '', sort: 'recent' });
|
|
let selectedId = $state<string | null>(null);
|
|
|
|
const app = $derived(libraryApps[name]);
|
|
const finishedLabel = $derived(app.statuses[1].label);
|
|
const entries = $derived(app.entries(libraryStore.data));
|
|
const shownEntries = $derived(filterEntries(entries, filter));
|
|
const grouped = $derived(groupEntries(shownEntries));
|
|
const selected = $derived(entries.find((entry) => entry.id === selectedId) ?? null);
|
|
const counts = $derived(
|
|
Object.fromEntries(app.statuses.map((status) => [status.id, entries.filter((entry) => entry.status === status.id).length]))
|
|
);
|
|
|
|
function toggleSelected(id: string) {
|
|
selectedId = id === selectedId ? null : id;
|
|
}
|
|
|
|
onMount(() => {
|
|
libraryStore.load();
|
|
});
|
|
</script>
|
|
|
|
<div class="library-app">
|
|
<nav class="tabs" aria-label="{app.title} sections">
|
|
{#each tabs as item (item.id)}
|
|
<button class:active={tab === item.id} onclick={() => (tab = item.id)}>
|
|
<Icon icon={item.icon} width="16" />{item.label}
|
|
</button>
|
|
{/each}
|
|
<span class="summary muted">
|
|
{entries.length} {app.itemLabel} · avg {averageRating(entries)} · {finishedThisYear(entries)} {finishedLabel} this year
|
|
</span>
|
|
<button class="reload" title="Reload" aria-label="Reload" onclick={() => libraryStore.reload()}>
|
|
<Icon icon="mdi:refresh" width="16" />
|
|
</button>
|
|
</nav>
|
|
|
|
<div class="body" class:has-detail={!!selected}>
|
|
<div class="content">
|
|
{#if libraryStore.state === 'error'}
|
|
<p class="message">Could not load the library. Try reloading.</p>
|
|
{:else if libraryStore.state !== 'ready' && !entries.length}
|
|
<p class="message muted">Loading {app.itemLabel}...</p>
|
|
{:else if tab === 'history'}
|
|
<LibraryHistory {entries} {finishedLabel} onselect={(entry) => (selectedId = entry.id)} />
|
|
{:else}
|
|
<LibraryToolbar bind:filter statuses={app.statuses} {counts} total={entries.length} />
|
|
{#if grouped.groups.length}
|
|
<h3 class="subheading">
|
|
<Icon icon="mdi:layers-outline" width="16" />{app.groupLabel}
|
|
<span class="muted">{grouped.groups.length}</span>
|
|
</h3>
|
|
<div class="groups">
|
|
{#each grouped.groups as group (group.name)}
|
|
<LibraryGroup {group} {app} {selectedId} onselect={toggleSelected} />
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{#if grouped.standalone.length}
|
|
{#if grouped.groups.length}
|
|
<h3 class="subheading">
|
|
<Icon icon="mdi:view-grid-outline" width="16" />All {app.itemLabel}
|
|
<span class="muted">{grouped.standalone.length}</span>
|
|
</h3>
|
|
{/if}
|
|
<div class="grid">
|
|
{#each grouped.standalone as entry (entry.id)}
|
|
<LibraryCard
|
|
{entry}
|
|
status={statusOf(app, entry.status)}
|
|
fallbackIcon={app.icon}
|
|
selected={entry.id === selectedId}
|
|
onselect={() => toggleSelected(entry.id)}
|
|
/>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{#if !shownEntries.length}
|
|
<p class="message muted">No {app.itemLabel} match.</p>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
{#if selected}
|
|
<LibraryDetail entry={selected} status={statusOf(app, selected.status)} fallbackIcon={app.icon} onclose={() => (selectedId = null)} />
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.library-app {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
min-height: 0;
|
|
container-type: inline-size;
|
|
}
|
|
|
|
.tabs {
|
|
display: flex;
|
|
align-items: stretch;
|
|
gap: 0.25rem;
|
|
padding: 0 0.75rem;
|
|
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
|
}
|
|
|
|
.tabs button {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
padding: 0.5rem 0.8rem;
|
|
background: none;
|
|
border: none;
|
|
border-bottom: 2px solid transparent;
|
|
color: var(--muted);
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.tabs button:hover {
|
|
color: var(--foreground);
|
|
}
|
|
|
|
.tabs button.active {
|
|
border-bottom-color: var(--accent);
|
|
color: var(--accent);
|
|
}
|
|
|
|
.summary {
|
|
align-self: center;
|
|
margin-left: auto;
|
|
overflow: hidden;
|
|
font-size: 0.75rem;
|
|
white-space: nowrap;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.tabs .reload {
|
|
padding: 0.5rem 0.3rem;
|
|
}
|
|
|
|
.body {
|
|
position: relative;
|
|
display: grid;
|
|
flex: 1;
|
|
grid-template-columns: minmax(0, 1fr);
|
|
min-height: 0;
|
|
}
|
|
|
|
.body.has-detail {
|
|
grid-template-columns: minmax(0, 1fr) 320px;
|
|
}
|
|
|
|
.content {
|
|
min-height: 0;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
|
gap: 0.9rem 0.75rem;
|
|
padding: 0 0.75rem 0.75rem;
|
|
}
|
|
|
|
.subheading {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
margin: 0.25rem 0.75rem 0.6rem;
|
|
color: var(--foreground);
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.groups {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: flex-start;
|
|
gap: 0.75rem;
|
|
padding: 0 0.75rem 1rem;
|
|
}
|
|
|
|
.message {
|
|
margin: 0;
|
|
padding: 1rem 0.75rem;
|
|
}
|
|
|
|
@container (max-width: 640px) {
|
|
.summary {
|
|
display: none;
|
|
}
|
|
|
|
.tabs .reload {
|
|
margin-left: auto;
|
|
}
|
|
|
|
.body.has-detail {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
|
|
.body.has-detail > :global(.detail) {
|
|
position: absolute;
|
|
inset: 0;
|
|
border-left: none;
|
|
}
|
|
|
|
.grid {
|
|
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
|
|
}
|
|
}
|
|
</style>
|