refactor: resolve() internal links instead of plain hrefs
Type-checks every link against the real route tree. Caught a dead one: the
book page linked tags to /tag/{id}, a route that has never existed.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
@@ -36,8 +37,8 @@
|
||||
|
||||
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => {
|
||||
// Rename files to use webkitRelativePath so directory structure is preserved through form submission
|
||||
const renamedFiles = uploadedFiles.map(f =>
|
||||
new File([f], f.webkitRelativePath || f.name, { type: f.type })
|
||||
const renamedFiles = uploadedFiles.map(
|
||||
(f) => new File([f], f.webkitRelativePath || f.name, { type: f.type })
|
||||
);
|
||||
uploadBooks.fields.files.set([...Array.from(files), ...renamedFiles]);
|
||||
if (autoUploadOnDrop && files.length > 0) {
|
||||
@@ -55,9 +56,12 @@
|
||||
let libraryId = books.items[0].library_id;
|
||||
libraryState.setActive(libraryId);
|
||||
if (books.items.length === 1) {
|
||||
goto(`/book/${books.items[0].id}`);
|
||||
goto(resolve('/(root)/(library)/book/[bookId]', { bookId: String(books.items[0].id) }));
|
||||
} else {
|
||||
goto(`/library/${libraryId}/view?orderBy=created_at&sortOrder=desc`);
|
||||
// The path is resolved; the query string is what the rule cannot see past.
|
||||
const view = resolve('/(root)/(library)/library/[libraryId]/view', { libraryId: String(libraryId) });
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
goto(`${view}?orderBy=created_at&sortOrder=desc`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -88,8 +92,10 @@
|
||||
}
|
||||
|
||||
// Update library book count
|
||||
const count = uploadBooks.result.total
|
||||
libraryState.libraries.find(lib => uploadBooks.fields.library_id.value() == lib.id.toString())!.total += count
|
||||
const count = uploadBooks.result.total;
|
||||
libraryState.libraries.find(
|
||||
(lib) => uploadBooks.fields.library_id.value() == lib.id.toString()
|
||||
)!.total += count;
|
||||
|
||||
// Reset the files field
|
||||
uploadBooks.fields.files.set([]);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||
|
||||
import ChitaiMark from '$lib/components/icons/chitai-mark.svelte';
|
||||
@@ -27,7 +28,7 @@
|
||||
// directly in the markup keeps it static.
|
||||
const header = $derived({
|
||||
title: 'chitai',
|
||||
url: `/library/${libraryState.activeLibrary!.id}`
|
||||
url: resolve('/(root)/(library)/library/[libraryId]', { libraryId: String(libraryState.activeLibrary!.id) })
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -40,6 +41,8 @@
|
||||
-->
|
||||
<Sidebar.MenuButton class="mt-1 -ml-1.5">
|
||||
{#snippet child({ props })}
|
||||
<!-- header.url is built with resolve(); the rule cannot trace the variable. -->
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<a href={header.url} {...props}>
|
||||
<ChitaiMark class="mr-3 size-7!" />
|
||||
<span class="font-serif text-xl tracking-tight">{header.title}</span>
|
||||
|
||||
@@ -1,118 +1,137 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import { Badge } from "$lib/components/ui/badge/index.js";
|
||||
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
|
||||
import { getBookshelfState } from '$lib/state/bookshelf.svelte';
|
||||
import { getLibraryState } from '$lib/state/library.svelte';
|
||||
import { House, LibraryBig, Rows3, ChevronRightIcon } from '@lucide/svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
|
||||
import { getBookshelfState } from '$lib/state/bookshelf.svelte';
|
||||
import { getLibraryState } from '$lib/state/library.svelte';
|
||||
import { House, LibraryBig, Rows3, ChevronRightIcon } from '@lucide/svelte';
|
||||
|
||||
const libraryState = getLibraryState();
|
||||
const bookshelfState = getBookshelfState();
|
||||
const sidebar = useSidebar();
|
||||
const libraryState = getLibraryState();
|
||||
const bookshelfState = getBookshelfState();
|
||||
const sidebar = useSidebar();
|
||||
|
||||
// Owned here so the collapsed rail can force it open when it expands the
|
||||
// sidebar. Previously open={isActive}, which was always false — the Shelves
|
||||
// item has no route of its own, so pathname never matched.
|
||||
let shelvesOpen = $state(false);
|
||||
// Owned here so the collapsed rail can force it open when it expands the
|
||||
// sidebar. Previously open={isActive}, which was always false — the Shelves
|
||||
// item has no route of its own, so pathname never matched.
|
||||
let shelvesOpen = $state(false);
|
||||
|
||||
// Deliberately a plain const, not $derived. These objects hold component
|
||||
// references, and `<item.icon />` is a dynamic component — if the array were
|
||||
// rebuilt on every navigation the icons would remount, flashing and shifting
|
||||
// layout. Only the url and active state need to be reactive, so they are
|
||||
// computed per-item in the markup instead.
|
||||
const items = [
|
||||
{ title: 'Home', icon: House, path: (id?: number) => `/library/${id}` },
|
||||
{ title: 'Library', icon: LibraryBig, path: (id?: number) => `/library/${id}/view` },
|
||||
{ title: 'Shelves', icon: Rows3, path: () => '#', shelves: [] }
|
||||
];
|
||||
// Deliberately a plain const, not $derived. These objects hold component
|
||||
// references, and `<item.icon />` is a dynamic component — if the array were
|
||||
// rebuilt on every navigation the icons would remount, flashing and shifting
|
||||
// layout. Only the url and active state need to be reactive, so they are
|
||||
// computed per-item in the markup instead.
|
||||
//
|
||||
// Active state is matched on route id rather than pathname. resolve() returns
|
||||
// an absolute path on the client but a relative one during SSR, so comparing
|
||||
// it to page.url.pathname would be false on the server and true after
|
||||
// hydration — the highlight would flash in.
|
||||
const items = [
|
||||
{
|
||||
title: 'Home',
|
||||
icon: House,
|
||||
routeId: '/(root)/(library)/library/[libraryId]',
|
||||
path: (id?: number) => resolve('/(root)/(library)/library/[libraryId]', { libraryId: String(id ?? '') })
|
||||
},
|
||||
{
|
||||
title: 'Library',
|
||||
icon: LibraryBig,
|
||||
routeId: '/(root)/(library)/library/[libraryId]/view',
|
||||
path: (id?: number) => resolve('/(root)/(library)/library/[libraryId]/view', { libraryId: String(id ?? '') })
|
||||
},
|
||||
{ title: 'Shelves', icon: Rows3, routeId: null, path: () => '#', shelves: [] }
|
||||
];
|
||||
</script>
|
||||
|
||||
{#snippet shelvesTrigger(item: { title: string; icon: typeof House })}
|
||||
<item.icon class="scale-125" />
|
||||
<span class="text-md ml-2">{item.title}</span>
|
||||
<ChevronRightIcon
|
||||
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
||||
/>
|
||||
<item.icon class="scale-125" />
|
||||
<span class="text-md ml-2">{item.title}</span>
|
||||
<ChevronRightIcon
|
||||
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<Sidebar.Group>
|
||||
<Sidebar.Menu>
|
||||
{#each items as item (item.title)}
|
||||
{@const url = item.path(libraryState.activeLibrary?.id)}
|
||||
{@const isActive = page.url.pathname === url}
|
||||
{#if 'shelves' in item}
|
||||
<Collapsible.Root bind:open={shelvesOpen} class="group/collapsible">
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuItem {...props}>
|
||||
{#if sidebar.state === 'collapsed'}
|
||||
<!--
|
||||
<Sidebar.Menu>
|
||||
{#each items as item (item.title)}
|
||||
{@const url = item.path(libraryState.activeLibrary?.id)}
|
||||
{@const isActive = item.routeId !== null && page.route.id === item.routeId}
|
||||
{#if 'shelves' in item}
|
||||
<Collapsible.Root bind:open={shelvesOpen} class="group/collapsible">
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuItem {...props}>
|
||||
{#if sidebar.state === 'collapsed'}
|
||||
<!--
|
||||
Sidebar.MenuSub is group-data-[collapsible=icon]:hidden, so
|
||||
toggling in the rail opens a list nobody can see. Expand the
|
||||
sidebar and open the section instead of toggling.
|
||||
-->
|
||||
<Sidebar.MenuButton
|
||||
tooltipContent={item.title}
|
||||
class="h-10"
|
||||
onclick={() => {
|
||||
sidebar.setOpen(true);
|
||||
shelvesOpen = true;
|
||||
}}
|
||||
>
|
||||
{@render shelvesTrigger(item)}
|
||||
</Sidebar.MenuButton>
|
||||
{:else}
|
||||
<Collapsible.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuButton {...props} tooltipContent={item.title} class="h-10">
|
||||
{@render shelvesTrigger(item)}
|
||||
</Sidebar.MenuButton>
|
||||
{/snippet}
|
||||
</Collapsible.Trigger>
|
||||
{/if}
|
||||
<Collapsible.Content>
|
||||
<Sidebar.MenuSub class="w-full">
|
||||
{#each bookshelfState.getBookshelves(libraryState.activeLibrary!.id) ?? [] as shelf (shelf.id)}
|
||||
<Sidebar.MenuSubItem>
|
||||
<Sidebar.MenuSubButton>
|
||||
{#snippet child({ props })}
|
||||
<a
|
||||
href={`/library/${libraryState.activeLibrary!.id}/view?shelves=${shelf.id}`}
|
||||
{...props}
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="scale-90 font-semibold bg-sidebar-primary text-sidebar-primary-foreground mr-1">
|
||||
{shelf.total}
|
||||
</Badge>
|
||||
<span>{shelf.title}</span>
|
||||
|
||||
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuSubButton>
|
||||
</Sidebar.MenuSubItem>
|
||||
{/each}
|
||||
</Sidebar.MenuSub>
|
||||
</Collapsible.Content>
|
||||
</Sidebar.MenuItem>
|
||||
{/snippet}
|
||||
</Collapsible.Root>
|
||||
{:else}
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton isActive={isActive} tooltipContent={item.title} class="h-10">
|
||||
{#snippet child({ props })}
|
||||
<a href={url} {...props}>
|
||||
{#if item.icon}
|
||||
<item.icon class="scale-125" />
|
||||
{/if}
|
||||
<span class="text-md pl-2">{item.title}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
{/if}
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Group>
|
||||
<Sidebar.MenuButton
|
||||
tooltipContent={item.title}
|
||||
class="h-10"
|
||||
onclick={() => {
|
||||
sidebar.setOpen(true);
|
||||
shelvesOpen = true;
|
||||
}}
|
||||
>
|
||||
{@render shelvesTrigger(item)}
|
||||
</Sidebar.MenuButton>
|
||||
{:else}
|
||||
<Collapsible.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuButton {...props} tooltipContent={item.title} class="h-10">
|
||||
{@render shelvesTrigger(item)}
|
||||
</Sidebar.MenuButton>
|
||||
{/snippet}
|
||||
</Collapsible.Trigger>
|
||||
{/if}
|
||||
<Collapsible.Content>
|
||||
<Sidebar.MenuSub class="w-full">
|
||||
{#each bookshelfState.getBookshelves(libraryState.activeLibrary!.id) ?? [] as shelf (shelf.id)}
|
||||
<Sidebar.MenuSubItem>
|
||||
<Sidebar.MenuSubButton>
|
||||
{#snippet child({ props })}
|
||||
<a
|
||||
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
|
||||
libraryId: String(libraryState.activeLibrary!.id)
|
||||
})}?shelves={shelf.id}"
|
||||
{...props}
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="mr-1 scale-90 bg-sidebar-primary font-semibold text-sidebar-primary-foreground"
|
||||
>
|
||||
{shelf.total}
|
||||
</Badge>
|
||||
<span>{shelf.title}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuSubButton>
|
||||
</Sidebar.MenuSubItem>
|
||||
{/each}
|
||||
</Sidebar.MenuSub>
|
||||
</Collapsible.Content>
|
||||
</Sidebar.MenuItem>
|
||||
{/snippet}
|
||||
</Collapsible.Root>
|
||||
{:else}
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton {isActive} tooltipContent={item.title} class="h-10">
|
||||
{#snippet child({ props })}
|
||||
<!-- item.path() calls resolve(); the rule cannot trace the variable. -->
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<a href={url} {...props}>
|
||||
{#if item.icon}
|
||||
<item.icon class="scale-125" />
|
||||
{/if}
|
||||
<span class="text-md pl-2">{item.title}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
{/if}
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Group>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import * as Avatar from '$lib/components/ui/avatar/index.js';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
@@ -31,13 +32,16 @@
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar.Root class="size-8 rounded-lg">
|
||||
<Avatar.Fallback class="rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||
<Avatar.Fallback
|
||||
class="rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
|
||||
>
|
||||
{initials}
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{handle}</span>
|
||||
<span class="truncate font-mono text-[11px] text-muted-foreground">{user?.email}</span>
|
||||
<span class="truncate font-mono text-[11px] text-muted-foreground">{user?.email}</span
|
||||
>
|
||||
</div>
|
||||
<ChevronsUpDownIcon class="ml-auto size-4" />
|
||||
</Sidebar.MenuButton>
|
||||
@@ -53,13 +57,16 @@
|
||||
<DropdownMenu.Label class="p-0 font-normal">
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar.Root class="size-8 rounded-lg">
|
||||
<Avatar.Fallback class="rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||
<Avatar.Fallback
|
||||
class="rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
|
||||
>
|
||||
{initials}
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{handle}</span>
|
||||
<span class="truncate font-mono text-[11px] text-muted-foreground">{user?.email}</span>
|
||||
<span class="truncate font-mono text-[11px] text-muted-foreground">{user?.email}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenu.Label>
|
||||
@@ -67,11 +74,11 @@
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.Item onSelect={() => goto('/settings/account')}>
|
||||
<DropdownMenu.Item onSelect={() => goto(resolve('/settings/account'))}>
|
||||
<SettingsIcon class="size-4" />
|
||||
Settings
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={() => goto('/settings/appearance')}>
|
||||
<DropdownMenu.Item onSelect={() => goto(resolve('/settings/appearance'))}>
|
||||
<PaletteIcon class="size-4" />
|
||||
Appearance
|
||||
</DropdownMenu.Item>
|
||||
@@ -82,7 +89,7 @@
|
||||
<DropdownMenu.Item
|
||||
onSelect={async () => {
|
||||
await logout();
|
||||
await goto('/login');
|
||||
await goto(resolve('/login'));
|
||||
}}
|
||||
>
|
||||
<LogOutIcon class="size-4" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import * as Command from '$lib/components/ui/command/index';
|
||||
import * as Kbd from '$lib/components/ui/kbd/index.js';
|
||||
import * as InputGroup from '$lib/components/ui/input-group/index.js';
|
||||
@@ -97,7 +98,7 @@
|
||||
<Command.Item
|
||||
value={String(book.id)}
|
||||
onSelect={() => {
|
||||
goto(`/book/${book.id}`);
|
||||
goto(resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) }));
|
||||
open = false;
|
||||
}}
|
||||
class="cursor-pointer"
|
||||
@@ -115,7 +116,9 @@
|
||||
by
|
||||
{#each book.authors as author}
|
||||
<a
|
||||
href="/library/{libraryState.activeLibrary!.id}/view?authors={author.id}"
|
||||
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
|
||||
libraryId: String(libraryState.activeLibrary!.id)
|
||||
})}?authors={author.id}"
|
||||
class="cs-list hover:underline">{author.name}</a
|
||||
>  
|
||||
{/each}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
|
||||
import {
|
||||
@@ -25,14 +26,14 @@
|
||||
import ReaderToc from './reader-toc.svelte';
|
||||
|
||||
let {
|
||||
bookUrl,
|
||||
fileUrl,
|
||||
bookId,
|
||||
filename,
|
||||
title = '',
|
||||
initialProgress = 0,
|
||||
initialEpubLoc = null
|
||||
}: {
|
||||
bookUrl: string;
|
||||
fileUrl: string;
|
||||
bookId: string | number;
|
||||
filename: string;
|
||||
title?: string;
|
||||
@@ -78,7 +79,7 @@
|
||||
file = undefined;
|
||||
|
||||
try {
|
||||
const response = await fetch(bookUrl);
|
||||
const response = await fetch(fileUrl);
|
||||
if (!response.ok) throw new Error(`The server returned ${response.status} for this file.`);
|
||||
|
||||
file = new File([await response.blob()], filename, { type: 'application/epub+zip' });
|
||||
@@ -135,7 +136,7 @@
|
||||
</Tooltip.Provider>
|
||||
|
||||
<a
|
||||
href="/book/{bookId}"
|
||||
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(bookId) })}
|
||||
class="min-w-0 flex-1 truncate font-serif text-sm hover:underline"
|
||||
{title}
|
||||
>
|
||||
@@ -178,7 +179,10 @@
|
||||
<RotateCcw class="size-4" />
|
||||
Try again
|
||||
</Button>
|
||||
<a href="/book/{bookId}" class={buttonVariants({ variant: 'outline' })}>
|
||||
<a
|
||||
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(bookId) })}
|
||||
class={buttonVariants({ variant: 'outline' })}
|
||||
>
|
||||
Back to book
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import BookCover from './book-cover.svelte';
|
||||
import BookActionsMenu from './book-actions-menu.svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index';
|
||||
@@ -120,7 +121,7 @@
|
||||
? 'cursor-pointer'
|
||||
: ''}"
|
||||
>
|
||||
<a href="/book/{book.id}" class="shrink-0">
|
||||
<a href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })} class="shrink-0">
|
||||
<BookCover {book} height={110} />
|
||||
</a>
|
||||
|
||||
@@ -128,7 +129,7 @@
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<a
|
||||
href="/book/{book.id}"
|
||||
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })}
|
||||
class="line-clamp-2 font-serif text-sm leading-snug hover:underline"
|
||||
>
|
||||
{book.title}
|
||||
@@ -166,7 +167,9 @@
|
||||
{#each book.tags.slice(0, TAG_LIMIT) as tag (tag.id)}
|
||||
<a
|
||||
data-row-control
|
||||
href="/library/{libraryState.activeLibrary?.id}/view?tags={tag.id}"
|
||||
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
|
||||
libraryId: String(libraryState.activeLibrary?.id ?? '')
|
||||
})}?tags={tag.id}"
|
||||
class={badgeVariants({ variant: 'secondary' })}>{tag.name}</a
|
||||
>
|
||||
{/each}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import * as Table from '$lib/components/ui/table/index';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index';
|
||||
@@ -52,7 +53,9 @@
|
||||
|
||||
const visible = $derived(columns.filter((c) => c.on));
|
||||
|
||||
const allSelected = $derived(books.length > 0 && books.every((b) => selectionState.isSelected(b.id)));
|
||||
const allSelected = $derived(
|
||||
books.length > 0 && books.every((b) => selectionState.isSelected(b.id))
|
||||
);
|
||||
|
||||
/** What a record is lacking — the reason this view exists. */
|
||||
function missing(book: Book) {
|
||||
@@ -208,19 +211,26 @@
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<a href="/book/{book.id}"><BookCover {book} height={36} /></a>
|
||||
<a href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })}
|
||||
><BookCover {book} height={36} /></a
|
||||
>
|
||||
</Table.Cell>
|
||||
|
||||
{#each visible as column (column.key)}
|
||||
{#if column.key === 'title'}
|
||||
<Table.Cell class="max-w-[280px] truncate font-serif">
|
||||
<a href="/book/{book.id}" class="hover:underline">{book.title}</a>
|
||||
<a
|
||||
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })}
|
||||
class="hover:underline">{book.title}</a
|
||||
>
|
||||
</Table.Cell>
|
||||
{:else if column.key === 'authors'}
|
||||
<Table.Cell class="max-w-[180px] truncate">
|
||||
{#each book.authors as author (author.id)}
|
||||
<a
|
||||
href="/library/{libraryState.activeLibrary?.id}/view?authors={author.id}"
|
||||
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
|
||||
libraryId: String(libraryState.activeLibrary?.id ?? '')
|
||||
})}?authors={author.id}"
|
||||
class="cs-list hover:underline">{author.name}</a
|
||||
>  
|
||||
{/each}
|
||||
@@ -248,7 +258,7 @@
|
||||
{formatFileSize(totalSize(book))}
|
||||
</Table.Cell>
|
||||
{:else if column.key === 'added'}
|
||||
<Table.Cell class="font-mono text-xs tabular-nums text-muted-foreground">
|
||||
<Table.Cell class="font-mono text-xs text-muted-foreground tabular-nums">
|
||||
{addedOn(book)}
|
||||
</Table.Cell>
|
||||
{:else if column.key === 'progress'}
|
||||
@@ -263,7 +273,7 @@
|
||||
style="width: {Math.round(book.progress.percentage * 100)}%;"
|
||||
></span>
|
||||
</span>
|
||||
<span class="font-mono text-xs tabular-nums text-muted-foreground">
|
||||
<span class="font-mono text-xs text-muted-foreground tabular-nums">
|
||||
{Math.round(book.progress.percentage * 100)}%
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import BookImage from './book-image.svelte';
|
||||
import { Progress } from '$lib/components/ui/progress/index';
|
||||
import { getLibraryState } from '$lib/state/library.svelte';
|
||||
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
|
||||
import type { Book } from '$lib/schema';
|
||||
|
||||
let { book, class: className = '', ...rest }: { book: Book, class?: string} = $props();
|
||||
let { book, class: className = '', ...rest }: { book: Book; class?: string } = $props();
|
||||
|
||||
const selectionState = getBookSelectionState();
|
||||
const libraryState = getLibraryState();
|
||||
@@ -24,7 +25,7 @@
|
||||
<div class="flex w-full flex-shrink-0 flex-col gap-1 {className}">
|
||||
<!-- Book Cover -->
|
||||
<a
|
||||
href="/book/{book.id}"
|
||||
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })}
|
||||
class="group relative aspect-9/12 w-full overflow-hidden rounded-sm shadow-lg drop-shadow-lg transition-all duration-200 {selected
|
||||
? 'ring-2 ring-star'
|
||||
: ''}"
|
||||
@@ -51,7 +52,9 @@
|
||||
</a>
|
||||
|
||||
<!-- Book Title -->
|
||||
<a href="/book/{book.id}" class="text-base-content mt-1 line-clamp-2 w-full font-serif text-sm hover:underline"
|
||||
<a
|
||||
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })}
|
||||
class="text-base-content mt-1 line-clamp-2 w-full font-serif text-sm hover:underline"
|
||||
>{book.title}</a
|
||||
>
|
||||
|
||||
@@ -59,7 +62,9 @@
|
||||
<p class="line-clamp-1 w-full text-xs text-muted-foreground">
|
||||
{#each book.authors as author}
|
||||
<a
|
||||
href="/library/{libraryState.activeLibrary?.id}/view?authors={author.id}"
|
||||
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
|
||||
libraryId: String(libraryState.activeLibrary?.id ?? '')
|
||||
})}?authors={author.id}"
|
||||
class="cs-list hover:underline">{author.name}</a
|
||||
>  
|
||||
{/each}
|
||||
|
||||
@@ -99,6 +99,9 @@ export class BookCollectionState {
|
||||
|
||||
const url = new URL(page.url);
|
||||
url.searchParams.set('view', next);
|
||||
// Not a route to resolve — this is the current URL with one query param
|
||||
// changed, so it is already fully qualified.
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
replaceState(url, page.state);
|
||||
}
|
||||
|
||||
@@ -137,7 +140,8 @@ export class BookCollectionState {
|
||||
url.searchParams.set('orderBy', this.orderBy);
|
||||
url.searchParams.set('sortOrder', this.sortOrder);
|
||||
|
||||
// pushState(url.toString(), {})
|
||||
// Same as setView: the current URL with sort params rewritten, not a route.
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
goto(url.toString());
|
||||
|
||||
this.loadNewBooks();
|
||||
@@ -285,9 +289,7 @@ export class BookCollectionState {
|
||||
|
||||
return wanted.every(([key, values]) => {
|
||||
const current = this.filters[key] ?? [];
|
||||
return (
|
||||
current.length === values.length && values.every((value) => current.includes(value))
|
||||
);
|
||||
return current.length === values.length && values.every((value) => current.includes(value));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolve } from '$app/paths';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto, invalidate } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
@@ -31,7 +32,9 @@ export class LibraryState {
|
||||
this.activeLibrary = this.libraries.find((lib) => lib.id === libraryId) || this.libraries[0];
|
||||
if (browser) {
|
||||
localStorage.setItem('previousLibraryId', this.activeLibrary.id.toString());
|
||||
await goto(`/library/${libraryId}/view`, { invalidate: ['app:libraries'] });
|
||||
await goto(resolve('/(root)/(library)/library/[libraryId]/view', { libraryId: String(libraryId) }), {
|
||||
invalidate: ['app:libraries']
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user