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

31
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,31 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Build outputs
.svelte-kit
build
dist
# IDE and editor files
.vscode
.idea
# Environment files
.env
.env.*
!.env.example
# Docker files
Dockerfile*
docker-compose*.yml
.dockerignore
# Git
.git
.gitignore
.gitattributes

23
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
frontend/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

13
frontend/.prettierignore Normal file
View File

@@ -0,0 +1,13 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Ignore artifacts:
build
coverage
# Miscellaneous
/static/

16
frontend/.prettierrc Normal file
View File

@@ -0,0 +1,16 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
],
"tailwindStylesheet": "./src/app.css"
}

57
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,57 @@
FROM node:24-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
FROM base AS prod-deps
COPY pnpm-lock.yaml ./
RUN --mount=type=cache,target=/pnpm/store \
pnpm fetch --frozen-lockfile
COPY package.json ./
RUN --mount=type=cache,target=/pnpm/store \
pnpm install --frozen-lockfile --prod --offline
FROM base AS build
COPY pnpm-lock.yaml package.json ./
RUN --mount=type=cache,target=/pnpm/store \
pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
FROM node:24-slim
# Setup a non-root user
RUN groupadd -g 9999 appuser && \
useradd -u 9999 -g appuser -m -d /app -s /sbin/nologin appuser
COPY --from=prod-deps --chown=appuser:appuser /app/node_modules /app/node_modules
COPY --from=build --chown=appuser:appuser /app/build /app/build
USER appuser
ENV ORIGIN=http://localhost:3000
ENV BODY_SIZE_LIMIT=2G
EXPOSE 3000
WORKDIR /app
CMD [ "node", "build" ]

38
frontend/README.md Normal file
View File

@@ -0,0 +1,38 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

16
frontend/components.json Normal file
View File

@@ -0,0 +1,16 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/app.css",
"baseColor": "slate"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry"
}

41
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,41 @@
import prettier from 'eslint-config-prettier';
import { fileURLToPath } from 'node:url';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import { defineConfig } from 'eslint/config';
import globals from 'globals';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default defineConfig(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node }
},
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig
}
}
}
);

16
frontend/jsrepo.json Normal file
View File

@@ -0,0 +1,16 @@
{
"$schema": "https://unpkg.com/jsrepo@2.5.0/schemas/project-config.json",
"repos": ["@ieedan/shadcn-svelte-extras"],
"includeTests": false,
"includeDocs": false,
"watermark": true,
"formatter": "prettier",
"configFiles": {},
"paths": {
"*": "./src/lib/blocks",
"ui": "$lib/components/ui",
"actions": "$lib/actions",
"hooks": "$lib/hooks",
"utils": "$lib/utils"
}
}

55
frontend/package.json Normal file
View File

@@ -0,0 +1,55 @@
{
"name": "chitai-web",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint ."
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.38.0",
"@iconify/svelte": "^5.0.2",
"@internationalized/date": "^3.10.0",
"@lucide/svelte": "^0.544.0",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.48.0",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.16",
"@types/node": "^22.18.12",
"bits-ui": "^2.14.0",
"clsx": "^2.1.1",
"eslint": "^9.38.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.12.5",
"globals": "^16.4.0",
"jsrepo": "^2.5.0",
"openapi-typescript": "^7.10.1",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.6.14",
"svelte": "^5.41.0",
"svelte-check": "^4.3.3",
"tailwind-merge": "^3.3.1",
"tailwind-scrollbar": "^4.0.2",
"tailwind-variants": "^3.1.1",
"tailwindcss": "^4.1.16",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.2",
"vite": "^7.1.12"
},
"dependencies": {
"epubjs": "^0.3.93",
"mode-watcher": "^1.1.0",
"svelte-sonner": "^1.0.5",
"zod": "^4.1.12"
}
}

4715
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
onlyBuiltDependencies:
- esbuild
- '@tailwindcss/oxide'

27
frontend/shell.nix Normal file
View File

@@ -0,0 +1,27 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
buildInputs = with pkgs; [
# Node.js ecosystem
nodejs_24
nodePackages.pnpm
];
shellHook = ''
FRONTEND_PATH=$(dirname ${toString ./.})
cd $FRONTEND_PATH
echo "Node.js development environment"
echo "Node version: $(node --version)"
echo "PNPM version: $(pnpm --version)"
# Install dependencies if package.json exists
if [ -f "package.json" ]; then
echo "Installing npm dependencies..."
pnpm install
fi
# Return to root directory
cd -
'';
}

132
frontend/src/app.css Normal file
View File

