Initial commit
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||
|
||||
import SettingsIcon from '@lucide/svelte/icons/settings';
|
||||
import UnjsDb0 from '$lib/components/icons/UnjsDb0.svelte';
|
||||
|
||||
import NavMain from './nav-main.svelte';
|
||||
import LibrarySwitcher from './library-switcher.svelte';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import { getLibraryState } from '$lib/state/library.svelte';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
collapsible = 'icon',
|
||||
...restProps
|
||||
}: ComponentProps<typeof Sidebar.Root> = $props();
|
||||
|
||||
const libraryState = getLibraryState();
|
||||
|
||||
const header = $derived({
|
||||
title: 'chitai',
|
||||
icon: UnjsDb0,
|
||||
url: `/library/${libraryState.activeLibrary!.id}`
|
||||
});
|
||||
|
||||
const footer = $derived({
|
||||
title: 'Settings',
|
||||
url: '/settings',
|
||||
icon: SettingsIcon,
|
||||
isActive: page.url.pathname.startsWith('/settings')
|
||||
});
|
||||
</script>
|
||||
|
||||
<Sidebar.Root variant="floating" {collapsible} {...restProps} class="ml-1 py-3">
|
||||
<Sidebar.Header class="h-(--header-height)">
|
||||
<Sidebar.MenuButton>
|
||||
{#snippet child({ props })}
|
||||
<a href={header.url} {...props}>
|
||||
<header.icon class="mr-3 scale-175" />
|
||||
<span class="text-xl font-semibold">{header.title}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.Header>
|
||||
<Separator />
|
||||
<Sidebar.Content class="mt-4 mr-2 overflow-x-hidden">
|
||||
<LibrarySwitcher />
|
||||
<NavMain />
|
||||
</Sidebar.Content>
|
||||
<Sidebar.Footer>
|
||||
<Sidebar.MenuButton class="mb-2" isActive={footer.isActive}>
|
||||
{#snippet child({ props })}
|
||||
<a href={footer.url} {...props}>
|
||||
{#if footer.icon}
|
||||
<footer.icon class="scale-125" />
|
||||
{/if}
|
||||
<span class="text-md pl-2">{footer.title}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.Footer>
|
||||
</Sidebar.Root>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
|
||||
import { Badge } from "$lib/components/ui/badge/index.js";
|
||||
import { getLibraryState, LibraryState } from '$lib/state/library.svelte';
|
||||
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import LibraryCreateForm from '../forms/library-create-form.svelte';
|
||||
|
||||
const libraryState = getLibraryState();
|
||||
const sidebar = useSidebar();
|
||||
</script>
|
||||
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuButton
|
||||
{...props}
|
||||
size="lg"
|
||||
class="ml-2 data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<div
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
|
||||
>
|
||||
<p class="text-lg font-semibold">{libraryState.activeLibrary!.name[0]}</p>
|
||||
<!-- <libraryState?.activeLibrary.logo class="size-4" /> -->
|
||||
</div>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="ml-1 truncate font-semibold">
|
||||
{libraryState.activeLibrary!.name}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDownIcon class="mr-2 ml-auto" />
|
||||
</Sidebar.MenuButton>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
class="w-(--bits-dropdown-menu-anchor-width) min-w-56 rounded-lg"
|
||||
align="start"
|
||||
side={sidebar.isMobile ? 'bottom' : 'right'}
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenu.Label class="text-xs text-muted-foreground">Libraries</DropdownMenu.Label>
|
||||
{#each libraryState.libraries as library (library.name)}
|
||||
<DropdownMenu.Item onSelect={() => libraryState.setActive(library.id)} class="gap-2 p-2">
|
||||
<div class="flex size-6 items-center justify-center rounded-md border">
|
||||
<p>{library.name[0]}</p>
|
||||
<!-- <library.logo class="size-3.5 shrink-0" /> -->
|
||||
</div>
|
||||
{library.name}
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="font-semibold ml-auto">
|
||||
{library.total}
|
||||
</Badge>
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={() => libraryState.openLibraryCreateDialog()} class="gap-2 p-2">
|
||||
<div class="flex size-6 items-center justify-center rounded-md border bg-transparent">
|
||||
<PlusIcon class="size-4" />
|
||||
</div>
|
||||
<div class="font-medium text-muted-foreground">Add library</div>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
|
||||
<LibraryCreateForm bind:open={libraryState.createDialogOpen} />
|
||||
@@ -0,0 +1,102 @@
|
||||
<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 { 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();
|
||||
|
||||
let items = $derived(
|
||||
[
|
||||
{
|
||||
title: 'Home',
|
||||
url: `/library/${libraryState.activeLibrary?.id}`,
|
||||
icon: House
|
||||
},
|
||||
{
|
||||
title: 'Library',
|
||||
url: `/library/${libraryState.activeLibrary?.id}/view`,
|
||||
icon: LibraryBig
|
||||
},
|
||||
{
|
||||
title: 'Shelves',
|
||||
url: '#',
|
||||
icon: Rows3,
|
||||
shelves: []
|
||||
}
|
||||
].map((item) => ({
|
||||
...item,
|
||||
isActive: page.url.pathname === item.url
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
|
||||
<Sidebar.Group>
|
||||
<Sidebar.Menu>
|
||||
{#each items as item (item.title)}
|
||||
{#if 'shelves' in item}
|
||||
<Collapsible.Root open={item.isActive} class="group/collapsible">
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuItem {...props}>
|
||||
<Collapsible.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuButton {...props} tooltipContent={item.title} class="h-10">
|
||||
{#if item.icon}
|
||||
<item.icon class="scale-125" />
|
||||
{/if}
|
||||
<span class="text-md ml-2">{item.title}</span>
|
||||
<ChevronRightIcon
|
||||
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
||||
/>
|
||||
</Sidebar.MenuButton>
|
||||
{/snippet}
|
||||
</Collapsible.Trigger>
|
||||
<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={item.isActive} tooltipContent={item.title} class="h-10">
|
||||
{#snippet child({ props })}
|
||||
<a href={item.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>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
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';
|
||||
import { Badge } from '$lib/components/ui/badge/index';
|
||||
import { Spinner } from '$lib/components/ui/spinner/index.js';
|
||||
import SearchIcon from '@lucide/svelte/icons/search';
|
||||
import { listBooks } from '$lib/api';
|
||||
import { getLibraryState } from '$lib/state/library.svelte';
|
||||
import type { PaginatedResponse, Book } from '$lib/schema';
|
||||
import BookImage from '../view/book-image.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
const libraryState = getLibraryState();
|
||||
|
||||
let open = $state(false);
|
||||
let searchString = $state('');
|
||||
let searchResult = $state<PaginatedResponse<Book>>();
|
||||
let debounceTimeout = $state<NodeJS.Timeout | undefined>(undefined);
|
||||
let isLoading = $state(false);
|
||||
|
||||
async function handleInputChange() {
|
||||
if (!searchString || searchString === '') {
|
||||
clearTimeout(debounceTimeout);
|
||||
searchResult = undefined;
|
||||
isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(debounceTimeout);
|
||||
|
||||
isLoading = true;
|
||||
|
||||
debounceTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const results = await listBooks({
|
||||
libraries: [libraryState.activeLibrary!.id],
|
||||
searchString
|
||||
});
|
||||
searchResult = results || [];
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
open = !open;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick(e: any) {
|
||||
open = true;
|
||||
e.target.blur();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:document onkeydown={handleKeydown} />
|
||||
|
||||
<form>
|
||||
<div class="flex w-full max-w-xs flex-col gap-6">
|
||||
<InputGroup.Root>
|
||||
<InputGroup.Input placeholder="Search..." onclick={handleClick} />
|
||||
<InputGroup.Addon>
|
||||
<SearchIcon />
|
||||
</InputGroup.Addon>
|
||||
<InputGroup.Addon align="inline-end">
|
||||
<Kbd.Root>Ctrl</Kbd.Root>+
|
||||
<Kbd.Root>k</Kbd.Root>
|
||||
</InputGroup.Addon>
|
||||
</InputGroup.Root>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Command.Dialog bind:open shouldFilter={false}>
|
||||
<Command.Input
|
||||
placeholder="Search for a book..."
|
||||
oninput={handleInputChange}
|
||||
bind:value={searchString}
|
||||
/>
|
||||
{#if searchString && searchResult?.items.length === 0 && !isLoading}
|
||||
<div class="flex h-24 items-center justify-center">
|
||||
<span class="text-sm">No results found.</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if isLoading}
|
||||
<div class="flex h-24 items-center justify-center">
|
||||
<Spinner class="scale-150" />
|
||||
</div>
|
||||
{:else}
|
||||
<Command.List class="max-h-[600px]">
|
||||
{#if searchResult?.items.length > 0}
|
||||
<Command.Group heading="Books">
|
||||
{#each searchResult?.items as book (book.id)}
|
||||
<Command.Item
|
||||
value={String(book.id)}
|
||||
onSelect={() => {
|
||||
goto(`/book/${book.id}`);
|
||||
open = false;
|
||||
}}
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<div class="hover:bg-base-200 flex gap-4 p-3">
|
||||
<BookImage
|
||||
src="/api/{book.cover_image}"
|
||||
class="w-24 rounded object-cover shadow-lg"
|
||||
/>
|
||||
<div class="flex-top flex flex-col">
|
||||
<span class="text-lg font-medium">{book.title}</span>
|
||||
<span class=" text-md">{book.subtitle}</span>
|
||||
{#if book.authors.length > 0}
|
||||
<span class="line-clamp-1 w-full text-sm text-muted-foreground">
|
||||
by
|
||||
{#each book.authors as author}
|
||||
<a
|
||||
href="/library/{libraryState.activeLibrary!.id}/view?authors={author.id}"
|
||||
class="cs-list hover:underline">{author.name}</a
|
||||
>  
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
<div class="mt-1 ml-[-1.5] flex">
|
||||
{#each book.tags as tag}
|
||||
<Badge class="scale-75">{tag.name}</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Command.Item>
|
||||
{/each}
|
||||
</Command.Group>
|
||||
{/if}
|
||||
</Command.List>
|
||||
{/if}
|
||||
</Command.Dialog>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import SidebarIcon from '@lucide/svelte/icons/sidebar';
|
||||
import SearchForm from './search-form.svelte';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import UploadButton from './upload-button.svelte';
|
||||
import ThemeToggle from './theme-toggle.svelte';
|
||||
|
||||
const sidebar = Sidebar.useSidebar();
|
||||
</script>
|
||||
|
||||
<header class="sticky top-0 z-50 flex w-full items-center border-b bg-background">
|
||||
<div class="my-2 flex h-(--header-height) w-full items-center gap-2 px-4">
|
||||
<div class="flex w-full justify-between">
|
||||
<div class="flex gap-3">
|
||||
<Button class="size-8" variant="ghost" size="icon" onclick={sidebar.toggle}>
|
||||
<SidebarIcon />
|
||||
</Button>
|
||||
<Separator orientation="vertical" class="mr-2 h-(--header-height) border" />
|
||||
</div>
|
||||
|
||||
<SearchForm />
|
||||
|
||||
<div class="flex w-24 items-center gap-4">
|
||||
<UploadButton />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { toggleMode, mode } from 'mode-watcher';
|
||||
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
import { buttonVariants } from '$lib/components/ui/button/button.svelte';
|
||||
import Sun from '@lucide/svelte/icons/sun';
|
||||
import Moon from '@lucide/svelte/icons/moon';
|
||||
|
||||
let { class: className = '' }: { class?: string } = $props();
|
||||
</script>
|
||||
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger
|
||||
onclick={toggleMode}
|
||||
class="{buttonVariants({ variant: 'ghost', size: 'icon' })} scale-110 {className}"
|
||||
>
|
||||
{#if mode.current === 'light'}
|
||||
<Moon class="scale-110" />
|
||||
{:else}
|
||||
<Sun class="scale-110" />
|
||||
{/if}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
{#if mode.current === 'light'}
|
||||
<p>Dark Mode</p>
|
||||
{:else}
|
||||
<p>Light Mode</p>
|
||||
{/if}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script>
|
||||
import Upload from '@lucide/svelte/icons/upload';
|
||||
import { buttonVariants } from '$lib/components/ui/button/button.svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index';
|
||||
import BooksUpload from '$lib/components/forms/books-upload.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
|
||||
|
||||
const bookOps = getBookOperationsState();
|
||||
</script>
|
||||
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger
|
||||
onclick={async () => {
|
||||
bookOps.uploadDialogOpen = true;
|
||||
}}
|
||||
class="{buttonVariants({ variant: 'ghost', size: 'icon' })} scale-110"
|
||||
>
|
||||
<Upload class="scale-110" />
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>Upload Book</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
|
||||
<BooksUpload bind:open={bookOps.uploadDialogOpen} />
|
||||
Reference in New Issue
Block a user