Initial commit

This commit is contained in:
hiperman
2025-12-04 00:33:37 -05:00
commit 7ca0a21283
798 changed files with 190424 additions and 0 deletions
@@ -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
> &thinsp;
{/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} />