@@ -0,0 +1,132 @@
@import 'tailwindcss';
@plugin 'tailwind-scrollbar';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
}
.dark {
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
/* Comma separated list */
.cs-list::after {
content: ',';
}
.cs-list:last-of-type::after {
display: none;
}

21
frontend/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
import type { ApiClient } from '$lib/server/api';
import type { User } from 'lucide-svelte';
declare global {
namespace App {
// interface Error {}
interface Locals {
authToken: string | null;
api: ApiClient;
user: User;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

11
frontend/src/app.html Normal file
View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="overflow-hidden">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,37 @@
import { ApiClient } from '$lib/server/api';
import { validateToken } from '$lib/server/auth';
import { redirect, type Handle } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
const authHandle: Handle = async ({ event, resolve }) => {
// Get auth token from cookies
const authToken = event.cookies.get('authToken');
if (authToken) {
// Validate the token
const api = new ApiClient(authToken);
const user = await validateToken(api);
if (user) {
// Token is valid
event.locals.user = user;
event.locals.authToken = authToken;
event.locals.api = api;
} else {
// Token invalid, clear auth cookie
event.cookies.delete('authToken', { path: '/' });
}
}
return resolve(event);
};
const protectedRoutesHandle: Handle = async ({ event, resolve }) => {
const isProtectedRoute = !event.url.pathname.startsWith('/login');
if (isProtectedRoute && !event.locals.user) throw redirect(303, '/login');
return resolve(event);
};
export const handle = sequence(authHandle, protectedRoutesHandle);

View File

@@ -0,0 +1,75 @@
import { command, form, getRequestEvent, query } from '$app/server';
import { loginSchema, signupSchema } from '$lib/schema/auth';
import { BACKEND_API_URL } from '$lib/server/config';
import { redirect } from '@sveltejs/kit';
export const login = form(loginSchema, async (data, invalid) => {
const { cookies, locals } = getRequestEvent();
// Create URL-encoded form data
const formData = new URLSearchParams();
formData.append('email', data.email);
formData.append('password', data.password);
const response = await fetch(`${BACKEND_API_URL}/access/login`, {
method: 'POST',
body: formData,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
if (!response.ok) {
if (response.status === 401) {
invalid(invalid.email('Invalid login credentials'));
} else {
const message = await response.text();
console.error('Unknown error: ', message);
invalid(invalid.email('An unknown error occurred'));
}
}
const token = await response.json();
cookies.set('authToken', token.access_token, {
path: '/',
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7 // 1 week
});
redirect(303, '/');
});
export const signup = form(signupSchema, async (data, invalid) => {
const response = await fetch(`${BACKEND_API_URL}/access/signup`, {
method: 'POST',
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
if (response.status == 409) {
invalid(invalid.email('Email is already in use by another account'));
} else {
const message = await response.text();
console.error('Unknown error: ', message);
invalid(invalid.email('An unknown error occurred'));
}
}
});
export const logout = command(async () => {
const { cookies } = getRequestEvent();
cookies.delete('authToken', {
path: '/'
});
});
export const getUser = query(async () => {
const { locals } = getRequestEvent();
if (!locals.user) {
redirect(307, '/login');
}
return locals.user;
});

View File

@@ -0,0 +1,16 @@
import { getRequestEvent, query } from '$app/server';
import { authorQuerySchema } from '$lib/schema/author';
import { createQueryParams } from '$lib/utils';
import { error } from '@sveltejs/kit';
export const listAuthors = query(authorQuerySchema, async (data) => {
const { locals } = getRequestEvent();
const params = createQueryParams(data);
const response = await locals.api.get(`/authors?${params.toString()}`);
if (!response.ok) error(500, 'An unkown error occurred');
return await response.json();
});

View File

@@ -0,0 +1,164 @@
import { command, form, getRequestEvent, query } from '$app/server';
import { error } from '@sveltejs/kit';
import {
bookCoverUpload,
booksUpload,
bookIdsSchema,
bookQuerySchema,
deleteBookFilesSchema,
deleteBooksSchema,
editBookMetadataSchema,
updateBookProgressSchema,
type Book,
bookFilesUpload
} from '$lib/schema/index';
import { stringCoerce, type PaginatedResponse } from '$lib/schema/common';
import { createQueryParams } from '$lib/utils';
export const getBook = query(stringCoerce, async (id): Promise<Book> => {
const { locals } = getRequestEvent();
const response = await locals.api.get(`/books/${id}`);
if (!response.ok) {
if (response.status == 404) error(404, 'The book does not exist');
error(500, 'An unkown error occurred');
}
return await response.json();
});
export const listBooks = query(bookQuerySchema, async (data): Promise<PaginatedResponse<Book>> => {
const { locals } = getRequestEvent();
const params = createQueryParams(data);
const response = await locals.api.get(`/books?${params.toString()}`);
if (!response.ok) error(500, 'An unkown error occurred');
return await response.json();
});
export const updateBookMetadata = form(editBookMetadataSchema, async (data): Promise<Book> => {
const { locals } = getRequestEvent();
const response = await locals.api.patch(`/books/${data.book_id}`, data);
if (!response.ok) {
const message = await response.text();
error(response.status, message);
}
return await response.json();
});
export const updateBookCover = form(bookCoverUpload, async ({ book_id, file }) => {
const { locals } = getRequestEvent();
const formData = new FormData();
formData.append('file', file);
const response = await locals.api.putMultipart(`/books/${book_id}/cover`, formData);
if (!response.ok) {
const message = await response.text();
error(response.status, message);
}
return await response.json();
});
export const uploadBooks = form(booksUpload, async ({ library_id, files }) => {
const { locals } = getRequestEvent();
const formData = new FormData();
files.forEach((file) => {
formData.append('files', file);
});
const response = await locals.api.postMultipart(
`/books/fromFiles?library_id=${library_id}`,
formData
);
if (!response.ok) {
const message = await response.text();
error(response.status, message);
}
return await response.json();
});
export const uploadBookFiles = form(bookFilesUpload, async ({ book_id, files }) => {
const { locals } = getRequestEvent();
const formData = new FormData();
files.forEach((file) => {
formData.append('files', file);
});
const response = await locals.api.postMultipart(`/books/${book_id}/files`, formData);
if (!response.ok) {
const message = await response.text();
error(response.status, message);
}
return await response.json();
});
export const deleteBooks = command(deleteBooksSchema, async (data) => {
const { locals } = getRequestEvent();
const params = createQueryParams(data);
const response = await locals.api.delete(`/books?${params.toString()}`);
if (!response.ok) {
const message = await response.text();
error(response.status, message);
}
});
export const deleteBookFiles = command(deleteBookFilesSchema, async ({ book_id, ...data }) => {
const { locals } = getRequestEvent();
const params = createQueryParams(data);
const response = await locals.api.delete(`/books/${book_id}/files?${params.toString()}`);
if (!response.ok) {
const message = await response.text();
error(response.status, message);
}
});
export const updateBookProgress = command(
updateBookProgressSchema,
async ({ book_ids, ...data }) => {
const { locals } = getRequestEvent();
const params = createQueryParams({ book_ids: book_ids });
const response = await locals.api.post(`/books/progress?${params.toString()}`, { ...data });
if (!response.ok) {
const message = await response.text();
error(response.status, message);
}
}
);
export const markBooksAsComplete = command(bookIdsSchema, async (data) => {
const { locals } = getRequestEvent();
const params = createQueryParams(data);
const response = await locals.api.post(`/books/completed?${params.toString()}`, {});
if (!response.ok) {
const message = await response.text();
error(response.status, message);
}
});

View File

@@ -0,0 +1,64 @@
import { command, getRequestEvent, query } from '$app/server';
import { bookshelfCreate, bookshelfQuerySchema, modifyBooksInShelf, type Bookshelf } from '$lib/schema/bookshelf';
import { createQueryParams } from '$lib/utils';
import { error } from '@sveltejs/kit';
export const listBookshelves = query(bookshelfQuerySchema, async (data) => {
const { locals } = getRequestEvent();
const params = createQueryParams(data);
const response = await locals.api.get(`/shelves?${params.toString()}`);
if (!response.ok) {
const message = await response.text();
console.error('An unknown error occurred: ', message);
error(500, 'An unkown error occurred');
}
return await response.json();
});
export const addBooksToShelf = command(modifyBooksInShelf, async ({ shelf_id, ...data }): Promise<Bookshelf> => {
const { locals } = getRequestEvent();
const params = createQueryParams(data);
const response = await locals.api.post(`/shelves/${shelf_id}/books?${params.toString()}`, {});
if (!response.ok) {
const message = await response.text();
error(response.status, message);
}
return await response.json()
});
export const removeBooksFromShelf = command(modifyBooksInShelf, async ({ shelf_id, ...data }): Promise<Bookshelf> => {
const { locals } = getRequestEvent();
const params = createQueryParams(data);
const response = await locals.api.delete(`/shelves/${shelf_id}/books?${params.toString()}`);
if (!response.ok) {
const message = await response.text();
error(response.status, message);
}
return await response.json()
});
export const createBookshelf = command(bookshelfCreate, async (data) => {
const { locals } = getRequestEvent();
const response = await locals.api.post(`/shelves`, data)
if (!response.ok) {
const message = await response.text();
error(response.status, message);
}
return await response.json()
})

View File

@@ -0,0 +1,7 @@
export * from './auth.remote';
export * from './author.remote';
export * from './book.remote';
export * from './bookshelf.remote';
export * from './library.remote';
export * from './publisher.remote';
export * from './tag.remote';

View File

@@ -0,0 +1,30 @@
import { form, getRequestEvent, query } from '$app/server';
import { libraryCreateSchema, libraryQuerySchema } from '$lib/schema/library';
import { createQueryParams } from '$lib/utils';
import { error } from '@sveltejs/kit';
export const listLibraries = query(libraryQuerySchema, async (data) => {
const { locals } = getRequestEvent();
const params = createQueryParams(data);
const response = await locals.api.get(`/libraries?${params.toString()}`);
if (!response.ok) error(500, 'An unkown error occurred');
return await response.json();
});
export const createLibrary = form(libraryCreateSchema, async (data) => {
const { locals } = getRequestEvent();
const response = await locals.api.post(`/libraries`, data);
if (!response.ok) error(500, 'An unknown error occurred');
return await response.json();
});
export const deleteLibrary = query('unchecked', async (data) => {
throw new Error('Not implemented');
});

View File

@@ -0,0 +1,16 @@
import { getRequestEvent, query } from '$app/server';
import { publisherQuerySchema } from '$lib/schema/publisher';
import { createQueryParams } from '$lib/utils';
import { error } from '@sveltejs/kit';
export const listPublishers = query(publisherQuerySchema, async (data) => {
const { locals } = getRequestEvent();
const params = createQueryParams(data);
const response = await locals.api.get(`/publishers?${params.toString()}`);
if (!response.ok) error(500, 'An unkown error occurred');
return await response.json();
});

View File

@@ -0,0 +1,16 @@
import { getRequestEvent, query } from '$app/server';
import { tagQuerySchema } from '$lib/schema/tag';
import { createQueryParams } from '$lib/utils';
import { error } from '@sveltejs/kit';
export const listTags = query(tagQuerySchema, async (data) => {
const { locals } = getRequestEvent();
const params = createQueryParams(data);
const response = await locals.api.get(`/tags?${params.toString()}`);
if (!response.ok) error(500, 'An unkown error occurred');
return await response.json();
});

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve">
<style type="text/css">
.st0{fill:#000000;}
</style>
<g>
<polygon class="st0" points="1.914,293.73 1.914,293.714 1.897,293.657 "/>
<path class="st0" d="M10.027,313.061l-0.12-0.145c-0.008-0.008-0.008-0.008-0.008-0.008L10.027,313.061z"/>
<path class="st0" d="M10.027,223.738l-0.12-0.145c-0.008,0-0.008,0-0.008,0L10.027,223.738z"/>
<path class="st0" d="M1.914,204.423c0,0,0-0.024-0.008-0.04l-0.008-0.024L1.914,204.423z"/>
<path class="st0" d="M512,204.688c0.008-2.275-0.435-4.479-1.15-6.569c0.7-1.93,1.07-3.876,1.078-5.758
c0.016-2.96-0.805-5.822-2.05-8.122l-0.04-0.072c-0.378-0.691-0.893-1.423-1.464-2.155v-55.268
c2.268-3.257,3.619-7.173,3.627-11.378c0-8.138-4.905-15.448-12.408-18.527l-185.36-77.446h-0.539
c-3.892-1.367-7.928-2.187-12.022-2.187c-3.996,0-8.001,0.78-11.852,2.106h-0.482L17.667,128.786l-0.676,0.378l0.016,0.024
c-3.466,1.825-6.031,4.358-7.8,6.81c-3.095,4.302-4.679,8.838-5.83,12.955l-0.008,0.056c-1.728,6.393-2.452,12.882-2.894,18.375
l-0.008,0.048c-0.41,5.516-0.434,10.18-0.466,12.095v0.048v1.022c0.008,6.698,0.378,15.406,1.906,23.786l0.024,0.089
c0.805,4.213,1.938,8.491,3.869,12.657l0.032,0.064l0.007,0.016c0.982,2.051,2.26,4.246,4.029,6.337l0.04,0.048
c0.128,0.153,0.314,0.29,0.459,0.435c-0.37,0.434-0.853,0.852-1.159,1.294c-3.104,4.294-4.679,8.83-5.83,12.938l-0.008,0.04v0.016
c-1.728,6.393-2.452,12.89-2.894,18.382l-0.008,0.04C0.056,262.264,0.032,266.92,0,268.842v0.04v1.029
c0.008,6.707,0.378,15.416,1.914,23.803l0.016,0.064c0.805,4.222,1.938,8.508,3.869,12.673l0.039,0.072
c0.974,2.035,2.252,4.23,4.029,6.344l0.04,0.049c0.322,0.386,0.796,0.732,1.174,1.117c-0.636,0.7-1.367,1.392-1.874,2.091
c-3.104,4.302-4.679,8.838-5.83,12.938l-0.024,0.113l0.016-0.049c-1.728,6.393-2.452,12.891-2.894,18.383l-0.008,0.048
c-0.41,5.516-0.434,10.18-0.466,12.11v0.048v0.997c0.008,6.714,0.378,15.431,1.914,23.819l0.016,0.064
c0.805,4.214,1.938,8.5,3.869,12.657l0.039,0.08c0.974,2.043,2.252,4.23,4.029,6.345l-0.128-0.145l0.169,0.201
c1.696,1.97,4.028,3.876,6.94,5.299l-0.032,0.048l0.056,0.032l0.41,0.233l199.948,82.52l0.458,0.193
c4.431,1.81,9.143,2.734,13.879,2.734c4.648,0,9.304-0.892,13.694-2.686l255.907-103.99v-0.008c2.951-1.19,5.734-3.128,7.865-6.24
l0.008,0.008c0,0,0.008-0.024,0.016-0.04c0.008-0.008,0.016-0.008,0.024-0.024h-0.008c2.018-3.056,2.838-6.272,2.855-9.328
c0.016-2.96-0.805-5.822-2.05-8.122l-0.04-0.072v0.008c-0.378-0.692-0.893-1.431-1.464-2.163v-55.26
c2.268-3.257,3.619-7.189,3.627-11.386c0.008-2.541-0.531-5.01-1.44-7.318c0.917-2.171,1.359-4.382,1.367-6.498
c0.016-2.967-0.805-5.813-2.05-8.13l-0.04-0.072c-0.378-0.684-0.893-1.424-1.464-2.156v-55.252
C510.64,212.802,511.992,208.877,512,204.688z M216.272,469.136L25.852,390.539l-0.716-0.836c-0.595-0.925-1.358-2.686-1.97-4.873
c-0.948-3.273-1.6-7.535-1.994-11.845c-0.394-4.302-0.531-8.636-0.531-12.271v-0.86c0.032-2.806,0.136-10.928,1.294-18.568
c0.547-3.812,1.392-7.487,2.436-10.092l0.218-0.474l191.681,77.76V469.136z M216.272,378.324L43.005,306.789l-17.152-7.06
l-0.716-0.836c-0.595-0.933-1.358-2.686-1.97-4.866c-0.948-3.289-1.6-7.543-1.994-11.852c-0.394-4.302-0.531-8.636-0.531-12.264
v-0.868c0.032-2.814,0.136-10.928,1.294-18.576c0.547-3.804,1.392-7.487,2.436-10.084l0.218-0.474l191.681,77.759V378.324z
M216.272,233.291v3.682v52.036l-175.108-72.3l-15.311-6.297l-0.716-0.844c-0.595-0.925-1.358-2.686-1.97-4.856
c-0.948-3.282-1.6-7.535-1.994-11.854c-0.394-4.31-0.531-8.636-0.531-12.262v-0.869c0.032-2.806,0.136-10.928,1.294-18.568
c0.547-3.812,1.392-7.486,2.436-10.092l0.218-0.466l191.681,77.752V233.291z M487.731,371.313L238.16,472.706l-0.66,0.297
c-1.922,0.756-3.924,1.15-5.934,1.15c-0.716,0-1.447-0.105-2.155-0.217v-61.412c0.941,0.105,1.914,0.161,2.855,0.161
c3.756,0,7.519-0.74,11.033-2.187l0.241-0.104l244.192-99.664V371.313z M487.731,280.156v0.338L238.16,381.895l-0.66,0.29
c-1.922,0.764-3.924,1.15-5.934,1.15c-0.716,0-1.447-0.104-2.155-0.201v-61.42c0.941,0.104,1.914,0.161,2.855,0.161
c3.756,0,7.519-0.74,11.033-2.18l0.241-0.112l171.272-69.896l72.919-29.77V280.156z M487.731,189.345v1.841L238.16,292.58
l-0.66,0.289c-1.922,0.764-3.924,1.158-5.934,1.158c-0.716,0-1.447-0.105-2.155-0.208v-56.145v-5.283
c0.941,0.113,1.914,0.16,2.855,0.16c3.756,0,7.519-0.731,11.033-2.17l0.241-0.105l244.192-99.672V189.345z"/>
<polygon class="st0" points="1.914,384.549 1.914,384.533 1.897,384.484 "/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import * as Field from '$lib/components/ui/field/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
let {
open = $bindable(),
title = 'Delete book?',
deleteFn
}: {
open: boolean;
title?: string;
deleteFn: (deleteFiles: boolean) => {};
} = $props();
const selectedState = getBookSelectionState();
const bookOps = getBookOperationsState();
let deleteFiles = $state(false);
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>
This will delete the book(s) from the database, and optionally delete the files from the
filesystem.
</Dialog.Description>
</Dialog.Header>
<Field.Set>
<Field.Group>
<!-- Delete files checkbox -->
<Field.Field>
<div class="flex items-center gap-2">
<Checkbox bind:checked={deleteFiles} />
<Field.Label class="font-normal">Delete files from the filesystem</Field.Label>
</div>
</Field.Field>
</Field.Group>
</Field.Set>
<!-- Buttons -->
<Dialog.Footer class="ml-auto flex">
<Button variant="outline" onclick={() => (open = false)}>Cancel</Button>
<Button
onclick={async () => {
open = false;
await deleteFn(deleteFiles);
}}
variant="destructive">Delete</Button
>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,161 @@
<script lang="ts">
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';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
import {
displaySize,
FileDropZone,
type FileDropZoneProps
} from '$lib/components/ui/file-drop-zone';
import { X } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { Switch } from '$lib/components/ui/switch/index';
import { tick } from 'svelte';
import { Spinner } from '$lib/components/ui/spinner/index';
import { getLibraryState } from '$lib/state/library.svelte';
import { uploadBooks } from '$lib/api';
import type { Book, PaginatedResponse } from '$lib/schema';
import { goto } from '$app/navigation';
let { open = $bindable() }: { open?: boolean } = $props();
let libraryState = getLibraryState();
$effect(() => {
uploadBooks.fields.library_id.set(libraryState.activeLibrary!.id);
});
let files = $derived(uploadBooks.fields.files.value() ?? []);
let autoUploadOnDrop = $state(true);
let navigateOnUpload = $state(true);
let formEl = $state<HTMLFormElement>();
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => {
uploadBooks.fields.files.set([...Array.from(files), ...uploadedFiles]);
if (autoUploadOnDrop && files.length > 0) {
await tick();
formEl?.requestSubmit();
}
};
const onFileRejected: FileDropZoneProps['onFileRejected'] = async ({ reason, file }) => {
toast.error(`${file.name} failed to upload!`, { description: reason });
};
function navigateToBooks(books: PaginatedResponse<Book>) {
open = false;
let libraryId = books.items[0].library_id;
libraryState.setActive(libraryId);
if (books.items.length === 1) {
goto(`/book/${books.items[0].id}`);
} else {
goto(`/library/${libraryId}/view?orderBy=created_at&sortOrder=desc`);
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content>
{#if uploadBooks.pending}
<div class="flex flex-col items-center gap-4">
<span class="text-lg font-semibold"
>Uploading {uploadBooks.fields.files.value().length} files...</span
>
<Spinner class="scale-150" />
</div>
{:else}
<Dialog.Header>
<Dialog.Title>Upload Books</Dialog.Title>
</Dialog.Header>
<form
{...uploadBooks.enhance(async ({ submit, form }) => {
try {
await submit();
// Check if there are any validation issues
const issues = uploadBooks.fields.allIssues();
if (issues && issues.length > 0) {
return;
}
// Update library book 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([]);
toast.success('Books successfully uploaded!');
if (navigateOnUpload) {
navigateToBooks(uploadBooks.result);
}
} catch (error) {
console.error('Failed to upload book: ', error);
toast.error('Failed to upload books');
}
})}
bind:this={formEl}
enctype="multipart/form-data"
class="flex w-full flex-col gap-2 p-4"
>
<!-- Library select field -->
<Field.Label for="library_id">Select Library</Field.Label>
<NativeSelect.Root {...uploadBooks.fields.library_id.as('select')} class="w-36">
{#each libraryState.libraries as library}
<NativeSelect.Option value={library.id}>
{library.name}
</NativeSelect.Option>
{/each}
</NativeSelect.Root>
<FileDropZone
{onUpload}
{onFileRejected}
directory={true}
accept=".pdf,.epub,.mobi,application/pdf,application/epub+zip,application/x-mobipocket-ebook"
sublabel="Only PDF, EPUB, and MOBI files supported"
/>
<input class="hidden" {...uploadBooks.fields.files.as('file multiple')} />
<div class="flex max-h-[300px] flex-col gap-2 overflow-y-auto">
{#each files as file, idx}
<div class="flex place-items-center justify-between gap-2">
<div class="flex flex-col">
<span>{file.name}</span>
<span class="text-xs text-muted-foreground">{displaySize(file.size)}</span>
</div>
<Button
variant="outline"
size="icon"
onclick={() => {
uploadBooks.fields.files.set([
...Array.from(files).slice(0, idx),
...Array.from(files).slice(idx + 1)
]);
}}
>
<X />
</Button>
</div>
{/each}
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<Switch bind:checked={autoUploadOnDrop} />
<Field.Label for="auto-upload-on-drop">Auto upload on file drop</Field.Label>
<Button type="submit" class="ml-auto w-fit">Upload</Button>
</div>
<div class="flex items-center gap-2">
<Switch bind:checked={navigateOnUpload} />
<Field.Label for="navigate-to-book">Navigate to book on upload</Field.Label>
</div>
</div>
</form>
{/if}
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import { type Book } from '$lib/schema';
import EditCover from './edit-cover.svelte';
import EditFiles from './edit-files.svelte';
import EditMetadata from './edit-metadata.svelte';
let { book, open = $bindable() }: { book?: Book; open: boolean } = $props();
</script>
<Dialog.Root bind:open>
{#if book}
<Dialog.Content class="sm:max-w-xl">
<Tabs.Root value="metadata" class="h-[500px] max-w-xl py-4 md:h-[700px]">
<Tabs.List class="grid w-full grid-cols-3">
<Tabs.Trigger value="metadata">Metadata</Tabs.Trigger>
<Tabs.Trigger value="cover">Cover</Tabs.Trigger>
<Tabs.Trigger value="files">Files</Tabs.Trigger>
</Tabs.List>
<!-- Metadata form -->
<Tabs.Content value="metadata" class="h-full overflow-y-auto pb-1">
<EditMetadata {book} {open} />
</Tabs.Content>
<!-- Cover form -->
<Tabs.Content value="cover">
<EditCover {book} {open} />
</Tabs.Content>
<!-- Add files form -->
<Tabs.Content value="files">
<EditFiles {book} />
</Tabs.Content>
</Tabs.Root>
</Dialog.Content>
{/if}
</Dialog.Root>

View File

@@ -0,0 +1,117 @@
<script lang="ts">
import {
FileDropZone,
type FileDropZoneProps,
displaySize
} from '$lib/components/ui/file-drop-zone/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Field from '$lib/components/ui/field/index.js';
import { Switch } from '$lib/components/ui/switch/index';
import BookImage from '$lib/components/view/book-image.svelte';
import { toast } from 'svelte-sonner';
import { updateBookCover } from '$lib/api';
import type { Book } from '$lib/schema';
import { tick, untrack } from 'svelte';
import { X } from '@lucide/svelte';
let { book, open = $bindable() }: { book: Book; open: boolean } = $props();
let formEl = $state<HTMLFormElement>();
let coverImagePreview = $state(`/api/${book.cover_image}`);
let autoUploadOnDrop = $state(true);
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => {
updateBookCover.fields.file.set(uploadedFiles[0]);
updateCoverPreview();
if (autoUploadOnDrop && updateBookCover.fields.file.value()) {
await tick();
formEl?.requestSubmit();
}
};
function updateCoverPreview() {
const file = updateBookCover.fields.file.value();
if (file && file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onloadend = () => {
coverImagePreview = reader.result;
};
reader.readAsDataURL(file);
}
}
const onFileRejected: FileDropZoneProps['onFileRejected'] = async ({ reason, file }) => {
toast.error(`${file.name} failed to upload!`, { description: reason });
};
$effect(() => {
book;
untrack(() => {
updateBookCover.fields.book_id.set(book.id);
});
});
</script>
<form
bind:this={formEl}
{...updateBookCover.enhance(async ({ submit, form }) => {
try {
await submit();
form.reset();
open = false;
toast.success('Updated book cover!');
} catch (error) {
console.error('Failed to update book cover: ', error);
toast.error('Failed to update cover.');
}
})}
enctype="multipart/form-data"
class="grid grid-cols-[1fr_2fr] gap-4 p-6"
>
<input class="hidden" {...updateBookCover.fields.book_id.as('text')} />
<div class="flex flex-col gap-2">
<BookImage src={coverImagePreview} class="w-64 rounded" />
</div>
<div class="flex flex-col gap-2">
<FileDropZone
{onUpload}
{onFileRejected}
accept=".jpeg,.jpg,.png,.webp,image/*"
label="Only JPEG, PNG, and WEBP images supported"
maxFiles={1}
fileCount={updateBookCover.fields.file.value() ? 1 : 0}
/>
<input class="hidden" {...updateBookCover.fields.file.as('file')} />
<div class="flex flex-col gap-2">
{#if updateBookCover.fields.file.value()}
<div class="flex place-items-center justify-between gap-2">
<div class="flex flex-col">
<span>{updateBookCover.fields.file.value().name}</span>
<span class="text-xs text-muted-foreground"
>{displaySize(updateBookCover.fields.file.value().size)}</span
>
</div>
<Button
variant="outline"
size="icon"
onclick={() => {
updateBookCover.fields.file.set(undefined);
coverImagePreview = `/api/${book.cover_image}`;
}}
>
<X />
</Button>
</div>
{/if}
</div>
<div class="flex flex-row items-center space-x-2">
<Switch bind:checked={autoUploadOnDrop} />
<Field.Label for="auto-upload-on-drop">Auto upload on file drop</Field.Label>
<Button type="submit" class="ml-auto w-fit" disabled={!updateBookCover.fields.file.value()}>Upload</Button>
</div>
</div>
</form>

View File

@@ -0,0 +1,98 @@
<script lang="ts">
import { uploadBookFiles } from '$lib/api';
import {
displaySize,
FileDropZone,
type FileDropZoneProps
} from '$lib/components/ui/file-drop-zone';
import { Button } from '$lib/components/ui/button';
import * as Field from '$lib/components/ui/field/index.js';
import { Switch } from '$lib/components/ui/switch/index';
import { X } from '@lucide/svelte';
import type { Book } from '$lib/schema';
import { tick } from 'svelte';
import { toast } from 'svelte-sonner';
let { book }: { book: Book } = $props();
let files = $derived(uploadBookFiles.fields.files.value() ?? []);
let autoUploadOnDrop = $state(true);
let formEl = $state<HTMLFormElement>();
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => {
uploadBookFiles.fields.files.set([...Array.from(files), ...uploadedFiles]);
if (autoUploadOnDrop && files.length > 0) {
await tick();
formEl?.requestSubmit();
}
};
const onFileRejected: FileDropZoneProps['onFileRejected'] = async ({ reason, file }) => {
toast.error(`${file.name} failed to upload!`, { description: reason });
};
</script>
<form
{...uploadBookFiles.enhance(async ({ submit, form }) => {
try {
await submit();
// Check if there are any validation issues
const issues = uploadBookFiles.fields.allIssues();
if (issues && issues.length > 0) {
return;
}
// Reset the files field
uploadBookFiles.fields.files.set([]);
toast.success('Files successfully added!');
} catch (error) {
console.error('Failed to upload files: ', error);
toast.error('Failed to upload files');
}
})}
bind:this={formEl}
enctype="multipart/form-data"
class="flex w-full flex-col gap-2 p-4"
>
<input {...uploadBookFiles.fields.book_id.as('hidden', book.id)} />
<FileDropZone
{onUpload}
{onFileRejected}
accept=".pdf,.epub,.mobi,application/pdf,application/epub+zip,application/x-mobipocket-ebook"
sublabel="Only PDF, EPUB, and MOBI files supported"
/>
<input class="hidden" {...uploadBookFiles.fields.files.as('file multiple')} />
<div class="flex flex-col gap-2">
{#each files as file, idx}
<div class="flex place-items-center justify-between gap-2">
<div class="flex flex-col">
<span>{file.name}</span>
<span class="text-xs text-muted-foreground">{displaySize(file.size)}</span>
</div>
<Button
variant="outline"
size="icon"
onclick={() => {
uploadBookFiles.fields.files.set([
...Array.from(files).slice(0, idx),
...Array.from(files).slice(idx + 1)
]);
}}
>
<X />
</Button>
</div>
{/each}
</div>
<div class="flex flex-row items-center space-x-2">
<Switch bind:checked={autoUploadOnDrop} />
<Field.Label for="auto-upload-on-drop">Auto upload on file drop</Field.Label>
<Button type="submit" class="ml-auto w-fit">Upload</Button>
</div>
</form>

View File

@@ -0,0 +1,282 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card/index.js';
import * as Field from '$lib/components/ui/field/index.js';
import { TagsInput, type TagsInputProps } from '$lib/components/ui/tags-input/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { toast } from 'svelte-sonner';
import { updateBookMetadata } from '$lib/api';
import type { Book } from '$lib/schema';
import { untrack } from 'svelte';
import { Minus, Plus } from '@lucide/svelte';
let { book, open = $bindable() }: { book: Book; open: boolean } = $props();
let authors = $state(book.authors.map((author) => author.name) || []);
let tags = $state(book.tags.map((tag) => tag.name) || []);
let identifierKeys = $state(Object.keys(book.identifiers));
let identifierValues = $state(Object.values(book.identifiers));
function handleAddIdentifier() {
// Add empty strings to both arrays
identifierKeys = [...identifierKeys, ''];
identifierValues = [...identifierValues, ''];
}
function handleRemoveIdentifier(index: number) {
identifierKeys = identifierKeys.filter((_, i) => i !== index);
identifierValues = identifierValues.filter((_, i) => i !== index);
}
const validateTagsInput: TagsInputProps['validate'] = (val, tags) => {
const transformed = val.trim();
// disallow empties
if (transformed.length === 0) return undefined;
// disallow duplicates
if (tags.find((t) => transformed === t.toLowerCase())) return undefined;
return transformed;
};
// Pre-populate forms
$effect(() => {
book;
untrack(() => {
updateBookMetadata.fields.set({
book_id: book.id.toString(),
title: book.title,
subtitle: book.subtitle || undefined,
authors: authors,
tags: tags,
identifiers: JSON.stringify(
Object.fromEntries(identifierKeys.map((key, i) => [key, identifierValues[i]]))
),
description: book.description || undefined,
publisher: book.publisher?.name || undefined,
published_date: book.published_date || undefined,
series: book?.series?.title || undefined,
series_position: book?.series_position || undefined,
pages: book?.pages || undefined,
language: book?.language || undefined,
edition: book.edition || undefined
});
identifierKeys = Object.keys(book.identifiers);
identifierValues = Object.values(book.identifiers);
});
});
// Keep form data in sync
$effect(() => {
updateBookMetadata.fields.authors.set(authors);
});
$effect(() => {
updateBookMetadata.fields.tags.set(tags);
});
$effect(() => {
updateBookMetadata.fields.identifiers.set(
JSON.stringify(Object.fromEntries(identifierKeys.map((key, i) => [key, identifierValues[i]])))
);
});
</script>
<Card.Root class="w-full ">
<form
{...updateBookMetadata.enhance(async ({ submit, form }) => {
try {
await submit();
// Check if there are any validation issues
const issues = updateBookMetadata.fields.allIssues();
if (issues && issues.length > 0) {
return;
}
open = false;
book = book;
toast.success('Updated book metadata!');
} catch (error) {
console.error('Error occurred updating book metadata: ', error);
toast.error('Failed to update book metadata.');
}
})}
>
<Card.Content>
<Field.Set>
<Field.Group class="flex flex-col">
<!-- Book ID field -->
<Field.Field class="hidden">
<Field.Label for="book_id">Book ID</Field.Label>
<Input {...updateBookMetadata.fields.book_id.as('text')} />
{#each updateBookMetadata.fields.book_id.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<!-- Title field -->
<Field.Field>
<Field.Label for="title">Title</Field.Label>
<Input {...updateBookMetadata.fields.title.as('text')} />
{#each updateBookMetadata.fields.title.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<!-- Subtitle field -->
<Field.Field>
<Field.Label for="subtitle">Subtitle</Field.Label>
<Input {...updateBookMetadata.fields.subtitle.as('text')} />
{#each updateBookMetadata.fields.subtitle.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<div class="grid grid-cols-[3fr_1fr] gap-2">
<!-- Series field -->
<Field.Field>
<Field.Label for="series">Series</Field.Label>
<Input {...updateBookMetadata.fields.series.as('text')} />
{#each updateBookMetadata.fields.series.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<!-- Series position field -->
<Field.Field>
<Field.Label for="series_position">Series position</Field.Label>
<Input {...updateBookMetadata.fields.series_position.as('text')} />
{#each updateBookMetadata.fields.series_position.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
</div>
<!-- Authors field -->
<Field.Field>
<Field.Label for="authors">Authors</Field.Label>
<TagsInput
bind:value={authors}
validate={validateTagsInput}
placeholder="Add an author"
class="min-h-10 p-2 text-sm"
/>
{#each authors as author}
<input class="hidden" {...updateBookMetadata.fields.authors.as('checkbox', author)} />
{/each}
{#each updateBookMetadata.fields.authors.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<!-- Tags field -->
<Field.Field>
<Field.Label for="tags">Tags</Field.Label>
<TagsInput
bind:value={tags}
validate={validateTagsInput}
placeholder="Add a tag"
class="min-h-10 p-2 text-sm"
/>
{#each tags as tag}
<input class="hidden" {...updateBookMetadata.fields.tags.as('checkbox', tag)} />
{/each}
{#each updateBookMetadata.fields.tags.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<!-- Description field -->
<Field.Field>
<Field.Label for="description">Description</Field.Label>
<Textarea {...updateBookMetadata.fields.description.as('text')} />
{#each updateBookMetadata.fields.description.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<!-- Identifier fields -->
<Field.Field>
<Field.Label for="identifiers">Identifiers</Field.Label>
<div class="grid grid-cols-[5fr_10fr_0.5fr] gap-2">
{#each identifierKeys as _, idx}
<Input bind:value={identifierKeys[idx]} placeholder="Identifier..." />
<Input bind:value={identifierValues[idx]} placeholder="Value..." />
<Button variant="outline" size="icon" onclick={() => handleRemoveIdentifier(idx)}>
<Minus />
</Button>
{/each}
<Button variant="outline" onclick={() => handleAddIdentifier()}>
<Plus />
Add Identifier
</Button>
<input {...updateBookMetadata.fields.identifiers.as('text')} class="hidden" />
</div>
</Field.Field>
<!-- Publisher field -->
<div class="grid grid-cols-[2fr_1fr] gap-2">
<Field.Field>
<Field.Label for="publisher">Publisher</Field.Label>
<Input {...updateBookMetadata.fields.publisher.as('text')} />
{#each updateBookMetadata.fields.publisher.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<!-- Published date field -->
<Field.Field>
<Field.Label for="published_date">Date published</Field.Label>
<Input {...updateBookMetadata.fields.published_date.as('date')} />
{#each updateBookMetadata.fields.published_date.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
</div>
<div class="grid grid-cols-[2fr_1fr_1fr] gap-2">
<!-- Pages field -->
<Field.Field>
<Field.Label for="pages">Pages</Field.Label>
<Input {...updateBookMetadata.fields.pages.as('number')} />
{#each updateBookMetadata.fields.pages.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<!-- Language field -->
<Field.Field>
<Field.Label for="language">Language</Field.Label>
<Input {...updateBookMetadata.fields.language.as('text')} />
{#each updateBookMetadata.fields.language.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<!-- Edition field -->
<Field.Field>
<Field.Label for="edition">Edition</Field.Label>
<Input {...updateBookMetadata.fields.edition.as('number')} />
{#each updateBookMetadata.fields.edition.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
</div>
</Field.Group>
</Field.Set>
</Card.Content>
<!-- Submit button -->
<Card.Footer class="flex-col gap-2 pt-6">
<Button type="submit" class="w-full">Save</Button>
</Card.Footer>
</form>
</Card.Root>

View File

@@ -0,0 +1,3 @@
import EditBook from './edit-book.svelte';
export { EditBook as BookEdit };

View File

@@ -0,0 +1,98 @@
<script lang="ts">
import { createLibrary } from '$lib/api/library.remote';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as Field from '$lib/components/ui/field/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Textarea } from '$lib/components/ui/textarea/index';
import { Button } from '$lib/components/ui/button/index.js';
import { toast } from 'svelte-sonner';
import { goto, invalidate, invalidateAll } from '$app/navigation';
import { page } from '$app/state';
import { getLibraryState } from '$lib/state/library.svelte';
let { open = $bindable(false) } = $props();
const libraryState = getLibraryState();
</script>
<Dialog.Root bind:open>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Create a new Library</Dialog.Title>
</Dialog.Header>
<form
{...createLibrary.enhance(async ({ form, submit }) => {
try {
await submit();
// Check if there are any validation issues
const issues = createLibrary.fields.allIssues();
if (issues && issues.length > 0) {
return;
}
const libraryName = createLibrary.fields.name.value();
form.reset();
open = false;
toast.success(`Library '${libraryName}' created.`);
libraryState.addLibrary(createLibrary.result);
} catch (error) {
console.error('Failed to create library: ', error);
toast.error('Failed to create library');
}
})}
class="flex flex-col gap-3"
>
<Field.Set>
<Field.Group>
<!-- Library name -->
<Field.Field>
<Field.Label for="name">Library name</Field.Label>
<Input {...createLibrary.fields.name.as('text')} />
{#each createLibrary.fields.name.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<!-- Description -->
<Field.Field>
<Field.Label for="description">Description</Field.Label>
<Textarea {...createLibrary.fields.description.as('text')} />
{#each createLibrary.fields.description.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<!-- Root Path -->
<Field.Field>
<Field.Label for="root_path">Root Path</Field.Label>
<Input {...createLibrary.fields.root_path.as('text')} />
{#each createLibrary.fields.root_path.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
<Field.Description class="text-xs">
Path the books in this library will be stored
</Field.Description>
</Field.Field>
<!-- Path Template -->
<Field.Field>
<Field.Label for="path_template">Path Template</Field.Label>
<Input {...createLibrary.fields.path_template.as('text')} />
{#each createLibrary.fields.path_template.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
<Field.Description class="text-xs">
The directory structure of your library
</Field.Description>
</Field.Field>
</Field.Group>
</Field.Set>
<Button type="submit" class="ml-auto w-24">Create</Button>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import { login } from '$lib/api/auth.remote';
import * as Card from '$lib/components/ui/card/index.js';
import * as Field from '$lib/components/ui/field/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Button } from '$lib/components/ui/button/index.js';
</script>
<Card.Root class="w-full max-w-sm">
<Card.Header>
<Card.Title>Login to your account</Card.Title>
<Card.Description>Enter your email below to login to your account</Card.Description>
</Card.Header>
<form {...login}>
<Card.Content>
<Field.Set>
<Field.Group>
<!-- Email field -->
<Field.Field>
<Field.Label for="email">Email</Field.Label>
<Input {...login.fields.email.as('email')} />
{#each login.fields.email.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<!-- Password field -->
<Field.Field>
<Field.Label for="password">Password</Field.Label>
<Input {...login.fields.password.as('password')} />
{#each login.fields.password.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
</Field.Group>
</Field.Set>
</Card.Content>
<!-- Submit button -->
<Card.Footer class="flex-col gap-2 pt-6">
<Button type="submit" class="w-full">Login</Button>
</Card.Footer>
</form>
</Card.Root>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
let { open = $bindable(), onSubmit }: { open?: boolean, onSubmit: (name: string) => Promise<undefined> } = $props()
let shelfName = $state('')
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>Create bookshelf</Dialog.Title>
</Dialog.Header>
<div class="flex items-center gap-4">
<Label>Name</Label>
<Input bind:value={shelfName}/>
</div>
<!-- Buttons -->
<Dialog.Footer class="ml-auto flex">
<Button variant="outline" onclick={() => (open = false)}>Cancel</Button>
<Button
onclick={async () => {
await onSubmit(shelfName);
}}
variant="default">Create</Button
>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,81 @@
<script lang="ts">
import { signup, login } from '$lib/api/auth.remote';
import * as Card from '$lib/components/ui/card/index.js';
import * as Field from '$lib/components/ui/field/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { toast } from 'svelte-sonner';
let { tabValue = $bindable() }: { tabValue: string } = $props();
</script>
<Card.Root class="w-full max-w-sm">
<Card.Header>
<Card.Title>Sign up for an account</Card.Title>
<Card.Description>Enter your email below to register for an account</Card.Description>
</Card.Header>
<form
{...signup.enhance(async ({ submit, form }) => {
try {
await submit();
// Check if there are any validation issues
const issues = signup.fields.allIssues();
if (issues && issues.length > 0) {
return;
}
// Move to login tab on success
// TODO: Fix previous errors showing on login form
form.reset();
toast.success('Successfully registered!');
login.fields.set({ email: '', password: '' });
login.validate();
tabValue = 'login';
} catch (error) {
console.error('Unknown error occurred: ', error);
toast.error('Registration failed.');
}
})}
>
<Card.Content>
<Field.Set>
<Field.Group>
<!-- Email field -->
<Field.Field>
<Field.Label for="email">Email</Field.Label>
<Input {...signup.fields.email.as('email')} />
{#each signup.fields.email.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<!-- Password field -->
<Field.Field>
<Field.Label for="password">Password</Field.Label>
<Input {...signup.fields.password.as('password')} />
{#each signup.fields.password.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<!-- Confirm password field -->
<Field.Field>
<Field.Label for="confirmPassword">Confirm Password</Field.Label>
<Input {...signup.fields.confirmPassword.as('password')} />
{#each signup.fields.confirmPassword.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
</Field.Group>
</Field.Set>
</Card.Content>
<!-- Submit button -->
<Card.Footer class="flex-col gap-2 pt-6">
<Button type="submit" class="w-full">Sign Up</Button>
</Card.Footer>
</form>
</Card.Root>

View File

@@ -0,0 +1,73 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 32 32" {...$$props}
><g fill="none"
><path
fill="#01579B"
d="m29.508 25.58l-11.435 5.17c-.706.332-1.44.3-2.116-.09L1.524 23.33c-.413-.265-.536-.652-.51-.922c.025-.27.087-.563.812-.773l1.07-.395l14.48 7.893l10.29-4.206z"
/><path
fill="#F5F5F5"
d="M17.935 29.923a1.99 1.99 0 0 1-1.815-.065l-14.093-7.1a.395.395 0 0 1-.14-.558c.31-.512.88-2.133-.06-3.477l15.916 7.662z"
/><path
fill="#94C6D6"
d="m28.898 24.995l-10.963 4.927c-.362.158-1.085.438-1.918-.122c.658.047 1.12-.225 1.358-.668c.233-.43.163-1.135-.12-1.532c-.172-.24-.635-.623-.837-.838l11.857-4.637c1.05-.433 2.035.215 2.193 1.003c.174.89-.96 1.617-1.57 1.867"
/><path
fill="#01579B"
d="m29.445 21.74l-11.317 5.05a2.67 2.67 0 0 1-2.193-.1l-14.46-7.345a.94.94 0 0 1-.43-.525c-.135-.437.035-.988.548-1.163l15.67 7.988l10.73-4.593z"
/><path
fill="#0091EA"
d="m30.298 22.473l-1.233-.448l-2.54.148l-8.395 3.747a2.67 2.67 0 0 1-2.192-.1L1.475 18.477a.438.438 0 0 1 .117-.82l10.423-4.662c.297-.055.602-.022.88.095l14.872 7.09s2.363 1.617 2.53 2.293"
/><path
fill="#616161"
d="M26.383 22.245s1.565-.613 2.794-.558s1.658.918 1.658.918c-.233-1.058-1.325-1.598-1.325-1.598l-16.25-8.182c-.112-.047-.527-.145-1.165.118c-.515.212-2.198 1-2.198 1z"
/><path
fill="#424242"
d="M30.905 22.805c-.117-.467-.408-.967-.943-1.21c-.704-.317-1.71-.235-2.352.1l-1.227.545v.865l1.552-.69c1.51-.672 2.18.335 2.238.573c.24.967-.225 1.527-1.598 2.157l-2.23 1.005v.87l2.565-1.142c1.135-.455 2.43-1.31 1.995-3.073"
/><path
fill="#01579B"
d="M8.253 22.578L3.935 16.61l.677-.302l4.858 6.675zm5.537 2.75l-.77-.61l13.363-2.728v.438l-1.873.71z"
/><path
fill="#9CCC65"
d="m3.7 11.545l16.878-2.82l7.372 8.118a.689.689 0 0 1-.36 1.15l-17.425 3.575z"
/><path
fill="#689F38"
d="m27.59 17.293l-17.305 3.505l-.055.825l17.36-3.56a.686.686 0 0 0 .427-1.058a.67.67 0 0 1-.427.288m.933 3.782a.49.49 0 0 1-.318.74l-15.93 3.23c-.957.197-1.898-.43-1.982-1.405a1.635 1.635 0 0 1 1.297-1.742l15.32-3.44z"
/><path
fill="#616161"
d="m13.898 20.025l-6.345-9.08l-3.62.957c-.838.833-.525 2.2-.525 2.2s5.542 8.895 6.417 10.033s2.153.96 2.153.96l2.157-.435l-.225-4.025z"
/><path
fill="#424242"
d="m13.898 20.085l-3.048.63c-.832.188-.982.97-.982.97L2.51 11.143s-1.047 1.267-.352 2.345l7.667 10.647c.838 1.192 2.153.97 2.153.97l2.157-.435l-.223-3.945z"
/><path
fill="#B9E4EA"
d="M27.563 20.75a.29.29 0 0 1-.205.405L12.125 24.28c-.957.197-1.635-.438-1.6-1.303c.045-1.092.657-1.555 1.467-1.722L27.3 18.127s-.52.585-.123 1.68c.136.378.28.713.386.943"
/><path
stroke="#424242"
stroke-miterlimit="10"
stroke-width=".518"
d="M11.303 20.925L4.775 11.69"
/><path fill="#424242" d="m11.815 16.987l-8.395-4.23l-.34.67l9.7 4.943z" /><path
fill="#689F38"
d="m27.198 16.008l-.616-.675l-9.457 4.34l-5.31-2.628l.905 1.295l3.518 1.797l2.62-.402z"
/><path
fill="#C62828"
d="m29.505 14.338l-11.432 5.17c-.706.332-1.44.3-2.116-.09l-14.434-7.33c-.413-.265-.536-.653-.51-.923c.025-.27.087-.562.812-.772l.678-.25l14.83 7.277l12.042-4.983z"
/><path
fill="#F5F5F5"
d="M17.933 18.68a1.99 1.99 0 0 1-1.815-.065l-14.093-7.1a.395.395 0 0 1-.14-.558c.31-.512.88-2.132-.06-3.477l15.56 7.915z"
/><path
fill="#94C6D6"
d="M28.895 13.753L17.933 18.68c-.363.157-1.085.438-1.918-.122c.657.047 1.12-.226 1.357-.668c.233-.43.163-1.135-.12-1.532c-.172-.24-.634-.623-.837-.838l11.858-4.637c1.05-.433 2.035.215 2.192 1.002c.175.89-.96 1.617-1.57 1.867"
/><path
fill="#C62828"
d="m29.445 10.498l-11.317 5.05a2.67 2.67 0 0 1-2.193-.1L1.472 8.102a.9.9 0 0 1-.447-.54c-.108-.405.032-.938.565-1.148l13.253-2.807z"
/><path
fill="#F44336"
d="m30.295 11.23l-1.233-.448l-2.54.148l-8.394 3.747a2.67 2.67 0 0 1-2.193-.1L1.472 7.232c-.372-.19-.24-.692.118-.82l10.425-4.66c.297-.055.602-.022.88.095l14.872 7.09s2.36 1.615 2.528 2.293"
/><path
fill="#616161"
d="M26.383 11s1.302-.457 2.532-.402s1.922.762 1.922.762c-.252-1.13-1.325-1.598-1.325-1.598L13.263 1.58c-.112-.048-.527-.145-1.165.117a99 99 0 0 0-2.197 1z"
/><path fill="#424242" d="M27.87 10.465L11.243 2.077l.55-.247l16.91 8.475z" /><path
fill="#424242"
d="M30.903 11.563c-.118-.468-.316-.92-.873-1.155c-.713-.3-1.363-.363-2.422.045l-1.228.545v.865l1.553-.69c.787-.37 1.947-.29 2.237.572c.317.945-.225 1.528-1.598 2.158l-2.23 1.005v.87l2.566-1.143c1.137-.455 2.432-1.31 1.994-3.072"
/></g
></svg
>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -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>

View File

@@ -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} />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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} />

View File

@@ -0,0 +1,27 @@
<script>
import * as Sidebar from '$lib/components/ui/sidebar/index';
let { chapters } = $props();
</script>
<Sidebar.Root>
<Sidebar.Header />
<Sidebar.Content>
<Sidebar.GroupLabel>Chapters</Sidebar.GroupLabel>
<Sidebar.GroupContent>
<Sidebar.Menu>
{#each chapters as chapter}
<Sidebar.MenuItem>
<Sidebar.MenuButton>
{#snippet child({ props })}
<span>{chapter.label}</span>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
</Sidebar.GroupContent>
<Sidebar.Group />
</Sidebar.Content>
<Sidebar.Footer />
</Sidebar.Root>

View File

@@ -0,0 +1,354 @@
<script lang="ts">
// TODO: Add type hints to the rest of this file
import { browser } from '$app/environment';
import { onDestroy, onMount } from 'svelte';
import { Book, Rendition } from 'epubjs';
import '../../../app.css';
import * as Sidebar from '$lib/components/ui/sidebar/index';
import * as Collapsible from '$lib/components/ui/collapsible/index';
import { ChevronDown, ChevronRight, ChevronLeft } from '@lucide/svelte';
import { Spinner } from '$lib/components/ui/spinner/index';
import type { DisplayedLocation } from 'epubjs/types/rendition';
let { bookUrl, bookId, initialProgress = 0, initialEpubLoc = null } = $props();
let epubViewer = $state<HTMLElement>();
let containerWidth = $state(0);
let containerHeight = $state(0);
let isReaderVisible = $state(false);
let hasNextPage = $state(true);
let hasPrevPage = $state(false);
let book: Book | undefined = $state();
let rendition = $state<Rendition>();
let chapters = $state([]);
let isMounted = $state(false);
let currentLocation = $state(initialEpubLoc);
let currentProgress = $state(initialProgress);
let isSidebarOpen = $state(false);
let debounceTimeout = $state<NodeJS.Timeout>();
// Function to update dimensions
function updateDimensions() {
if (epubViewer) {
// Get parent element dimensions
const parent = epubViewer.parentElement;
containerWidth = parent?.clientWidth! * 0.9 - 240;
containerHeight = window.innerHeight * 0.8;
}
}
// Handle window resize
function handleResize() {
updateDimensions();
if (rendition) {
rendition.resize(containerWidth, containerHeight);
}
}
async function handleKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowLeft') {
await prevPage();
} else if (e.key === 'ArrowRight') {
await nextPage();
}
}
async function nextPage() {
if (hasNextPage) await rendition!.next();
}
async function prevPage() {
if (hasPrevPage) await rendition!.prev();
}
async function setUserBookProgress() {
clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(async () => {
await fetch(`/api/books/progress/${bookId}`, {
method: 'POST',
body: JSON.stringify({
progress: currentProgress,
epub_loc: currentLocation,
completed: currentProgress === 1
})
});
}, 3000);
}
const getChapters = async (book: Book) => {
await book.ready;
// Get spine items (basic chapter structure)
const spineItems = book.spine.items.map((item, index) => ({
id: item.idref,
href: item.href,
index: index,
label: item.label || `Chapter ${index + 1}`,
cfi: book.spine.get(index).cfiBase // Get CFI from spine
}));
// Get table of contents for better labels
const toc = await book.loaded.navigation;
// Combine spine items with TOC information
const chapters = toc.toc.map((chapter) => {
const spineItem = spineItems.find((item: any) => item.href === chapter.href);
return {
...spineItem,
label: chapter.label || spineItem?.label,
subitems: chapter.subitems,
href: chapter.href,
cfi: spineItem?.cfi || book.spine.get(chapter.href)?.cfiBase
};
});
return chapters;
};
async function navigateToChapter(chapter: any) {
try {
if (!rendition || !book) return;
// Ensure book is ready
await book.ready;
// Try different navigation methods
if (chapter.href) {
await rendition.display(chapter.href);
} else {
console.error('No valid navigation target found for chapter:', chapter);
}
} catch (error) {
console.error('Error navigating to chapter:', error);
}
}
onMount(async () => {
if (browser) {
// Initial setup
updateDimensions();
try {
// Fetch the EPUB file
const response = await fetch(bookUrl);
const arrayBuffer = await response.arrayBuffer();
// Create book from array buffer
book = new Book();
await book.open(arrayBuffer, 'binary');
// Generate locations if they do not exist in localStorage
let existingLocations = localStorage.getItem(`${bookId}-locations`);
let locations;
if (existingLocations) {
locations = JSON.parse(existingLocations);
book.locations.load(locations);
} else {
locations = await book.locations.generate(1600);
// Save locations to localStorage
localStorage.setItem(`${bookId}-locations`, JSON.stringify(locations));
}
await book.ready;
// Render the book to the viewer element
rendition = book.renderTo('epub-viewer', {
width: containerWidth,
height: containerHeight,
spread: 'auto',
snap: true,
manager: 'continuous',
flow: 'paginated'
});
// Set the key listener on the iframe element
let keyListener = async function (e: any) {
// Left Key
if ((e.keyCode || e.which) == 37) {
await prevPage();
}
// Right Key
if ((e.keyCode || e.which) == 39) {
await nextPage();
}
};
// Add resize listener
window.addEventListener('resize', handleResize);
// Add Key listener
rendition.on('keydown', keyListener);
// Listen to location changes
rendition.on('locationChanged', async (location: DisplayedLocation) => {
if (!location?.start) return;
currentLocation = rendition!.currentLocation().start.cfi;
currentProgress = book?.locations.percentageFromCfi(currentLocation);
await setUserBookProgress();
hasNextPage = !rendition!.location.atEnd;
hasPrevPage = !rendition!.location.atStart;
});
chapters = await getChapters(book);
let initialLocationCfi =
currentLocation || book.locations.cfiFromPercentage(currentProgress);
if (initialLocationCfi) {
await rendition.display(initialLocationCfi);
} else {
await rendition.display();
}
isMounted = true;
isReaderVisible = true;
} catch (error) {
console.error('Error loading EPUB', error);
}
}
});
onDestroy(() => {
if (browser) {
window.removeEventListener('resize', handleResize);
book.destroy();
}
});
</script>
<svelte:document onkeydown={handleKeydown} />
<Sidebar.Provider bind:open={isSidebarOpen}>
<Sidebar.Root class={!isReaderVisible ? 'hidden' : ''}>
<Sidebar.Header />
<Sidebar.Content>
<Sidebar.GroupLabel>Chapters</Sidebar.GroupLabel>
<Sidebar.GroupContent>
<Sidebar.Menu>
{#each chapters as chapter}
{#if chapter.subitems.length > 0}
<Collapsible.Root class="group/collapsible">
<div class="flex w-full items-center gap-1">
<Sidebar.MenuItem class="min-w-0 flex-1">
<Sidebar.MenuButton
class="w-full"
onclick={async () => await navigateToChapter(chapter)}
>
<span class="block truncate" title={chapter.label}>{chapter.label}</span>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Collapsible.Trigger class="flex-shrink-0 p-2">
<ChevronDown
class="h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180"
/>
</Collapsible.Trigger>
</div>
<Collapsible.Content>
<Sidebar.MenuSub>
{#each chapter.subitems as subchapter}
<Sidebar.MenuSubItem class="min-w-0">
<Sidebar.MenuButton
class="w-full"
onclick={async () => await navigateToChapter(subchapter)}
>
<span class="block truncate" title={subchapter.label}
>{subchapter.label}</span
>
</Sidebar.MenuButton>
</Sidebar.MenuSubItem>
{/each}
</Sidebar.MenuSub>
</Collapsible.Content>
</Collapsible.Root>
{:else}
<Sidebar.MenuItem class="min-w-0">
<Sidebar.MenuButton
class="w-full"
onclick={async () => await navigateToChapter(chapter)}
>
<span class="block truncate">{chapter.label}</span>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/if}
{/each}
</Sidebar.Menu>
</Sidebar.GroupContent>
<Sidebar.Group />
</Sidebar.Content>
<Sidebar.Footer />
</Sidebar.Root>
<main class="flex w-full overflow-hidden">
{#if browser}
{#if !isReaderVisible}
<div class="absolute inset-0 flex items-center justify-center">
<Spinner />
</div>
{/if}
<div class="h-full w-full {isReaderVisible ? '' : 'opacity-0'}">
<div class="flex">
<Sidebar.Trigger />
</div>
<div class="mt-[-30px] flex h-full w-full items-center justify-center">
<ChevronLeft
onclick={prevPage}
class={hasPrevPage
? 'text-primary/70 hover:cursor-pointer hover:text-primary'
: 'text-muted'}
/>
<div
id="epub-viewer"
bind:this={epubViewer}
class="h-[{containerHeight}px] w-[{containerWidth}] epub-content mx-8 rounded border-2 p-8 shadow-md"
></div>
<ChevronRight
onclick={nextPage}
class={hasNextPage
? 'text-primary/70 hover:cursor-pointer hover:text-primary'
: 'text-muted'}
/>
</div>
</div>
{/if}
</main>
</Sidebar.Provider>
<style>
.epub-content {
position: relative;
background: white;
}
/* Position separator relative to epub content */
@media (min-width: 1209px) {
.epub-content:after {
/* Calculate position based on content width */
--separator-position: calc(var(--content-width, 100%) / 2);
position: absolute;
width: 1px;
border-right: 1px #000 solid;
height: 90%;
z-index: 1;
left: 50%;
transform: translateX(-50%);
top: 5%;
opacity: 0.15;
box-shadow: -2px 0 15px rgba(0, 0, 0, 1);
content: '';
}
}
</style>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from 'bits-ui';
import { cn, type WithoutChild } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithoutChild<AccordionPrimitive.ContentProps> = $props();
</script>
<AccordionPrimitive.Content
bind:ref
data-slot="accordion-content"
class="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...restProps}
>
<div class={cn('pt-0 pb-4', className)}>
{@render children?.()}
</div>
</AccordionPrimitive.Content>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: AccordionPrimitive.ItemProps = $props();
</script>
<AccordionPrimitive.Item
bind:ref
data-slot="accordion-item"
class={cn('border-b last:border-b-0', className)}
{...restProps}
/>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from 'bits-ui';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { cn, type WithoutChild } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
level = 3,
children,
...restProps
}: WithoutChild<AccordionPrimitive.TriggerProps> & {
level?: AccordionPrimitive.HeaderProps['level'];
} = $props();
</script>
<AccordionPrimitive.Header {level} class="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
bind:ref
class={cn(
'flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon
class="pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200"
/>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
value = $bindable(),
...restProps
}: AccordionPrimitive.RootProps = $props();
</script>
<AccordionPrimitive.Root
bind:ref
bind:value={value as never}
data-slot="accordion"
{...restProps}
/>

View File

@@ -0,0 +1,16 @@
import Root from './accordion.svelte';
import Content from './accordion-content.svelte';
import Item from './accordion-item.svelte';
import Trigger from './accordion-trigger.svelte';
export {
Root,
Content,
Item,
Trigger,
//
Root as Accordion,
Content as AccordionContent,
Item as AccordionItem,
Trigger as AccordionTrigger
};

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { buttonVariants } from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.ActionProps = $props();
</script>
<AlertDialogPrimitive.Action
bind:ref
data-slot="alert-dialog-action"
class={cn(buttonVariants(), className)}
{...restProps}
/>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { buttonVariants } from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.CancelProps = $props();
</script>
<AlertDialogPrimitive.Cancel
bind:ref
data-slot="alert-dialog-cancel"
class={cn(buttonVariants({ variant: 'outline' }), className)}
{...restProps}
/>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import AlertDialogOverlay from './alert-dialog-overlay.svelte';
import { cn, type WithoutChild, type WithoutChildrenOrChild } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
portalProps,
...restProps
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<AlertDialogPrimitive.PortalProps>;
} = $props();
</script>
<AlertDialogPrimitive.Portal {...portalProps}>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
bind:ref
data-slot="alert-dialog-content"
class={cn(
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg',
className
)}
{...restProps}
/>
</AlertDialogPrimitive.Portal>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.DescriptionProps = $props();
</script>
<AlertDialogPrimitive.Description
bind:ref
data-slot="alert-dialog-description"
class={cn('text-sm text-muted-foreground', className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-footer"
class={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-header"
class={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.OverlayProps = $props();
</script>
<AlertDialogPrimitive.Overlay
bind:ref
data-slot="alert-dialog-overlay"
class={cn(
'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.TitleProps = $props();
</script>
<AlertDialogPrimitive.Title
bind:ref
data-slot="alert-dialog-title"
class={cn('text-lg font-semibold', className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props();
</script>
<AlertDialogPrimitive.Trigger bind:ref data-slot="alert-dialog-trigger" {...restProps} />

View File

@@ -0,0 +1,39 @@
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import Trigger from './alert-dialog-trigger.svelte';
import Title from './alert-dialog-title.svelte';
import Action from './alert-dialog-action.svelte';
import Cancel from './alert-dialog-cancel.svelte';
import Footer from './alert-dialog-footer.svelte';
import Header from './alert-dialog-header.svelte';
import Overlay from './alert-dialog-overlay.svelte';
import Content from './alert-dialog-content.svelte';
import Description from './alert-dialog-description.svelte';
const Root = AlertDialogPrimitive.Root;
const Portal = AlertDialogPrimitive.Portal;
export {
Root,
Title,
Action,
Cancel,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
//
Root as AlertDialog,
Title as AlertDialogTitle,
Action as AlertDialogAction,
Cancel as AlertDialogCancel,
Portal as AlertDialogPortal,
Footer as AlertDialogFooter,
Header as AlertDialogHeader,
Trigger as AlertDialogTrigger,
Overlay as AlertDialogOverlay,
Content as AlertDialogContent,
Description as AlertDialogDescription
};

View File

@@ -0,0 +1,49 @@
<script lang="ts" module>
import { type VariantProps, tv } from 'tailwind-variants';
export const badgeVariants = tv({
base: 'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-full border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent',
secondary:
'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent',
destructive:
'bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white',
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground'
}
},
defaultVariants: {
variant: 'default'
}
});
export type BadgeVariant = VariantProps<typeof badgeVariants>['variant'];
</script>
<script lang="ts">
import type { HTMLAnchorAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
href,
class: className,
variant = 'default',
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant;
} = $props();
</script>
<svelte:element
this={href ? 'a' : 'span'}
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</svelte:element>

View File

@@ -0,0 +1,2 @@
export { default as Badge } from './badge.svelte';
export { badgeVariants, type BadgeVariant } from './badge.svelte';

View File

@@ -0,0 +1,83 @@
<script lang="ts" module>
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { type VariantProps, tv } from 'tailwind-variants';
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
outline:
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
accent: 'bg-green-500 hover:bg-green-500/90 dark:text-primary dark:bg-green-500/90'
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
});
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
let {
class: className,
variant = 'default',
size = 'default',
ref = $bindable(null),
href = undefined,
type = 'button',
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? 'link' : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}

View File

@@ -0,0 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants
} from './button.svelte';
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant
};

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-action"
class={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} data-slot="card-content" class={cn('px-6', className)} {...restProps}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn('text-sm text-muted-foreground', className)}
{...restProps}
>
{@render children?.()}
</p>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-footer"
class={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-header"
class={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-title"
class={cn('leading-none font-semibold', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card"
class={cn(
'flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm',
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,25 @@
import Root from './card.svelte';
import Content from './card-content.svelte';
import Description from './card-description.svelte';
import Footer from './card-footer.svelte';
import Header from './card-header.svelte';
import Title from './card-title.svelte';
import Action from './card-action.svelte';
export {
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction
};

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { Checkbox as CheckboxPrimitive } from 'bits-ui';
import CheckIcon from '@lucide/svelte/icons/check';
import MinusIcon from '@lucide/svelte/icons/minus';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
...restProps
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
</script>
<CheckboxPrimitive.Root
bind:ref
data-slot="checkbox"
class={cn(
'peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary',
className
)}
bind:checked
bind:indeterminate
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<div data-slot="checkbox-indicator" class="text-current transition-none">
{#if checked}
<CheckIcon class="size-3.5" />
{:else if indeterminate}
<MinusIcon class="size-3.5" />
{/if}
</div>
{/snippet}
</CheckboxPrimitive.Root>

View File

@@ -0,0 +1,6 @@
import Root from './checkbox.svelte';
export {
Root,
//
Root as Checkbox
};

View File

@@ -0,0 +1,54 @@
<!-- CollapsibleText.svelte -->
<!-- The CollapsibleText component offers a collapsible text area with truncation functionality.
It automatically shortens long texts up to a specified maximum length, ensuring the last
word before the limit is fully visible.
-->
<script lang="ts">
import { cn } from '$lib/utils';
import { Button } from '$lib/components/ui/button/index';
import { fade } from 'svelte/transition';
let { text, maxLength = 150, class: className = '' } = $props();
let isExpanded = $state(false);
// Derived values using runes
const shouldCollapse = $derived(text.length > maxLength);
function truncateText(text: string) {
if (text.length <= maxLength) return text;
// Find the last space before maxLength
const lastSpace = text.slice(0, maxLength).lastIndexOf(' ');
// If no space found, just use maxLength
return lastSpace === -1 ? text.slice(0, maxLength) : text.slice(0, lastSpace);
}
// Find the last word boundary before maxLength
let truncatedText = $derived(truncateText(text));
const displayText = $derived(!shouldCollapse || isExpanded ? text : truncatedText + '...');
function toggleExpand() {
isExpanded = !isExpanded;
}
</script>
<div class={cn('space-y-2', className)}>
<p class="text-sm text-muted-foreground">
{#if shouldCollapse}
<span transition:fade>
{@html displayText}
</span>
{:else}
{@html text}
{/if}
</p>
{#if shouldCollapse}
<Button variant="link" class="h-auto p-0 text-sm" onclick={toggleExpand}>
{isExpanded ? 'Read less' : 'Read more'}
</Button>
{/if}
</div>

View File

@@ -0,0 +1 @@
export { default as CollapsibleText } from './collapsible-text.svelte';

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.ContentProps = $props();
</script>
<CollapsiblePrimitive.Content bind:ref data-slot="collapsible-content" {...restProps} />

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.TriggerProps = $props();
</script>
<CollapsiblePrimitive.Trigger bind:ref data-slot="collapsible-trigger" {...restProps} />

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
let {
ref = $bindable(null),
open = $bindable(false),
...restProps
}: CollapsiblePrimitive.RootProps = $props();
</script>
<CollapsiblePrimitive.Root bind:ref bind:open data-slot="collapsible" {...restProps} />

View File

@@ -0,0 +1,13 @@
import Root from './collapsible.svelte';
import Trigger from './collapsible-trigger.svelte';
import Content from './collapsible-content.svelte';
export {
Root,
Content,
Trigger,
//
Root as Collapsible,
Content as CollapsibleContent,
Trigger as CollapsibleTrigger
};

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import type { Command as CommandPrimitive, Dialog as DialogPrimitive } from 'bits-ui';
import type { Snippet } from 'svelte';
import Command from './command.svelte';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import type { WithoutChildrenOrChild } from '$lib/utils.js';
let {
open = $bindable(false),
ref = $bindable(null),
value = $bindable(''),
title = 'Command Palette',
description = 'Search for a command to run',
portalProps,
children,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.RootProps> &
WithoutChildrenOrChild<CommandPrimitive.RootProps> & {
portalProps?: DialogPrimitive.PortalProps;
children: Snippet;
title?: string;
description?: string;
} = $props();
</script>
<Dialog.Root bind:open {...restProps}>
<Dialog.Header class="sr-only">
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>{description}</Dialog.Description>
</Dialog.Header>
<Dialog.Content class="overflow-hidden p-0" {portalProps}>
<Command
class="**:data-[slot=command-input-wrapper]:h-12 [&_[data-command-group]]:px-2 [&_[data-command-group]:not([hidden])_~[data-command-group]]:pt-0 [&_[data-command-input-wrapper]_svg]:h-5 [&_[data-command-input-wrapper]_svg]:w-5 [&_[data-command-input]]:h-12 [&_[data-command-item]]:px-2 [&_[data-command-item]]:py-3 [&_[data-command-item]_svg]:h-5 [&_[data-command-item]_svg]:w-5"
{...restProps}
bind:value
bind:ref
{children}
/>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Command as CommandPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.EmptyProps = $props();
</script>
<CommandPrimitive.Empty
bind:ref
data-slot="command-empty"
class={cn('py-6 text-center text-sm', className)}
{...restProps}
/>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import { Command as CommandPrimitive, useId } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
heading,
value,
...restProps
}: CommandPrimitive.GroupProps & {
heading?: string;
} = $props();
</script>
<CommandPrimitive.Group
bind:ref
data-slot="command-group"
class={cn('overflow-hidden p-1 text-foreground', className)}
value={value ?? heading ?? `----${useId()}`}
{...restProps}
>
{#if heading}
<CommandPrimitive.GroupHeading class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{heading}
</CommandPrimitive.GroupHeading>
{/if}
<CommandPrimitive.GroupItems {children} />
</CommandPrimitive.Group>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import { Command as CommandPrimitive } from 'bits-ui';
import SearchIcon from '@lucide/svelte/icons/search';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
value = $bindable(''),
...restProps
}: CommandPrimitive.InputProps = $props();
</script>
<div class="flex h-9 items-center gap-2 border-b pr-8 pl-3" data-slot="command-input-wrapper">
<SearchIcon class="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
class={cn(
'flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className
)}
bind:ref
{...restProps}
bind:value
/>
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Command as CommandPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.ItemProps = $props();
</script>
<CommandPrimitive.Item
bind:ref
data-slot="command-item"
class={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Command as CommandPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.LinkItemProps = $props();
</script>
<CommandPrimitive.LinkItem
bind:ref
data-slot="command-item"
class={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Command as CommandPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.ListProps = $props();
</script>
<CommandPrimitive.List
bind:ref
data-slot="command-list"
class={cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', className)}
{...restProps}
/>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Command as CommandPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.SeparatorProps = $props();
</script>
<CommandPrimitive.Separator
bind:ref
data-slot="command-separator"
class={cn('-mx-1 h-px bg-border', className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="command-shortcut"
class={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
{...restProps}
>
{@render children?.()}
</span>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Command as CommandPrimitive } from 'bits-ui';
export type CommandRootApi = CommandPrimitive.Root;
let {
api = $bindable(null),
ref = $bindable(null),
value = $bindable(''),
class: className,
...restProps
}: CommandPrimitive.RootProps & {
api?: CommandRootApi | null;
} = $props();
</script>
<CommandPrimitive.Root
bind:this={api}
bind:value
bind:ref
data-slot="command"
class={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,40 @@
import { Command as CommandPrimitive } from 'bits-ui';
import Root from './command.svelte';
import Dialog from './command-dialog.svelte';
import Empty from './command-empty.svelte';
import Group from './command-group.svelte';
import Item from './command-item.svelte';
import Input from './command-input.svelte';
import List from './command-list.svelte';
import Separator from './command-separator.svelte';
import Shortcut from './command-shortcut.svelte';
import LinkItem from './command-link-item.svelte';
const Loading = CommandPrimitive.Loading;
export {
Root,
Dialog,
Empty,
Group,
Item,
LinkItem,
Input,
List,
Separator,
Shortcut,
Loading,
//
Root as Command,
Dialog as CommandDialog,
Empty as CommandEmpty,
Group as CommandGroup,
Item as CommandItem,
LinkItem as CommandLinkItem,
Input as CommandInput,
List as CommandList,
Separator as CommandSeparator,
Shortcut as CommandShortcut,
Loading as CommandLoading
};

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
</script>
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import XIcon from '@lucide/svelte/icons/x';
import type { Snippet } from 'svelte';
import * as Dialog from './index.js';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
portalProps,
children,
showCloseButton = true,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: DialogPrimitive.PortalProps;
children: Snippet;
showCloseButton?: boolean;
} = $props();
</script>
<Dialog.Portal {...portalProps}>
<Dialog.Overlay />
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
class={cn(
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg',
className
)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close
class="absolute end-4 top-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
{/if}
</DialogPrimitive.Content>
</Dialog.Portal>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.DescriptionProps = $props();
</script>
<DialogPrimitive.Description
bind:ref
data-slot="dialog-description"
class={cn('text-sm text-muted-foreground', className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-footer"
class={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-header"
class={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.OverlayProps = $props();
</script>
<DialogPrimitive.Overlay
bind:ref
data-slot="dialog-overlay"
class={cn(
'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
className
)}
{...restProps}
/>

Some files were not shown because too many files have changed in this diff Show More