Rewrite bookshelf view from scratch

This commit is contained in:
Evan Fiordeliso 2025-07-06 23:22:57 -04:00
parent 2df1cf4b30
commit 349fcd903e
16 changed files with 522 additions and 758 deletions

View File

@ -111,6 +111,7 @@ const context = await esbuild.context({
"@lezer/lr",
...builtins,
],
conditions: ["svelte"],
format: "cjs",
target: "es2018",
logLevel: "info",

View File

@ -44,9 +44,13 @@
"typescript": "5.0.4"
},
"dependencies": {
"@humanspeak/svelte-virtual-list": "^0.2.6",
"@leveluptuts/svelte-fit": "^1.0.3",
"chart.js": "^4.5.0",
"chroma-js": "^3.1.2",
"esbuild-sass-plugin": "^3.3.1",
"fitty": "^2.4.2",
"just-memoize": "^2.2.0",
"textfit": "^2.4.0",
"uuid": "^11.1.0",
"yaml": "^2.8.0",

View File

@ -8,6 +8,12 @@ importers:
.:
dependencies:
'@humanspeak/svelte-virtual-list':
specifier: ^0.2.6
version: 0.2.6(svelte@5.34.8)
'@leveluptuts/svelte-fit':
specifier: ^1.0.3
version: 1.0.3
chart.js:
specifier: ^4.5.0
version: 4.5.0
@ -17,6 +23,12 @@ importers:
esbuild-sass-plugin:
specifier: ^3.3.1
version: 3.3.1(esbuild@0.17.3)(sass-embedded@1.89.2)
fitty:
specifier: ^2.4.2
version: 2.4.2
just-memoize:
specifier: ^2.2.0
version: 2.2.0
textfit:
specifier: ^2.4.0
version: 2.4.0
@ -312,6 +324,11 @@ packages:
resolution: {integrity: sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==}
engines: {node: '>=18.18.0'}
'@humanspeak/svelte-virtual-list@0.2.6':
resolution: {integrity: sha512-nfD81b4LQw2bTSFYV/M0ky/pnUkfI0KYr5qCVJHZe6h3dUtXoNIhoxypI6JGBcpW3D8jN5Y53NOkfDggJNb5nA==}
peerDependencies:
svelte: ^5.0.0
'@humanwhocodes/module-importer@1.0.1':
resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
engines: {node: '>=12.22'}
@ -345,6 +362,9 @@ packages:
'@kurkle/color@0.3.4':
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
'@leveluptuts/svelte-fit@1.0.3':
resolution: {integrity: sha512-Hg8Xz06Mf1pwI92cY60LdFKDjXUkix0KcNP5orgGjtV7ecfK4zZ8gwPHOSDioZt73/7Muj1O43QzvtK2oPwEfA==}
'@marijn/find-cluster-break@1.0.2':
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
@ -885,6 +905,9 @@ packages:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
fitty@2.4.2:
resolution: {integrity: sha512-GNhWgImK4+wEkgEZjBkQMyu5NLSmmryg/CaRP7zYby+TWzCrUou6BHL+iqbjKzJRXMyzuJkH+LBB1+lh4oO77g==}
flat-cache@4.0.1:
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
engines: {node: '>=16'}
@ -1138,6 +1161,9 @@ packages:
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
just-memoize@2.2.0:
resolution: {integrity: sha512-zriv+MY+61RXT0QsrO1ZJtL5umouqqSWmCGBkp2wJm35kniunBAA4qhUKx8Lvg/QcwrF9xuw9E6PkevKFf4boQ==}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@ -1951,6 +1977,11 @@ snapshots:
'@humanfs/core': 0.19.1
'@humanwhocodes/retry': 0.3.1
'@humanspeak/svelte-virtual-list@0.2.6(svelte@5.34.8)':
dependencies:
esm-env: 1.2.2
svelte: 5.34.8
'@humanwhocodes/module-importer@1.0.1': {}
'@humanwhocodes/retry@0.3.1': {}
@ -1976,6 +2007,8 @@ snapshots:
'@kurkle/color@0.3.4': {}
'@leveluptuts/svelte-fit@1.0.3': {}
'@marijn/find-cluster-break@1.0.2': {}
'@nodelib/fs.scandir@2.1.5':
@ -2608,6 +2641,8 @@ snapshots:
locate-path: 6.0.0
path-exists: 4.0.0
fitty@2.4.2: {}
flat-cache@4.0.1:
dependencies:
flatted: 3.3.3
@ -2869,6 +2904,8 @@ snapshots:
json-stable-stringify-without-jsonify@1.0.1: {}
just-memoize@2.2.0: {}
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1

View File

@ -4,7 +4,6 @@
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,
@ -22,6 +21,8 @@
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 VirtualList from "@humanspeak/svelte-virtual-list";
interface Props {
plugin: BookTrackerPlugin;
@ -32,11 +33,11 @@
id: string;
title: string;
subtitle?: string;
author: string;
authors: string[];
width: number;
color: ColorName;
design: (typeof designs)[number];
orientation: undefined | "tilted" | "on-display";
orientation: "default" | "tilted" | "front";
file: TFile;
}
@ -55,12 +56,7 @@
const metadataStore = createMetadata(plugin, settings.statusFilter, true);
setMetadataContext(metadataStore);
const designs = [
"default",
"colored-spine",
"dual-top-bands",
"split-bands",
] as const;
const designs = ["default", "dual-top-bands", "split-bands"] as const;
const randomDesign = () => randomElement(designs);
const randomColor = () => randomElement(COLOR_NAMES);
@ -68,11 +64,11 @@
const n = randomFloat();
if (n < 0.55) {
return undefined;
return "default";
} else if (n < 0.8) {
return "tilted";
} else {
return "on-display";
return "front";
}
}
const randomStackChance = () => randomFloat() > 0.9;
@ -91,23 +87,34 @@
}
}
function getBookData(metadata: FileMetadata): BookData {
return {
id: metadata.file.path,
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 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([]);
@ -156,13 +163,14 @@
<Bookshelf>
{#each books as book (book.id)}
{#if "books" in book}
<BookStack totalChildren={book.books.length}>
<BookStack>
{#each book.books as bookData (bookData.id)}
<BookStackElement
<Book
title={bookData.title}
subtitle={bookData.subtitle}
color={bookData.color}
design={bookData.design}
orientation="flat"
onClick={() =>
plugin.app.workspace
.getLeaf("tab")
@ -174,7 +182,7 @@
<Book
title={book.title}
subtitle={book.subtitle}
author={book.author}
authors={book.authors}
width={book.width}
color={book.color}
design={book.design}
@ -275,7 +283,7 @@
{/if}
</div>
<style>
<style lang="scss">
.shelf-code-block {
.controls {
margin-bottom: 1rem;

View File

@ -1,205 +1,327 @@
<script lang="ts">
import Self from "./Book.svelte";
import { Color, isColorName, type ColorName } from "@utils/color";
import BookTilted from "./BookTilted.svelte";
import BookOnDisplay from "./BookOnDisplay.svelte";
import BookText from "./BookText.svelte";
const BOOK_SIZE_DEFAULT: number = 40;
const BOOK_SIZE_MIN: number = 15;
import type { Snippet } from "svelte";
import BookshelfItem from "./BookshelfItem.svelte";
import { Color, type ColorName } from "@utils/color";
import type { HTMLAttributes } from "svelte/elements";
import { fit } from "@leveluptuts/svelte-fit";
interface BookProps {
interface Props {
children?: Snippet;
title?: string;
subtitle?: string;
author?: string;
color?: ColorName | string;
design?: "default" | "colored-spine" | "dual-top-bands" | "split-bands";
orientation?: "tilted" | "on-display";
authors?: string[];
height?: number;
width?: number;
color?: ColorName;
orientation?: "default" | "tilted" | "flat" | "front";
design?: "default" | "split-bands" | "dual-top-bands";
role?: ARIAMixin["role"];
tabindex?: HTMLAttributes<HTMLDivElement>["tabindex"];
onClick?: () => void;
}
let {
title,
subtitle,
author,
color: colorRaw = "green",
authors,
height = 200,
width = 40,
color: colorName = "blue",
orientation = "default",
design = "default",
orientation,
height,
width,
role = "link",
tabindex = 0,
onClick,
}: BookProps = $props();
}: Props = $props();
function normalizeWidth(input: number | undefined) {
if (input) {
if (input <= 150) {
return BOOK_SIZE_MIN;
}
return input / 10;
$effect(() => {
if (orientation === "front" && design !== "default") {
console.warn(
"The front orientation does not support different designs.",
);
}
return BOOK_SIZE_DEFAULT;
}
});
const color = $derived(
isColorName(colorRaw)
? Color.fromName(colorRaw).darken()
: Color.fromCSSColor(colorRaw),
);
const backgroundColor = $derived(
design === "colored-spine"
? color.chroma.mix("black", 0.14)
: color.chroma,
);
const borderLeftColor = $derived(color.chroma.mix("white", 0.04));
const borderRightColor = $derived(color.chroma.mix("black", 0.04));
const bandColor = $derived(color.chroma.mix("black", 0.14));
const textColor = $derived(new Color(backgroundColor).contrastColor.hex);
const verifiedWidth = $derived(normalizeWidth(width));
const color = $derived(Color.fromName(colorName));
const backgroundColor = $derived(color.chroma.css());
const textColor = $derived(color.contrastColor.chroma.css());
</script>
{#if orientation}
{#if orientation === "tilted"}
<BookTilted {design} width={verifiedWidth}>
<Self
{title}
{subtitle}
{author}
color={color.hex}
{design}
{height}
{width}
{onClick}
/>
</BookTilted>
{:else if orientation === "on-display"}
<BookOnDisplay color={color.hex} {onClick}>
{#if title}
<BookText {title} {subtitle} allowWrap />
<p class="bookshelf__book-author">By: {author}</p>
{/if}
</BookOnDisplay>
{/if}
{:else}
<BookshelfItem>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="bookshelf__book-wrapper"
class:default={design === "default"}
class:colored-spine={design === "colored-spine"}
class:dual-top-bands={design === "dual-top-bands"}
class:split-bands={design === "split-bands"}
style:--book-color={backgroundColor.css()}
style:--book-border-left-color={borderLeftColor.css()}
style:--book-border-right-color={borderRightColor.css()}
style:--book-band-color={bandColor.css()}
style:--book-width={verifiedWidth + "px"}
style:width={verifiedWidth + "px"}
style:color={textColor}
class="book"
class:tilted={orientation === "tilted"}
class:flat={orientation === "flat"}
class:front={orientation === "front"}
style:--book-height="{height}px"
style:--book-width="{width}px"
style:--book-bg-color={backgroundColor}
style:--book-text-color={textColor}
style:cursor={onClick ? "pointer" : "default"}
{role}
{tabindex}
onclick={onClick}
onkeydown={(ev) => ev.key === "Enter" && onClick?.()}
role="link"
tabindex="0"
onkeypress={(ev) => ev.key === "Enter" && onClick?.()}
>
<BookText {title} {subtitle} />
{#if orientation !== "front" && (design === "split-bands" || design === "dual-top-bands")}
<div class="book-band top"></div>
{/if}
{#if orientation !== "front" && design === "dual-top-bands"}
<div class="book-band top2"></div>
{/if}
{#if orientation === "front"}
<div class="book-crease"></div>
{/if}
<div class="book-inner">
{#if title}
<h2 class="book-title">
{title}
</h2>
{#if subtitle}
<h3 class="book-subtitle">
{subtitle}
</h3>
{/if}
{/if}
{#if authors && orientation === "front"}
<p class="book-authors">By: {authors?.join(", ")}</p>
{/if}
</div>
{#if orientation !== "front" && design === "split-bands"}
<div class="book-band bottom"></div>
{/if}
</div>
{/if}
</BookshelfItem>
<style lang="scss">
div.bookshelf__book-wrapper {
background: var(--book-color);
border-left: 2px solid var(--book-border-left-color);
border-right: 2px solid var(--book-border-right-color);
@use "./variables.scss" as bookshelf;
&.default,
&.colored-spine {
:global(.bookshelf__book-content) {
height: calc(var(--book-width));
margin: 0 var(--book-width);
$small-band-width: calc(bookshelf.$book-band-width / 1.5);
$band-color: rgba(0, 0, 0, 0.2);
$band-top-offset: bookshelf.$book-band-offset;
$band-top2-offset: $band-top-offset + $small-band-width +
bookshelf.$book-band-gap;
// prettier-ignore
:global(:has(.book:not(.tilted)) + :has(.book.tilted:hover):has(+ * > .book.tilted)),
:global(:has(.book.tilted) + :has(.book.tilted:not(:hover))) {
& > div.book.tilted {
margin-left: 0;
}
}
// prettier-ignore
:global(:has(.book.tilted) + :has(.book.tilted:hover):has(+ * > .book:not(.tilted))),
:global(:has(.book.tilted:not(:hover)):has(+ * > .book.tilted)) {
div.book.tilted {
margin-right: 0;
}
}
div.book {
box-sizing: content-box;
position: relative;
height: var(--book-height);
width: var(--book-width);
border-radius: bookshelf.$book-corner-radius;
transition: transform 0.4s ease;
background-color: var(--book-bg-color);
color: var(--book-text-color);
.book-inner {
--book-content-width: calc(
var(--book-height) - #{bookshelf.$book-padding * 2}
);
box-sizing: border-box;
height: var(--book-width);
width: var(--book-height);
padding: bookshelf.$book-padding;
transform-origin: 0 0;
transform: translateX(var(--book-width)) rotate(90deg);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
$content-height: calc(
var(--book-width) - #{bookshelf.$book-padding * 2}
);
.book-title,
.book-subtitle,
.book-authors {
margin: 0;
padding: 0;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
}
.book-title {
font-size: 16px;
height: $content-height;
width: var(--book-content-width);
&:has(+ .book-subtitle) {
height: calc(#{$content-height} / 2);
}
}
.book-subtitle {
font-size: 12px;
height: calc(#{$content-height} / 2);
width: var(--book-content-width);
align-items: start;
}
.book-authors {
font-size: 10px;
justify-self: end;
width: var(--book-content-width);
}
}
&.colored-spine {
&:before {
content: " ";
display: block;
background: var(--book-band-color);
height: 100%;
width: calc(var(--book-width));
border-radius: 4px;
&:hover {
transform: scale(bookshelf.$book-hover-scale);
}
position: absolute;
top: 0px;
left: -2px;
&.tilted {
@include bookshelf.tilt;
&:hover {
transform: scale(bookshelf.$book-hover-scale);
}
}
&.dual-top-bands,
&.split-bands {
&:after,
&:before {
content: "";
display: block;
background: var(--book-band-color);
width: calc(100% + 4px);
// Flat book orientation for Book Stack
&.flat {
height: var(--book-width);
width: var(--book-height);
position: absolute;
left: -2px;
.book-inner {
height: var(--book-width);
width: var(--book-height);
transform: none;
}
&:before {
z-index: 2;
}
.book-band {
transform: none;
top: 0 !important;
:global(.bookshelf__book-content),
:global(.fit-text) {
height: calc(var(--book-width));
&.top {
left: $band-top-offset;
&:has(+ .top2) {
width: $small-band-width;
}
}
&.top2 {
width: $small-band-width;
left: $band-top2-offset;
}
&.bottom {
left: calc(
var(--book-height) - #{bookshelf.$book-band-offset} - #{bookshelf.$book-band-width}
);
}
}
}
&.dual-top-bands {
&:after {
height: 10px;
top: 8px;
}
// Front book orientation (cover view)
&.front {
$book-width: calc(var(--book-height) * 3 / 4);
width: $book-width;
&:before {
height: 15px;
top: 26px;
}
.book-inner {
$padding-left: bookshelf.$book-crease-offset +
bookshelf.$book-crease-width + bookshelf.$book-padding;
$padding-right: bookshelf.$book-padding;
$padding-total: $padding-left + $padding-right;
:global(.bookshelf__book-content),
:global(.fit-text) {
width: calc(200px - 61px) !important;
}
--book-content-width: calc(#{$book-width} - #{$padding-total});
transform: none;
height: var(--book-height);
width: $book-width;
padding-left: $padding-left;
:global(.bookshelf__book-content) {
margin: 40px var(--book-width);
.book-title {
height: calc(var(--book-height) / 4);
}
.book-subtitle {
height: calc(var(--book-height) / 4);
}
.book-authors {
height: calc(var(--book-height) / 2);
}
}
}
&.split-bands {
&:after,
&:before {
height: 20px;
// Book band for styles
.book-band {
position: absolute;
height: var(--book-width);
width: bookshelf.$book-band-width;
transform-origin: 0 0;
transform: translateX(var(--book-width)) rotate(90deg);
background-color: $band-color;
&.top {
top: $band-top-offset;
&:has(+ .top2) {
width: $small-band-width;
}
}
&:after {
top: 10px;
&.top2 {
width: $small-band-width;
top: $band-top2-offset;
}
&:before {
bottom: 10px;
&.bottom {
top: calc(
var(--book-height) - #{bookshelf.$book-band-offset} - #{bookshelf.$book-band-width}
);
}
:global(.bookshelf__book-content),
:global(.fit-text) {
width: calc(200px - 62px) !important;
// Dual Top Band Offset
& + .book-band + .book-inner {
$padding-left: $band-top2-offset + $small-band-width +
bookshelf.$book-padding;
$padding-right: bookshelf.$book-padding;
$padding-total: $padding-left + $padding-right;
--book-content-width: calc(
var(--book-height) - #{$padding-total}
);
padding-left: $padding-left;
}
:global(.bookshelf__book-content) {
margin: 30px var(--book-width);
// Split Band Offset
&.top + .book-inner {
$padding: $band-top-offset + bookshelf.$book-band-width +
bookshelf.$book-padding;
--book-content-width: calc(
var(--book-height) - #{$padding * 2}
);
padding-left: $padding;
padding-right: $padding;
}
}
.book-crease {
position: absolute;
height: var(--book-height);
width: bookshelf.$book-crease-width;
left: bookshelf.$book-crease-offset;
background-color: $band-color;
}
}
</style>

View File

@ -1,65 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
import chroma from "chroma-js";
interface Props {
children?: Snippet;
color?: string;
onClick?: () => void;
}
let { children, color = "green", onClick }: Props = $props();
let backgroundColor = $derived(chroma(color));
let bandColor = $derived(chroma(color).mix("black", 0.14));
</script>
<div
class="bookshelf__book-wrapper bookshelf__book-onDisplay"
style:--book-color={backgroundColor.css()}
style:--book-band-color={bandColor.css()}
onclick={onClick}
onkeydown={(ev) => ev.key === "Enter" && onClick?.()}
role="link"
tabindex="0"
>
<div class="book-display-crease"></div>
{@render children?.()}
</div>
<style lang="scss">
$band-width: 5px;
$band-offset: 8px;
div.bookshelf__book-onDisplay {
width: 150px;
height: 200px;
background: var(--book-color);
display: flex;
flex-flow: column wrap;
justify-content: space-between;
align-items: center;
text-align: center;
margin: 20px 2px 10px 2px;
position: relative;
padding-left: calc($band-offset + $band-width);
padding-right: 8px;
&:before {
content: " ";
display: block;
background: var(--book-band-color);
width: $band-width;
height: 100%;
position: absolute;
top: 0;
left: $band-offset;
}
&:global(.bookshelf__book-author) {
font-size: 0.8em;
margin-left: 13px;
}
}
</style>

View File

@ -1,19 +1,31 @@
<script lang="ts">
import type { Snippet } from "svelte";
const MAX_CHILDREN = 5;
import BookshelfItem from "./BookshelfItem.svelte";
interface Props {
children?: Snippet;
totalChildren?: number;
}
let { children, totalChildren = 0 }: Props = $props();
let { children }: Props = $props();
</script>
<ul
class="bookshelf__bookStack-wrapper"
style:margin={`calc(20px + ${(MAX_CHILDREN - totalChildren) * 40}px) 1px 10px`}
>
{@render children?.()}
</ul>
<BookshelfItem>
<div class="book-stack">
{@render children?.()}
</div>
</BookshelfItem>
<style lang="scss">
@use "./variables.scss" as bookshelf;
div.book-stack {
display: flex;
flex-direction: column;
align-items: center;
justify-content: end;
:global(.bookshelf-item) {
height: auto;
}
}
</style>

View File

@ -1,142 +0,0 @@
<script lang="ts">
import { Color, isColorName, type ColorName } from "@utils/color";
import BookText from "./BookText.svelte";
interface Props {
title?: string;
subtitle?: string;
color?: ColorName | string;
design?: "default" | "split-bands" | "dual-top-bands" | "colored-spine";
onClick?: () => void;
}
let {
title,
subtitle,
color: colorRaw = "green",
design,
onClick,
}: Props = $props();
const color = $derived(
isColorName(colorRaw)
? Color.fromName(colorRaw).darken()
: Color.fromCSSColor(colorRaw),
);
const backgroundColor = $derived(
design === "colored-spine"
? color.chroma.mix("black", 0.14)
: color.chroma,
);
const borderLeftColor = $derived(color.chroma.mix("white", 0.04));
const borderRightColor = $derived(color.chroma.mix("black", 0.04));
const bandColor = $derived(color.chroma.mix("black", 0.14));
const textColor = $derived(new Color(backgroundColor).contrastColor.hex);
</script>
<li class="bookshelf__bookstack-elem">
<div
class="bookshelf__book-wrapper"
class:default={design === "default"}
class:colored-spine={design === "colored-spine"}
class:dual-top-bands={design === "dual-top-bands"}
class:split-bands={design === "split-bands"}
style:--book-color={backgroundColor.css()}
style:--book-border-left-color={borderLeftColor.css()}
style:--book-border-right-color={borderRightColor.css()}
style:--book-band-color={bandColor.css()}
style:color={textColor}
onclick={onClick}
onkeydown={(ev) => ev.key === "Enter" && onClick?.()}
role="link"
tabindex="0"
>
<BookText {title} {subtitle} />
</div>
</li>
<style lang="scss">
div {
background: var(--book-color);
border-left: 2px solid var(--book-border-left-color);
border-right: 2px solid var(--book-border-right-color);
&.colored-spine {
&:before {
content: " ";
display: block;
background: var(--book-band-color);
height: 40px;
width: calc(100% + 4px);
border-radius: 4px;
position: absolute;
top: 0px;
left: -2px;
}
}
&.split-bands,
&.dual-top-bands {
&:after,
&:before {
content: " ";
display: block;
background: var(--book-band-color);
height: 40px;
border-radius: 4px;
position: absolute;
top: 0px;
}
&:before {
z-index: 2;
}
}
&.split-bands {
&:after {
width: 20px;
left: 10px;
}
&:before {
width: 20px;
right: 10px;
}
:global(.bookshelf__book-content),
:global(.fit-text) {
width: calc(200px - 60px) !important;
}
:global(.bookshelf__book-content) {
margin: 0 30px;
}
}
&.dual-top-bands {
&:after {
width: 10px;
left: 6px;
}
&:before {
width: 15px;
left: 24px;
z-index: 2;
}
:global(.bookshelf__book-content),
:global(.fit-text) {
width: calc(200px - 31px) !important;
}
:global(.bookshelf__book-content) {
margin: 0 31px;
}
}
}
</style>

View File

@ -1,61 +0,0 @@
<script lang="ts">
import { fitText } from "@ui/directives";
import { wrap } from "module";
interface Props {
title?: string;
subtitle?: string;
allowWrap?: boolean;
}
let { title, subtitle, allowWrap }: Props = $props();
</script>
<div
class="bookshelf__book-content"
class:center-content={subtitle === undefined}
>
{#if title}
<h2
class="bookshelf__book-title fit-text"
class:wrap={allowWrap}
use:fitText={{
minFontSize: 12,
maxFontSize: 16,
multiLine: allowWrap,
detectMultiLine: false,
}}
>
{title}
</h2>
{/if}
{#if subtitle}
<h4
class="bookshelf__book-subtitle fit-text"
use:fitText={{
minFontSize: 10,
maxFontSize: 14,
multiLine: allowWrap,
detectMultiLine: false,
}}
>
{subtitle}
</h4>
{/if}
</div>
<style lang="scss">
.bookshelf__book-title,
.bookshelf__book-subtitle {
width: 100% !important;
:global(.textFitted) {
width: 100%;
word-wrap: break-word;
}
}
.bookshelf__book-subtitle {
overflow: hidden;
}
</style>

View File

@ -1,51 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
children?: Snippet;
width?: number;
design?: "default" | "colored-spine" | "dual-top-bands" | "split-bands";
topMargin?: number;
}
let {
children,
width = 40,
design = "default",
topMargin,
}: Props = $props();
function getTopMargin(
design: "default" | "colored-spine" | "dual-top-bands" | "split-bands",
): number {
switch (design) {
case "split-bands":
return 30;
case "dual-top-bands":
return 41;
}
return 0;
}
</script>
<div
class="bookshelf__book-wrapper bookshelf__book-tilted"
style:--book-tilted-width={width + "px"}
style:--book-tilted-top-margin={(topMargin ?? getTopMargin(design)) + "px"}
>
{@render children?.()}
</div>
<style lang="scss">
div {
:global(.bookshelf__book-content),
:global(.fit-text) {
height: calc(var(--book-tilted-width));
width: calc(200px - 60px);
}
:global(.bookshelf__book-content) {
margin: var(--book-tilted-top-margin) var(--book-tilted-width);
}
}
</style>

View File

@ -1,32 +1,88 @@
<script lang="ts">
import "./bookshelf.scss";
import type { Snippet } from "svelte";
import { type Snippet } from "svelte";
import type { ActionReturn } from "svelte/action";
import textFit from "textfit";
type Props = {
interface Props {
children?: Snippet;
};
}
let { children }: Props = $props();
const color = "#a47148";
const fitAllText = (el: HTMLDivElement): ActionReturn => {
const observer = new MutationObserver(() => {
requestAnimationFrame(() => {
textFit(el.getElementsByClassName("book-title"), {
minFontSize: 8,
maxFontSize: 16,
multiLine: true,
});
textFit(el.getElementsByClassName("book-subtitle"), {
minFontSize: 6,
maxFontSize: 14,
multiLine: true,
});
});
});
observer.observe(el, {
childList: true,
subtree: true,
});
return {
destroy() {
observer.disconnect();
},
};
};
</script>
<div class="bookshelf__wrapper" style:--book-shelf-color={color}>
<div class="bookshelf" use:fitAllText>
{@render children?.()}
</div>
<style lang="scss">
div {
@use "sass:color";
@use "./variables.scss" as bookshelf;
div.bookshelf {
width: 80%;
margin: 0 auto;
min-height: 100vh;
min-height: calc(bookshelf.$height + bookshelf.$shelf-width);
background-image: linear-gradient(
color-mix(in srgb, var(--book-shelf-color), black 32%),
color-mix(in srgb, var(--book-shelf-color), black 30%) 220px,
color-mix(in srgb, var(--book-shelf-color), white 4%) 220px,
color-mix(in srgb, var(--book-shelf-color), white 4%) 222px,
var(--book-shelf-color) 222px,
var(--book-shelf-color) 228px,
color-mix(in srgb, var(--book-shelf-color), black 4%) 228px,
color-mix(in srgb, var(--book-shelf-color), black 4%) 230px
color.scale(bookshelf.$color, $lightness: -32%),
color.scale(bookshelf.$color, $lightness: -30%) bookshelf.$height,
color.scale(bookshelf.$color, $lightness: bookshelf.$shadow-pct)
bookshelf.$height,
color.scale(bookshelf.$color, $lightness: bookshelf.$shadow-pct)
calc(bookshelf.$height + bookshelf.$shadow-width),
bookshelf.$color bookshelf.$height,
bookshelf.$color calc(bookshelf.$height + bookshelf.$shelf-width),
color.scale(
bookshelf.$color,
$lightness: -1 * bookshelf.$shadow-pct
)
calc(
bookshelf.$height + bookshelf.$shelf-width -
bookshelf.$shadow-width
),
color.scale(
bookshelf.$color,
$lightness: -1 * bookshelf.$shadow-pct
)
calc(bookshelf.$height + bookshelf.$shelf-width)
);
border: 10px var(--book-shelf-color) solid;
background-size: 10px calc(bookshelf.$height + bookshelf.$shelf-width);
border: 10px bookshelf.$color solid;
padding-left: bookshelf.$book-spacing;
padding-right: bookshelf.$book-spacing;
display: flex;
flex-wrap: wrap;
gap: bookshelf.$shelf-width bookshelf.$book-spacing;
}
</style>

View File

@ -0,0 +1,30 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
children?: Snippet;
}
let { children }: Props = $props();
</script>
<div class="bookshelf-item">
{@render children?.()}
</div>
<style lang="scss">
@use "./variables.scss" as bookshelf;
div.bookshelf-item {
box-sizing: border-box;
height: bookshelf.$height;
display: flex;
align-items: end;
// Why????
padding-bottom: 0.75px;
&:global(:has(.book.tilted)) {
padding-bottom: 0;
}
}
</style>

View File

@ -1,217 +0,0 @@
$white: white;
$black: black;
$background: #dedede;
$bookWidth: 40px;
$bookHeight: 200px;
$bookEdge: 2px;
.bookshelf__wrapper {
width: 80%;
height: 100%;
margin: 0 auto;
overflow: hidden;
background-size: 10px 230px;
min-height: 230px;
.bookshelf__bookStack-wrapper {
width: 200px;
height: 100%;
display: inline-flex;
flex-flow: column nowrap;
list-style: none;
float: left;
margin: 0;
padding: 0;
.bookshelf__bookStack-outOfStock {
background: #232323;
color: #fff;
height: 150px;
padding: 0;
margin: 50px 1px auto 1px;
display: flex;
flex-flow: column nowrap;
justify-content: space-around;
align-items: center;
font-size: 1.1em;
font-weight: 600;
letter-spacing: 1.25px;
border-radius: 6px;
}
.bookshelf__bookstack-elem {
margin-inline-start: 0;
margin: 0;
padding: 0;
.bookshelf__book-wrapper {
width: 100%;
height: 40px;
margin: 0;
padding: 0;
.bookshelf__book-content {
height: $bookWidth;
width: $bookHeight;
transform-origin: 0% 0%;
transform: rotate(0deg);
overflow: hidden;
// * Centering content
display: flex;
flex-flow: column nowrap;
justify-content: center;
align-items: center;
.bookshelf__book-title,
.bookshelf__book-subtitle {
font-size: 0.8em;
font-weight: 600;
height: calc($bookWidth / 2);
width: $bookHeight;
padding: 0;
margin: 0;
// * Centering content
display: flex;
justify-content: center;
align-items: center;
text-align: center;
}
.bookshelf__book-subtitle {
font-size: 0.6em;
letter-spacing: 1px;
font-weight: 400;
font-style: italic;
}
}
.center-content {
display: flex;
justify-content: center;
align-items: center;
}
// .bookshelf__book-title{
// width: 140px;
// height: 40px;
// transform: rotate(0deg);
// margin-left: 29px;
// margin-top: 0;
// text-align: center;
// }
}
}
}
.bookshelf__book-wrapper {
height: $bookHeight;
width: $bookWidth;
float: left;
margin: 20px 1px 10px 1px;
border-radius: 6px;
transition: transform 0.4s ease;
position: relative;
.bookshelf__book-content {
width: $bookHeight;
transform-origin: 0% 0%;
transform: rotate(90deg);
overflow: hidden;
// * Centering content
display: flex;
flex-flow: column nowrap;
justify-content: center;
align-items: center;
.bookshelf__book-title,
.bookshelf__book-subtitle {
font-size: 0.8em;
font-weight: 600;
height: calc($bookWidth / 2);
width: $bookHeight;
padding: 0;
margin: 0;
// * Centering content
display: flex;
justify-content: center;
align-items: center;
text-align: center;
}
.bookshelf__book-subtitle {
font-size: 0.6em;
letter-spacing: 1px;
font-weight: 400;
font-style: italic;
}
}
&:hover {
transform: scale(1.05);
}
.center-content {
display: flex;
justify-content: center;
align-items: center;
}
}
.bookshelf__book-tilted {
float: left;
width: 72px;
.bookshelf__book-wrapper {
--book-width: 40px !important;
width: 40px !important;
}
&:hover .bookshelf__book-wrapper {
transform: translateY(-20px);
}
& > .bookshelf__book-wrapper {
transform: translateY(-22px) translateX(13px) rotate(9deg);
}
}
.bookshelf__book-onDisplay {
.bookshelf__book-content {
width: calc(100% - 11px);
margin-left: 11px;
transform: rotate(0deg);
overflow: visible;
.bookshelf__book-title,
.bookshelf__book-subtitle {
margin-top: 16px;
width: 100%;
}
.bookshelf__book-title {
word-wrap: break-word;
}
.bookshelf__book-subtitle {
white-space: nowrap;
text-overflow: ellipsis;
}
}
.bookshelf__book-author {
font-size: 0.8em;
margin-left: 13px;
}
}
}

View File

@ -0,0 +1,47 @@
@use "sass:math";
$color: #a47148;
$height: 220px;
$shelf-width: 10px;
$shadow-pct: 4%;
$shadow-width: calc($shelf-width / 5);
$book-spacing: 2px;
$book-padding: 4px;
$book-corner-radius: 3.5px;
$book-hover-scale: 1.02;
$book-band-offset: 10px;
$book-band-gap: 5px;
$book-band-width: 10px;
$book-crease-offset: 10px;
$book-crease-width: 2px;
$tilt-angle: 9deg;
@mixin tilt() {
$width: var(--book-width);
$height: var(--book-height);
$sin: math.sin($tilt-angle);
$cos: math.cos($tilt-angle);
// $newWidth: calc(abs($width * $cos) + abs($height * $sin));
$newWidth: calc(
max(($width * $cos), -1 * ($width * $cos)) +
max(($height * $sin), -1 * ($height * $sin))
);
$margin-h: calc(($newWidth - $width) / 2);
// $newHeight: calc(abs($width * $sin) + abs($height * $cos));
$newHeight: calc(
max(($width * $sin), -1 * ($width * $sin)) +
max(($height * $cos), -1 * ($height * $cos))
);
$margin-v: calc(($newHeight - $height) / 2);
transform: rotate($tilt-angle);
margin: $margin-v $margin-h;
transition: transform 0.4s ease, margin 0.4s ease;
&:hover {
margin-top: 0;
margin-bottom: 0;
}
}

View File

@ -1,16 +0,0 @@
import type { ActionReturn } from "svelte/action";
import { default as doTextFit, type TextFitOption } from "textfit";
export function fitText(
el: HTMLElement,
opts: TextFitOption = {}
): ActionReturn<TextFitOption> {
doTextFit(el, opts);
return {
update(opts) {
doTextFit(el, opts);
},
destroy() {},
};
}

View File

@ -1,3 +1,2 @@
export { chart } from "./chart";
export { clickOutside } from "./clickOutside";
export { fitText } from "./fitText";