Add details view to shelf code block

This commit is contained in:
Evan Fiordeliso 2025-07-07 11:37:20 -04:00
parent f4c2aabf1f
commit 67930eb1fd
6 changed files with 512 additions and 241 deletions

View File

@ -15,7 +15,7 @@ export function registerShelfCodeBlockProcessor(
);
}
export const SHELF_VIEWS = ["table", "bookshelf"] as const;
export const SHELF_VIEWS = ["table", "bookshelf", "details"] as const;
export type ShelfView = (typeof SHELF_VIEWS)[number];
export const ShelfSettingsSchema = z.object({
@ -27,10 +27,13 @@ export const ShelfSettingsSchema = z.object({
titleProperty: z.string(),
subtitleProperty: z.optional(z.string()),
authorsProperty: z.string(),
descriptionProperty: z.optional(z.string()),
seriesTitleProperty: z.optional(z.string()),
seriesNumberProperty: z.optional(z.string()),
});
export type ShelfSettings = z.infer<typeof ShelfSettingsSchema>;
export class ShelfCodeBlockRenderer extends SvelteCodeBlockRenderer<
typeof ShelfCodeBockView
> {

View File

@ -1,50 +1,31 @@
<script lang="ts">
import { STATUS_IN_PROGRESS, STATUS_READ } from "@src/const";
import { STATUS_READ } from "@src/const";
import type BookTrackerPlugin from "@src/main";
import Book from "@ui/components/bookshelf/Book.svelte";
import Bookshelf from "@ui/components/bookshelf/Bookshelf.svelte";
import BookStack from "@ui/components/bookshelf/BookStack.svelte";
import {
createMetadata,
setMetadataContext,
type FileMetadata,
} from "@ui/stores/metadata.svelte";
import {
createSettings,
setSettingsContext,
} from "@ui/stores/settings.svelte";
import { COLOR_NAMES, type ColorName } from "@utils/color";
import { randomElement, randomFloat } from "@utils/rand";
import { onDestroy } from "svelte";
import { ShelfSettingsSchema } from "./ShelfCodeBlock";
import { getLinkpath, parseYaml, TFile } from "obsidian";
import { parseYaml } from "obsidian";
import DateFilter from "@ui/components/DateFilter.svelte";
import Rating from "@ui/components/Rating.svelte";
import { v4 as uuidv4 } from "uuid";
import memoize from "just-memoize";
import BookshelfView from "@ui/components/BookshelfView.svelte";
import TableView from "@ui/components/TableView.svelte";
import DetailsView from "@ui/components/DetailsView.svelte";
import {
createReadingLog,
setReadingLogContext,
} from "@ui/stores/reading-log.svelte";
interface Props {
plugin: BookTrackerPlugin;
source: string;
}
interface BookData {
id: string;
title: string;
subtitle?: string;
authors: string[];
width: number;
color: ColorName;
design: (typeof designs)[number];
orientation: "default" | "tilted" | "front";
file: TFile;
}
interface BookStackData {
id: string;
books: BookData[];
}
const { plugin, source }: Props = $props();
const settings = ShelfSettingsSchema.parse(parseYaml(source));
@ -55,91 +36,7 @@
const metadataStore = createMetadata(plugin, settings.statusFilter, true);
setMetadataContext(metadataStore);
const designs = ["default", "dual-top-bands", "split-bands"] as const;
const randomDesign = () => randomElement(designs);
const randomColor = () => randomElement(COLOR_NAMES);
function randomOrientation() {
const n = randomFloat();
if (n < 0.55) {
return "default";
} else if (n < 0.8) {
return "tilted";
} else {
return "front";
}
}
const randomStackChance = () => randomFloat() > 0.9;
function randomStackSize() {
const n = randomFloat();
if (n < 0.15) {
return 5;
} else if (n < 0.3) {
return 4;
} else if (n < 0.5) {
return 3;
} else if (n < 0.8) {
return 2;
} else {
return 1;
}
}
const getBookData = memoize(
(metadata: FileMetadata): BookData => {
const orientation = randomOrientation();
return {
id: metadata.file.path,
title: metadata.frontmatter[settings.titleProperty],
subtitle: settings.subtitleProperty
? metadata.frontmatter[settings.subtitleProperty]
: undefined,
authors: metadata.frontmatter[settings.authorsProperty],
width: Math.min(
Math.max(
20,
metadata.frontmatter[
settingsStore.settings.pageCountProperty
] / 10,
),
100,
),
color: randomColor(),
design: orientation === "front" ? "default" : randomDesign(),
orientation: randomOrientation(),
file: metadata.file,
};
},
(metadata: FileMetadata) => metadata.file.path,
);
let view = $state(settings.defaultView);
let books: (BookData | BookStackData)[] = $state([]);
$effect(() => {
let newBooks: (BookData | BookStackData)[] = [];
for (let i = 0; i < metadataStore.metadata.length; i++) {
if (randomStackChance()) {
const booksRemaining = metadataStore.metadata.length - i;
const stackSize = Math.min(booksRemaining, randomStackSize());
newBooks.push({
id: uuidv4(),
books: metadataStore.metadata
.slice(i, i + stackSize)
.map(getBookData),
});
i += stackSize - 1;
} else {
newBooks.push(getBookData(metadataStore.metadata[i]));
}
}
books = newBooks;
});
onDestroy(() => metadataStore.destroy());
</script>
@ -148,139 +45,24 @@
class="shelf-code-block"
class:table-view={view === "table"}
class:bookshelf-view={view === "bookshelf"}
class:details-view={view === "details"}
>
<div class="controls">
<select bind:value={view}>
<option value="table">Table</option>
<option value="bookshelf">Bookshelf</option>
<option value="details">Details</option>
</select>
{#if settings.statusFilter === STATUS_READ}
<DateFilter store={metadataStore} />
{/if}
</div>
{#if view === "bookshelf"}
<Bookshelf>
{#each books as book (book.id)}
{#if "books" in book}
<BookStack>
{#each book.books as bookData (bookData.id)}
<Book
title={bookData.title}
subtitle={bookData.subtitle}
authors={bookData.authors}
width={bookData.width}
color={bookData.color}
design={bookData.design}
orientation="flat"
onClick={() =>
plugin.app.workspace
.getLeaf("tab")
.openFile(bookData.file)}
/>
{/each}
</BookStack>
{:else}
<Book
title={book.title}
subtitle={book.subtitle}
authors={book.authors}
width={book.width}
color={book.color}
design={book.design}
orientation={book.orientation}
onClick={() =>
plugin.app.workspace
.getLeaf("tab")
.openFile(book.file)}
/>
{/if}
{/each}
</Bookshelf>
<BookshelfView {plugin} {settings} />
{:else if view === "table"}
<table>
<thead>
<tr>
<th>Cover</th>
<th>Title</th>
<th>Authors</th>
{#if settings.seriesTitleProperty}
<th>Series</th>
{/if}
{#if settings.seriesNumberProperty}
<th>#</th>
{/if}
{#if settings.statusFilter === STATUS_IN_PROGRESS || settings.statusFilter === STATUS_READ}
<th>Start Date</th>
{/if}
{#if settings.statusFilter === STATUS_READ}
<th>End Date</th>
<th>Rating</th>
{/if}
</tr>
</thead>
<tbody>
{#each metadataStore.metadata as book}
<tr>
<td>
<img
src={plugin.app.vault.getResourcePath(
plugin.app.vault.getFileByPath(
book.frontmatter[
settings.coverProperty
],
)!,
)}
alt={book.frontmatter[settings.titleProperty]}
width="50"
/>
</td>
<td>
<a href={getLinkpath(book.file.path)}>
{book.frontmatter[settings.titleProperty]}
</a>
</td>
<td>
{book.frontmatter[settings.authorsProperty].join(
", ",
)}
</td>
{#if settings.seriesTitleProperty}
<td>
{book.frontmatter[settings.seriesTitleProperty]}
</td>
{/if}
{#if settings.seriesNumberProperty}
<td>
{book.frontmatter[
settings.seriesNumberProperty
]}
</td>
{/if}
{#if settings.statusFilter === STATUS_IN_PROGRESS || settings.statusFilter === STATUS_READ}
<td>
{book.frontmatter[
settingsStore.settings.startDateProperty
]}
</td>
{/if}
{#if settings.statusFilter === STATUS_READ}
<td>
{book.frontmatter[
settingsStore.settings.endDateProperty
]}
</td>
<td>
<Rating
rating={book.frontmatter[
settingsStore.settings.ratingProperty
]}
/>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
<TableView {plugin} {settings} />
{:else if view === "details"}
<DetailsView {plugin} {settings} />
{/if}
</div>

View File

@ -0,0 +1,164 @@
<script lang="ts">
import type BookTrackerPlugin from "@src/main";
import Book from "@ui/components/bookshelf/Book.svelte";
import Bookshelf from "@ui/components/bookshelf/Bookshelf.svelte";
import BookStack from "@ui/components/bookshelf/BookStack.svelte";
import {
getMetadataContext,
type FileMetadata,
} from "@ui/stores/metadata.svelte";
import { getSettingsContext } from "@ui/stores/settings.svelte";
import { COLOR_NAMES, type ColorName } from "@utils/color";
import { randomElement, randomFloat } from "@utils/rand";
import { v4 as uuidv4 } from "uuid";
import memoize from "just-memoize";
import type { ShelfSettings } from "@ui/code-blocks/ShelfCodeBlock";
import type { TFile } from "obsidian";
interface Props {
plugin: BookTrackerPlugin;
settings: ShelfSettings;
}
interface BookData {
id: string;
title: string;
subtitle?: string;
authors: string[];
width: number;
color: ColorName;
design: (typeof designs)[number];
orientation: "default" | "tilted" | "front";
file: TFile;
}
interface BookStackData {
id: string;
books: BookData[];
}
const { plugin, settings }: Props = $props();
const settingsStore = getSettingsContext();
const metadataStore = getMetadataContext();
const designs = ["default", "dual-top-bands", "split-bands"] as const;
const randomDesign = () => randomElement(designs);
const randomColor = () => randomElement(COLOR_NAMES);
function randomOrientation() {
const n = randomFloat();
if (n < 0.55) {
return "default";
} else if (n < 0.8) {
return "tilted";
} else {
return "front";
}
}
const randomStackChance = () => randomFloat() > 0.9;
function randomStackSize() {
const n = randomFloat();
if (n < 0.15) {
return 5;
} else if (n < 0.3) {
return 4;
} else if (n < 0.5) {
return 3;
} else if (n < 0.8) {
return 2;
} else {
return 1;
}
}
const getBookData = memoize(
(metadata: FileMetadata): BookData => {
const orientation = randomOrientation();
return {
id: metadata.file.path,
title: metadata.frontmatter[settings.titleProperty],
subtitle: settings.subtitleProperty
? metadata.frontmatter[settings.subtitleProperty]
: undefined,
authors: metadata.frontmatter[settings.authorsProperty],
width: Math.min(
Math.max(
20,
metadata.frontmatter[
settingsStore.settings.pageCountProperty
] / 10,
),
100,
),
color: randomColor(),
design: orientation === "front" ? "default" : randomDesign(),
orientation: randomOrientation(),
file: metadata.file,
};
},
(metadata: FileMetadata) => metadata.file.path,
);
let books: (BookData | BookStackData)[] = $state([]);
$effect(() => {
let newBooks: (BookData | BookStackData)[] = [];
for (let i = 0; i < metadataStore.metadata.length; i++) {
if (randomStackChance()) {
const booksRemaining = metadataStore.metadata.length - i;
const stackSize = Math.min(booksRemaining, randomStackSize());
newBooks.push({
id: uuidv4(),
books: metadataStore.metadata
.slice(i, i + stackSize)
.map(getBookData),
});
i += stackSize - 1;
} else {
newBooks.push(getBookData(metadataStore.metadata[i]));
}
}
books = newBooks;
});
</script>
<Bookshelf>
{#each books as book (book.id)}
{#if "books" in book}
<BookStack>
{#each book.books as bookData (bookData.id)}
<Book
title={bookData.title}
subtitle={bookData.subtitle}
authors={bookData.authors}
width={bookData.width}
color={bookData.color}
design={bookData.design}
orientation="flat"
onClick={() =>
plugin.app.workspace
.getLeaf("tab")
.openFile(bookData.file)}
/>
{/each}
</BookStack>
{:else}
<Book
title={book.title}
subtitle={book.subtitle}
authors={book.authors}
width={book.width}
color={book.color}
design={book.design}
orientation={book.orientation}
onClick={() =>
plugin.app.workspace.getLeaf("tab").openFile(book.file)}
/>
{/if}
{/each}
</Bookshelf>

View File

@ -0,0 +1,199 @@
<script lang="ts">
import { STATUS_IN_PROGRESS, STATUS_READ } from "@src/const";
import type BookTrackerPlugin from "@src/main";
import { getMetadataContext } from "@ui/stores/metadata.svelte";
import { getSettingsContext } from "@ui/stores/settings.svelte";
import { getLinkpath } from "obsidian";
import type { ShelfSettings } from "@ui/code-blocks/ShelfCodeBlock";
import { Dot, Flame, Star, StarHalf } from "lucide-svelte";
import RatingInput from "./RatingInput.svelte";
interface Props {
plugin: BookTrackerPlugin;
settings: ShelfSettings;
}
const { plugin, settings }: Props = $props();
const settingsStore = getSettingsContext();
const metadataStore = getMetadataContext();
</script>
<div class="book-details-list">
{#each metadataStore.metadata as book}
{@const coverPath = book.frontmatter[settings.coverProperty]}
{@const title = book.frontmatter[settings.titleProperty]}
{@const subtitle = settings.subtitleProperty
? book.frontmatter[settings.subtitleProperty]
: undefined}
{@const authors = book.frontmatter[settings.authorsProperty]}
{@const description = settings.descriptionProperty
? book.frontmatter[settings.descriptionProperty]
: undefined}
{@const seriesTitle = settings.seriesTitleProperty
? book.frontmatter[settings.seriesTitleProperty]
: undefined}
{@const seriesNumber = settings.seriesNumberProperty
? book.frontmatter[settings.seriesNumberProperty]
: undefined}
{@const startDate =
book.frontmatter[settingsStore.settings.startDateProperty]}
{@const endDate =
book.frontmatter[settingsStore.settings.endDateProperty]}
{@const rating =
book.frontmatter[settingsStore.settings.ratingProperty] ?? 0}
{@const spice =
book.frontmatter[settingsStore.settings.spiceProperty] ?? 0}
<div class="book-details">
<img
src={plugin.app.vault.getResourcePath(
plugin.app.vault.getFileByPath(coverPath)!,
)}
alt={title}
/>
<div class="book-info">
<a href={getLinkpath(book.file.path)}>
<h2 class="book-title">
{title}
</h2>
</a>
{#if subtitle}
<p class="subtitle">{subtitle}</p>
{/if}
<p class="authors">By: {authors.join(", ")}</p>
{#if seriesTitle}
<p class="series">
<span class="series-title">{seriesTitle}</span>
{#if seriesNumber}
<span class="series-number">#{seriesNumber}</span>
{/if}
</p>
{/if}
{#if description}
<hr />
<p class="description">{@html description}</p>
<hr />
{/if}
<div class="footer">
{#if settings.statusFilter === STATUS_IN_PROGRESS || settings.statusFilter === STATUS_READ}
<p class="start-date">
Started:
<datetime datetime={startDate}>{startDate}</datetime
>
</p>
{/if}
{#if settings.statusFilter === STATUS_IN_PROGRESS}
<Dot color="var(--text-muted)" />
<p class="current-page">
Current Page: {plugin.readingLog.getLastEntryForBook(
book.file.basename,
)?.pagesReadTotal ?? 0}
</p>
{/if}
{#if settings.statusFilter === STATUS_READ}
{@const iconSize = 18}
<Dot color="var(--text-muted)" />
<p class="end-date">
Finished:
<datetime datetime={endDate}>{endDate}</datetime>
</p>
<Dot color="var(--text-muted)" />
<RatingInput value={rating} disabled {iconSize}>
{#snippet inactive()}
<Star
color="var(--background-modifier-border)"
/>
{/snippet}
{#snippet active()}
<Star
color="var(--color-yellow)"
fill="rgba(var(--color-yellow-rgb), 0.2)"
/>
{/snippet}
{#snippet partial()}
<Star
color="var(--background-modifier-border)"
/>
<StarHalf
color="var(--color-yellow)"
fill="rgba(var(--color-yellow-rgb), 0.2)"
/>
{/snippet}
</RatingInput>
<RatingInput value={spice} disabled {iconSize}>
{#snippet inactive()}
<Flame
color="var(--background-modifier-border)"
/>
{/snippet}
{#snippet active()}
<Flame
color="var(--color-red)"
fill="rgba(var(--color-red-rgb), 0.2)"
/>
{/snippet}
</RatingInput>
{/if}
</div>
</div>
</div>
{/each}
</div>
<style lang="scss">
.book-details-list {
display: flex;
flex-direction: column;
gap: var(--size-4-6);
}
.book-details {
display: flex;
align-items: start;
gap: 1rem;
background-color: var(--background-secondary);
border-radius: var(--radius-l);
img {
border-radius: var(--radius-l);
max-width: 30%;
}
.book-info {
display: flex;
flex-direction: column;
gap: var(--size-4-2);
padding: var(--size-4-4);
h2,
p {
margin: 0;
}
hr {
margin: var(--size-4-2) 0;
}
.authors,
.series {
font-size: var(--font-small);
color: var(--text-muted);
}
.description {
max-height: 30rem;
overflow-y: auto;
}
.footer {
font-size: var(--font-smaller);
color: var(--text-muted);
display: flex;
gap: var(--size-2-2);
align-items: center;
flex-wrap: wrap;
}
}
}
</style>

View File

@ -7,6 +7,8 @@
name?: string;
max?: number;
half?: boolean;
iconSize?: number;
disabled?: boolean;
inactive?: Snippet;
active?: Snippet;
partial?: Snippet;
@ -14,9 +16,10 @@
let {
value = $bindable(),
name = "",
max = 5,
half = false,
iconSize = 64,
disabled,
inactive,
active,
partial,
@ -36,7 +39,9 @@
let hovering = $state(false);
let valueHover = $state(0);
let displayVal = $derived(hovering ? valueHover : (value ?? 0));
let displayVal = $derived(
hovering && !disabled ? valueHover : (value ?? 0),
);
let items = $derived.by(() => {
const full = Number.isInteger(displayVal);
return Array.from({ length: max }, (_, i) => i + 1).map((index) => ({
@ -80,7 +85,12 @@
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
<div class="rating-input" {onclick} {onmouseout}>
<div
class="rating-input"
{onclick}
{onmouseout}
style:--icon-size="{iconSize}px"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="ctrl" {onmousemove} bind:this={ctrl}></div>
<div class="cont m-1" bind:clientWidth={w} bind:clientHeight={h}>
@ -132,14 +142,14 @@
:global(svg) {
position: absolute;
width: 100%;
height: 100%;
width: var(--icon-size);
height: var(--icon-size);
}
.rating-item {
position: relative;
width: var(--size-4-16);
height: var(--size-4-16);
width: var(--icon-size);
height: var(--icon-size);
}
}
</style>

View File

@ -0,0 +1,113 @@
<script lang="ts">
import { STATUS_IN_PROGRESS, STATUS_READ } from "@src/const";
import type BookTrackerPlugin from "@src/main";
import { getMetadataContext } from "@ui/stores/metadata.svelte";
import { getSettingsContext } from "@ui/stores/settings.svelte";
import { getLinkpath } from "obsidian";
import Rating from "@ui/components/Rating.svelte";
import type { ShelfSettings } from "@ui/code-blocks/ShelfCodeBlock";
interface Props {
plugin: BookTrackerPlugin;
settings: ShelfSettings;
}
const { plugin, settings }: Props = $props();
const settingsStore = getSettingsContext();
const metadataStore = getMetadataContext();
</script>
<table>
<thead>
<tr>
<th>Cover</th>
<th>Title</th>
<th>Authors</th>
{#if settings.seriesTitleProperty}
<th>Series</th>
{/if}
{#if settings.seriesNumberProperty}
<th>#</th>
{/if}
{#if settings.statusFilter === STATUS_IN_PROGRESS || settings.statusFilter === STATUS_READ}
<th>Start Date</th>
{/if}
{#if settings.statusFilter === STATUS_READ}
<th>End Date</th>
<th>Rating</th>
{/if}
</tr>
</thead>
<tbody>
{#each metadataStore.metadata as book}
{@const coverPath = book.frontmatter[settings.coverProperty]}
{@const title = book.frontmatter[settings.titleProperty]}
{@const authors = book.frontmatter[settings.authorsProperty]}
{@const seriesTitle = settings.seriesTitleProperty
? book.frontmatter[settings.seriesTitleProperty]
: undefined}
{@const seriesNumber = settings.seriesNumberProperty
? book.frontmatter[settings.seriesNumberProperty]
: undefined}
{@const startDate =
book.frontmatter[settingsStore.settings.startDateProperty]}
{@const endDate =
book.frontmatter[settingsStore.settings.endDateProperty]}
{@const rating =
book.frontmatter[settingsStore.settings.ratingProperty] ?? 0}
{@const spice =
book.frontmatter[settingsStore.settings.spiceProperty] ?? 0}
<tr>
<td>
<img
src={plugin.app.vault.getResourcePath(
plugin.app.vault.getFileByPath(coverPath)!,
)}
alt={title}
width="50"
/>
</td>
<td>
<a href={getLinkpath(book.file.path)}>
{title}
</a>
</td>
<td>
{authors.join(", ")}
</td>
{#if settings.seriesTitleProperty}
<td>
{#if seriesTitle}{seriesTitle}{/if}
</td>
{/if}
{#if settings.seriesNumberProperty}
<td>
{#if seriesNumber}{seriesNumber}{/if}
</td>
{/if}
{#if settings.statusFilter === STATUS_IN_PROGRESS || settings.statusFilter === STATUS_READ}
<td>
<datetime datetime={startDate}>{startDate}</datetime>
</td>
{/if}
{#if settings.statusFilter === STATUS_READ}
<td>
<datetime datetime={endDate}>{endDate}</datetime>
</td>
<td>
<Rating {rating} />
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
<style lang="scss">
table {
border-collapse: collapse;
width: 100%;
}
</style>