Rename bookshelf code block to shelf and add table view

This commit is contained in:
Evan Fiordeliso 2025-07-05 15:06:39 -04:00
parent 94fe4d5f1c
commit db732fd8a6
8 changed files with 583 additions and 215 deletions

View File

@ -22,7 +22,7 @@ import { BackupReadingLogCommand } from "@commands/CreateReadingLogBackupCommand
import { RestoreReadingLogBackupCommand } from "@commands/RestoreReadingLogBackupCommand";
import { Goodreads } from "@data-sources/Goodreads";
import { CreateBookFromGoodreadsUrlCommand } from "@commands/CreateBookFromGoodreadsUrlCommand";
import { registerBookshelfCodeBlockProcessor } from "@ui/code-blocks/BookshelfCodeBlock";
import { registerShelfCodeBlockProcessor } from "@ui/code-blocks/ShelfCodeBlock";
export default class BookTrackerPlugin extends Plugin {
public settings: BookTrackerPluginSettings;
@ -86,7 +86,7 @@ export default class BookTrackerPlugin extends Plugin {
registerReadingLogCodeBlockProcessor(this);
registerReadingStatsCodeBlockProcessor(this);
registerBookshelfCodeBlockProcessor(this);
registerShelfCodeBlockProcessor(this);
}
onunload() {}

View File

@ -1,35 +0,0 @@
import { registerCodeBlockRenderer } from ".";
import { SvelteCodeBlockRenderer } from "./SvelteCodeBlockRenderer";
import BookshelfCodeBlockView from "./BookshelfCodeBlockView.svelte";
import type BookTrackerPlugin from "@src/main";
import z from "zod/v4";
export function registerBookshelfCodeBlockProcessor(
plugin: BookTrackerPlugin
): void {
registerCodeBlockRenderer(
plugin,
"bookshelf",
(source, el) => new BookshelfCodeBlockRenderer(plugin, source, el)
);
}
export const BookshelfSettingsSchema = z.object({
titleProperty: z.string(),
subtitleProperty: z.optional(z.string()),
authorsProperty: z.string(),
});
export class BookshelfCodeBlockRenderer extends SvelteCodeBlockRenderer<
typeof BookshelfCodeBlockView
> {
constructor(
plugin: BookTrackerPlugin,
source: string,
contentEl: HTMLElement
) {
super(contentEl, BookshelfCodeBlockView, { props: { plugin, source } });
}
onunload() {}
}

View File

@ -1,166 +0,0 @@
<script lang="ts">
import { STATUS_TO_BE_READ } from "@src/const";
import type BookTrackerPlugin from "@src/main";
import type { ReadingState } from "@src/types";
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 BookStackElement from "@ui/components/bookshelf/BookStackElement.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, randomInt } from "@utils/rand";
import { onDestroy } from "svelte";
import { BookshelfSettingsSchema } from "./BookshelfCodeBlock";
import { parseYaml, TFile } from "obsidian";
interface Props {
plugin: BookTrackerPlugin;
source: string;
}
interface BookData {
title: string;
subtitle?: string;
author: string;
width: number;
color: ColorName;
design: (typeof designs)[number];
orientation: undefined | "tilted" | "on-display";
file: TFile;
}
const { plugin, source }: Props = $props();
const settings = $derived(BookshelfSettingsSchema.parse(parseYaml(source)));
const settingsStore = createSettings(plugin);
setSettingsContext(settingsStore);
let stateFilter: ReadingState = $state(STATUS_TO_BE_READ);
const metadataStore = createMetadata(plugin, stateFilter);
setMetadataContext(metadataStore);
const designs = [
"default",
"colored-spine",
"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 undefined;
} else if (n < 0.8) {
return "tilted";
} else {
return "on-display";
}
}
const randomStackChance = () => randomFloat() > 0.9;
function randomStackSize() {
const n = randomFloat();
if (n < 0.2) {
return 5;
} else if (n < 0.5) {
return 4;
} else if (n < 0.8) {
return 3;
} else if (n < 0.98) {
return 2;
} else {
return 1;
}
}
function getBookData(metadata: FileMetadata): BookData {
return {
title: metadata.frontmatter[settings.titleProperty],
subtitle: settings.subtitleProperty
? metadata.frontmatter[settings.subtitleProperty]
: undefined,
author: metadata.frontmatter[settings.authorsProperty].join(", "),
width: metadata.frontmatter[
settingsStore.settings.pageCountProperty
],
color: randomColor(),
design: randomDesign(),
orientation: randomOrientation(),
file: metadata.file,
};
}
const books = $derived.by(() => {
let books: (BookData | BookData[])[] = [];
for (let i = 0; i < metadataStore.metadata.length; i++) {
if (randomStackChance()) {
const booksRemaining = metadataStore.metadata.length - i;
const stackSize = randomInt(
1,
Math.min(booksRemaining, randomStackSize()),
);
books.push(
metadataStore.metadata
.slice(i, i + stackSize)
.map(getBookData),
);
i += stackSize - 1;
} else {
books.push(getBookData(metadataStore.metadata[i]));
}
}
return books;
});
onDestroy(() => metadataStore.destroy());
</script>
<Bookshelf>
{#each books as book}
{#if Array.isArray(book)}
<BookStack totalChildren={book.length}>
{#each book as bookData}
<BookStackElement
title={bookData.title}
subtitle={bookData.subtitle}
color={bookData.color}
design={bookData.design}
onClick={() =>
plugin.app.workspace.openLinkText(
bookData.file.path,
"",
true,
)}
/>
{/each}
</BookStack>
{:else}
<Book
title={book.title}
subtitle={book.subtitle}
author={book.author}
width={book.width}
color={book.color}
design={book.design}
orientation={book.orientation}
onClick={() =>
plugin.app.workspace.openLinkText(book.file.path, "", true)}
/>
{/if}
{/each}
</Bookshelf>

View File

@ -0,0 +1,46 @@
import { registerCodeBlockRenderer } from ".";
import { SvelteCodeBlockRenderer } from "./SvelteCodeBlockRenderer";
import ShelfCodeBockView from "./ShelfCodeBlockView.svelte";
import type BookTrackerPlugin from "@src/main";
import z from "zod/v4";
import { STATUS_IN_PROGRESS, STATUS_READ, STATUS_TO_BE_READ } from "@src/const";
export function registerShelfCodeBlockProcessor(
plugin: BookTrackerPlugin
): void {
registerCodeBlockRenderer(
plugin,
"shelf",
(source, el) => new ShelfCodeBlockRenderer(plugin, source, el)
);
}
export const SHELF_VIEWS = ["table", "bookshelf"] as const;
export type ShelfView = (typeof SHELF_VIEWS)[number];
export const ShelfSettingsSchema = z.object({
statusFilter: z
.enum([STATUS_TO_BE_READ, STATUS_IN_PROGRESS, STATUS_READ])
.default(STATUS_TO_BE_READ),
defaultView: z.enum(SHELF_VIEWS).default("table"),
coverProperty: z.string(),
titleProperty: z.string(),
subtitleProperty: z.optional(z.string()),
authorsProperty: z.string(),
seriesTitleProperty: z.optional(z.string()),
seriesNumberProperty: z.optional(z.string()),
});
export class ShelfCodeBlockRenderer extends SvelteCodeBlockRenderer<
typeof ShelfCodeBockView
> {
constructor(
plugin: BookTrackerPlugin,
source: string,
contentEl: HTMLElement
) {
super(contentEl, ShelfCodeBockView, { props: { plugin, source } });
}
onunload() {}
}

View File

@ -0,0 +1,289 @@
<script lang="ts">
import { STATUS_IN_PROGRESS, 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 BookStackElement from "@ui/components/bookshelf/BookStackElement.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, randomInt } from "@utils/rand";
import { onDestroy } from "svelte";
import { ShelfSettingsSchema } from "./ShelfCodeBlock";
import { parseYaml, TFile } from "obsidian";
import DateFilter from "@ui/components/DateFilter.svelte";
import Rating from "@ui/components/Rating.svelte";
interface Props {
plugin: BookTrackerPlugin;
source: string;
}
interface BookData {
title: string;
subtitle?: string;
author: string;
width: number;
color: ColorName;
design: (typeof designs)[number];
orientation: undefined | "tilted" | "on-display";
file: TFile;
}
const { plugin, source }: Props = $props();
const settings = ShelfSettingsSchema.parse(parseYaml(source));
const settingsStore = createSettings(plugin);
setSettingsContext(settingsStore);
const metadataStore = createMetadata(plugin, settings.statusFilter);
setMetadataContext(metadataStore);
const designs = [
"default",
"colored-spine",
"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 undefined;
} else if (n < 0.8) {
return "tilted";
} else {
return "on-display";
}
}
const randomStackChance = () => randomFloat() > 0.9;
function randomStackSize() {
const n = randomFloat();
if (n < 0.2) {
return 5;
} else if (n < 0.5) {
return 4;
} else if (n < 0.8) {
return 3;
} else if (n < 0.98) {
return 2;
} else {
return 1;
}
}
function getBookData(metadata: FileMetadata): BookData {
return {
title: metadata.frontmatter[settings.titleProperty],
subtitle: settings.subtitleProperty
? metadata.frontmatter[settings.subtitleProperty]
: undefined,
author: metadata.frontmatter[settings.authorsProperty].join(", "),
width: metadata.frontmatter[
settingsStore.settings.pageCountProperty
],
color: randomColor(),
design: randomDesign(),
orientation: randomOrientation(),
file: metadata.file,
};
}
let view = $state(settings.defaultView);
const books = $derived.by(() => {
let books: (BookData | BookData[])[] = [];
for (let i = 0; i < metadataStore.metadata.length; i++) {
if (randomStackChance()) {
const booksRemaining = metadataStore.metadata.length - i;
const stackSize = randomInt(
1,
Math.min(booksRemaining, randomStackSize()),
);
books.push(
metadataStore.metadata
.slice(i, i + stackSize)
.map(getBookData),
);
i += stackSize - 1;
} else {
books.push(getBookData(metadataStore.metadata[i]));
}
}
return books;
});
onDestroy(() => metadataStore.destroy());
</script>
<div
class="shelf-code-block"
class:table-view={view === "table"}
class:bookshelf-view={view === "bookshelf"}
>
<div class="controls">
<select bind:value={view}>
<option value="table">Table</option>
<option value="bookshelf">Bookshelf</option>
</select>
{#if settings.statusFilter === STATUS_READ}
<DateFilter store={metadataStore} />
{/if}
</div>
{#if view === "bookshelf"}
<Bookshelf>
{#each books as book}
{#if Array.isArray(book)}
<BookStack totalChildren={book.length}>
{#each book as bookData}
<BookStackElement
title={bookData.title}
subtitle={bookData.subtitle}
color={bookData.color}
design={bookData.design}
onClick={() =>
plugin.app.workspace.openLinkText(
bookData.file.path,
"",
true,
)}
/>
{/each}
</BookStack>
{:else}
<Book
title={book.title}
subtitle={book.subtitle}
author={book.author}
width={book.width}
color={book.color}
design={book.design}
orientation={book.orientation}
onClick={() =>
plugin.app.workspace.openLinkText(
book.file.path,
"",
true,
)}
/>
{/if}
{/each}
</Bookshelf>
{: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>{book.frontmatter[settings.titleProperty]}</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>
{/if}
</div>
<style>
.shelf-code-block {
.controls {
margin-bottom: 1rem;
}
&.bookshelf-view {
.controls {
margin-left: auto;
margin-right: auto;
width: 80%;
}
}
table {
border-collapse: collapse;
width: 100%;
}
}
</style>

View File

@ -0,0 +1,46 @@
<script lang="ts">
import {
ALL_TIME,
type DateFilterStore,
} from "@ui/stores/date-filter.svelte";
interface Props {
store: Pick<
DateFilterStore,
"filterYear" | "filterYears" | "filterMonth" | "filterMonths"
>;
showAllMonths?: boolean;
}
const { store, showAllMonths }: Props = $props();
</script>
<select class="year-filter" bind:value={store.filterYear}>
{#each store.filterYears as year}
<option value={year}>{year}</option>
{/each}
<option value={ALL_TIME}>All Time</option>
</select>
{#if store.filterYear !== ALL_TIME}
<select class="month-filter" bind:value={store.filterMonth}>
<option value={ALL_TIME}>Select Month</option>
{#if showAllMonths}
<option value={1}>January</option>
<option value={2}>February</option>
<option value={3}>March</option>
<option value={4}>April</option>
<option value={5}>May</option>
<option value={6}>June</option>
<option value={7}>July</option>
<option value={8}>August</option>
<option value={9}>September</option>
<option value={10}>October</option>
<option value={11}>November</option>
<option value={12}>December</option>
{:else}
{#each store.filterMonths as month}
<option value={month.value}>{month.label}</option>
{/each}
{/if}
</select>
{/if}

View File

@ -0,0 +1,186 @@
<script lang="ts">
interface Props {
rating: number;
}
let { rating }: Props = $props();
</script>
<span data-star={rating}>{rating}</span>
<style>
[data-star] {
text-align: left;
font-style: normal;
display: inline-block;
position: relative;
unicode-bidi: bidi-override;
}
[data-star]::before {
display: block;
content: "★★★★★";
color: #eee;
}
[data-star]::after {
white-space: nowrap;
position: absolute;
top: 0;
left: 0;
content: "★★★★★";
width: 0;
color: #ff8c00;
overflow: hidden;
height: 100%;
}
[data-star^="0.1"]::after {
width: 2%;
}
[data-star^="0.2"]::after {
width: 4%;
}
[data-star^="0.3"]::after {
width: 6%;
}
[data-star^="0.4"]::after {
width: 8%;
}
[data-star^="0.5"]::after {
width: 10%;
}
[data-star^="0.6"]::after {
width: 12%;
}
[data-star^="0.7"]::after {
width: 14%;
}
[data-star^="0.8"]::after {
width: 16%;
}
[data-star^="0.9"]::after {
width: 18%;
}
[data-star^="1"]::after {
width: 20%;
}
[data-star^="1.1"]::after {
width: 22%;
}
[data-star^="1.2"]::after {
width: 24%;
}
[data-star^="1.3"]::after {
width: 26%;
}
[data-star^="1.4"]::after {
width: 28%;
}
[data-star^="1.5"]::after {
width: 30%;
}
[data-star^="1.6"]::after {
width: 32%;
}
[data-star^="1.7"]::after {
width: 34%;
}
[data-star^="1.8"]::after {
width: 36%;
}
[data-star^="1.9"]::after {
width: 38%;
}
[data-star^="2"]::after {
width: 40%;
}
[data-star^="2.1"]::after {
width: 42%;
}
[data-star^="2.2"]::after {
width: 44%;
}
[data-star^="2.3"]::after {
width: 46%;
}
[data-star^="2.4"]::after {
width: 48%;
}
[data-star^="2.5"]::after {
width: 50%;
}
[data-star^="2.6"]::after {
width: 52%;
}
[data-star^="2.7"]::after {
width: 54%;
}
[data-star^="2.8"]::after {
width: 56%;
}
[data-star^="2.9"]::after {
width: 58%;
}
[data-star^="3"]::after {
width: 60%;
}
[data-star^="3.1"]::after {
width: 62%;
}
[data-star^="3.2"]::after {
width: 64%;
}
[data-star^="3.3"]::after {
width: 66%;
}
[data-star^="3.4"]::after {
width: 68%;
}
[data-star^="3.5"]::after {
width: 70%;
}
[data-star^="3.6"]::after {
width: 72%;
}
[data-star^="3.7"]::after {
width: 74%;
}
[data-star^="3.8"]::after {
width: 76%;
}
[data-star^="3.9"]::after {
width: 78%;
}
[data-star^="4"]::after {
width: 80%;
}
[data-star^="4.1"]::after {
width: 82%;
}
[data-star^="4.2"]::after {
width: 84%;
}
[data-star^="4.3"]::after {
width: 86%;
}
[data-star^="4.4"]::after {
width: 88%;
}
[data-star^="4.5"]::after {
width: 90%;
}
[data-star^="4.6"]::after {
width: 92%;
}
[data-star^="4.7"]::after {
width: 94%;
}
[data-star^="4.8"]::after {
width: 96%;
}
[data-star^="4.9"]::after {
width: 98%;
}
[data-star^="5"]::after {
width: 100%;
}
</style>

View File

@ -22,21 +22,23 @@ export function createDateFilter<T>(
initialMonth ? today.getMonth() + 1 : ALL_TIME
);
const filteredData = $derived.by(() => {
return data().filter((item) => {
const date = selector(item);
if (filterYear !== ALL_TIME) {
if (date.year() !== filterYear) {
return false;
return data()
.filter((item) => {
const date = selector(item);
if (filterYear !== ALL_TIME) {
if (date.year() !== filterYear) {
return false;
}
}
}
if (filterMonth !== ALL_TIME) {
if (date.month() !== filterMonth - 1) {
return false;
if (filterMonth !== ALL_TIME) {
if (date.month() !== filterMonth - 1) {
return false;
}
}
}
return true;
});
return true;
})
.sort((a, b) => selector(a).diff(selector(b)));
});
const filterYears = $derived.by(() => {