refactor: move duplicates into library settings

This commit is contained in:
2026-08-17 10:32:57 -04:00
parent 315149e8a2
commit 904d8fc76f
6 changed files with 110 additions and 18 deletions
@@ -7,7 +7,7 @@
import { useSidebar } from '$lib/components/ui/sidebar/index.js'; import { useSidebar } from '$lib/components/ui/sidebar/index.js';
import { getBookshelfState } from '$lib/state/bookshelf.svelte'; import { getBookshelfState } from '$lib/state/bookshelf.svelte';
import { getLibraryState } from '$lib/state/library.svelte'; import { getLibraryState } from '$lib/state/library.svelte';
import { CopyCheck, House, LibraryBig, Rows3, ChevronRightIcon } from '@lucide/svelte'; import { House, LibraryBig, Rows3, ChevronRightIcon } from '@lucide/svelte';
const libraryState = getLibraryState(); const libraryState = getLibraryState();
const bookshelfState = getBookshelfState(); const bookshelfState = getBookshelfState();
@@ -43,13 +43,6 @@
path: (id?: number) => path: (id?: number) =>
resolve('/(root)/(library)/library/[libraryId]/view', { libraryId: String(id ?? '') }) resolve('/(root)/(library)/library/[libraryId]/view', { libraryId: String(id ?? '') })
}, },
{
title: 'Duplicates',
icon: CopyCheck,
routeId: '/(root)/(library)/library/[libraryId]/duplicates',
path: (id?: number) =>
resolve('/(root)/(library)/library/[libraryId]/duplicates', { libraryId: String(id ?? '') })
},
{ title: 'Shelves', icon: Rows3, routeId: null, path: () => '#', shelves: [] } { title: 'Shelves', icon: Rows3, routeId: null, path: () => '#', shelves: [] }
]; ];
</script> </script>
@@ -1,9 +1,14 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { getLibraryState } from '$lib/state/library.svelte';
let { children } = $props(); let { children } = $props();
// Set in (root)/+layout.svelte, above this group, so listing the libraries in
// the nav needs no load function.
const libraryState = getLibraryState();
// Route ids rather than paths: resolve() is called in the markup so it stays a // Route ids rather than paths: resolve() is called in the markup so it stays a
// direct call the lint rule can see, and the active check compares route ids. // direct call the lint rule can see, and the active check compares route ids.
// A pathname comparison would miss during SSR, where resolve() returns a // A pathname comparison would miss during SSR, where resolve() returns a
@@ -14,12 +19,28 @@
{ title: 'Libraries', routeId: '/(root)/settings/libraries' }, { title: 'Libraries', routeId: '/(root)/settings/libraries' },
{ title: 'Devices', routeId: '/(root)/settings/devices' } { title: 'Devices', routeId: '/(root)/settings/devices' }
] as const; ] as const;
/**
* Whether a nested library item is the one being looked at.
*
* Both halves are needed: the route id alone is shared by every library, so
* matching on it would light all of them up at once.
*/
function isActiveLibrary(id: number) {
return (
page.route.id?.startsWith('/(root)/settings/libraries/[libraryId]') === true &&
page.params.libraryId === String(id)
);
}
</script> </script>
<div class="flex h-[calc(100vh-var(--header-height)-2rem)] flex-col"> <div class="flex h-[calc(100vh-var(--header-height)-2rem)] flex-col">
<h1 class="font-serif text-xl font-medium tracking-tight">Settings</h1> <h1 class="font-serif text-xl font-medium tracking-tight">Settings</h1>
<div class="mt-6 flex flex-1 gap-8 overflow-hidden"> <div class="mt-6 flex flex-1 gap-8 overflow-hidden">
<nav class="flex w-48 shrink-0 flex-col gap-1"> <!-- Scrolls on its own now that it holds a list that grows with the number
of libraries; the row above it is overflow-hidden, so without this a
long list is clipped rather than reachable. -->
<nav class="flex w-48 shrink-0 flex-col gap-1 overflow-y-auto">
{#each items as item} {#each items as item}
<a <a
href={resolve(item.routeId)} href={resolve(item.routeId)}
@@ -30,6 +51,26 @@
> >
{item.title} {item.title}
</a> </a>
<!-- Libraries is the one item with children: a library's own settings
hang off it, so every library and every section it has stay one
click from here. -->
{#if item.routeId === '/(root)/settings/libraries'}
{#each libraryState.libraries as library (library.id)}
<a
href={resolve('/(root)/settings/libraries/[libraryId]/duplicates', {
libraryId: String(library.id)
})}
class="ml-3 truncate rounded-md border-l px-3 py-1.5 text-sm transition-colors hover:bg-muted {isActiveLibrary(
library.id
)
? 'bg-muted font-medium'
: 'text-muted-foreground'}"
>
{library.name}
</a>
{/each}
{/if}
{/each} {/each}
</nav> </nav>
<div class="flex-1 overflow-auto">{@render children()}</div> <div class="flex-1 overflow-auto">{@render children()}</div>
@@ -0,0 +1,48 @@
<script lang="ts">
import { page } from '$app/state';
import { resolve } from '$app/paths';
import { getLibraryState } from '$lib/state/library.svelte';
let { children } = $props();
// Set in (root)/+layout.svelte, above the settings group, so the libraries are
// already here — this pane needs no load function of its own.
const libraryState = getLibraryState();
const library = $derived(
libraryState.libraries.find((lib) => String(lib.id) === page.params.libraryId)
);
// Duplicates is the only section today. The strip exists so General and a
// danger zone have an obvious place to land; neither is built yet, and an
// empty tab is worse than no tab.
//
// Active state is matched on route id, not pathname, for the reason spelled
// out in settings/+layout.svelte.
const sections = [
{ title: 'Duplicates', routeId: '/(root)/settings/libraries/[libraryId]/duplicates' }
] as const;
</script>
<div class="flex flex-col gap-4">
<div>
<h2 class="text-lg font-semibold">{library?.name ?? 'Library'}</h2>
<p class="text-sm text-muted-foreground">Settings for this library</p>
</div>
<nav class="flex gap-1 border-b">
{#each sections as section (section.routeId)}
<a
href={resolve(section.routeId, { libraryId: page.params.libraryId! })}
class="-mb-px border-b-2 px-3 py-2 text-sm font-medium transition-colors hover:text-foreground {page
.route.id === section.routeId
? 'border-foreground'
: 'border-transparent text-muted-foreground'}"
>
{section.title}
</a>
{/each}
</nav>
{@render children()}
</div>
@@ -0,0 +1,10 @@
import { resolve } from '$app/paths';
import { redirect } from '@sveltejs/kit';
export async function load({ params }) {
// The library pane is its sections; land on the first one.
redirect(
303,
resolve('/(root)/settings/libraries/[libraryId]/duplicates', { libraryId: params.libraryId })
);
}
@@ -70,15 +70,15 @@
} }
</script> </script>
<div class="mx-auto flex w-full max-w-5xl flex-col gap-6 p-4"> <!-- No max width and no padding of its own: the settings pane sets the width and
<div> is the thing that scrolls, so a wrapper that centres inside it only makes the
<h2 class="text-lg font-semibold">Possible duplicates</h2> cards narrower than they need to be. -->
<div class="flex flex-col gap-6 pb-4">
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
Books whose metadata matches. A second edition, a translation and a different scan of one book Books whose metadata matches. A second edition, a translation and a different scan of one book
all look like this, so nothing here has been changed or removed — this is a list to read, not all look like this, so nothing here has been changed or removed — this is a list to read, not a
a problem to fix. problem to fix.
</p> </p>
</div>
{#if data.groups.length === 0} {#if data.groups.length === 0}
<Empty.Root> <Empty.Root>