Initial commit
This commit is contained in:
132
frontend/src/app.css
Normal file
132
frontend/src/app.css
Normal 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
21
frontend/src/app.d.ts
vendored
Normal 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
11
frontend/src/app.html
Normal 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>
|
||||
37
frontend/src/hooks.server.ts
Normal file
37
frontend/src/hooks.server.ts
Normal 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);
|
||||
75
frontend/src/lib/api/auth.remote.ts
Normal file
75
frontend/src/lib/api/auth.remote.ts
Normal 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;
|
||||
});
|
||||
16
frontend/src/lib/api/author.remote.ts
Normal file
16
frontend/src/lib/api/author.remote.ts
Normal 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();
|
||||
});
|
||||
164
frontend/src/lib/api/book.remote.ts
Normal file
164
frontend/src/lib/api/book.remote.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
64
frontend/src/lib/api/bookshelf.remote.ts
Normal file
64
frontend/src/lib/api/bookshelf.remote.ts
Normal 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()
|
||||
})
|
||||
7
frontend/src/lib/api/index.ts
Normal file
7
frontend/src/lib/api/index.ts
Normal 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';
|
||||
30
frontend/src/lib/api/library.remote.ts
Normal file
30
frontend/src/lib/api/library.remote.ts
Normal 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');
|
||||
});
|
||||
16
frontend/src/lib/api/publisher.remote.ts
Normal file
16
frontend/src/lib/api/publisher.remote.ts
Normal 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();
|
||||
});
|
||||
16
frontend/src/lib/api/tag.remote.ts
Normal file
16
frontend/src/lib/api/tag.remote.ts
Normal 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();
|
||||
});
|
||||
51
frontend/src/lib/assets/favicon.svg
Normal file
51
frontend/src/lib/assets/favicon.svg
Normal 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 |
59
frontend/src/lib/components/forms/book-delete.svelte
Normal file
59
frontend/src/lib/components/forms/book-delete.svelte
Normal 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>
|
||||
161
frontend/src/lib/components/forms/books-upload.svelte
Normal file
161
frontend/src/lib/components/forms/books-upload.svelte
Normal 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>
|
||||
39
frontend/src/lib/components/forms/edit-book/edit-book.svelte
Normal file
39
frontend/src/lib/components/forms/edit-book/edit-book.svelte
Normal 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>
|
||||
117
frontend/src/lib/components/forms/edit-book/edit-cover.svelte
Normal file
117
frontend/src/lib/components/forms/edit-book/edit-cover.svelte
Normal 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>
|
||||
@@ -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>
|
||||
282
frontend/src/lib/components/forms/edit-book/edit-metadata.svelte
Normal file
282
frontend/src/lib/components/forms/edit-book/edit-metadata.svelte
Normal 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>
|
||||
3
frontend/src/lib/components/forms/edit-book/index.ts
Normal file
3
frontend/src/lib/components/forms/edit-book/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import EditBook from './edit-book.svelte';
|
||||
|
||||
export { EditBook as BookEdit };
|
||||
98
frontend/src/lib/components/forms/library-create-form.svelte
Normal file
98
frontend/src/lib/components/forms/library-create-form.svelte
Normal 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>
|
||||
46
frontend/src/lib/components/forms/login-form.svelte
Normal file
46
frontend/src/lib/components/forms/login-form.svelte
Normal 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>
|
||||
35
frontend/src/lib/components/forms/shelf-create-dialog.svelte
Normal file
35
frontend/src/lib/components/forms/shelf-create-dialog.svelte
Normal 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>
|
||||
81
frontend/src/lib/components/forms/signup-form.svelte
Normal file
81
frontend/src/lib/components/forms/signup-form.svelte
Normal 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>
|
||||
73
frontend/src/lib/components/icons/UnjsDb0.svelte
Normal file
73
frontend/src/lib/components/icons/UnjsDb0.svelte
Normal 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 |
64
frontend/src/lib/components/layout/app-sidebar.svelte
Normal file
64
frontend/src/lib/components/layout/app-sidebar.svelte
Normal 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>
|
||||
73
frontend/src/lib/components/layout/library-switcher.svelte
Normal file
73
frontend/src/lib/components/layout/library-switcher.svelte
Normal 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} />
|
||||
102
frontend/src/lib/components/layout/nav-main.svelte
Normal file
102
frontend/src/lib/components/layout/nav-main.svelte
Normal 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>
|
||||
137
frontend/src/lib/components/layout/search-form.svelte
Normal file
137
frontend/src/lib/components/layout/search-form.svelte
Normal 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
|
||||
>  
|
||||
{/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>
|
||||
31
frontend/src/lib/components/layout/site-header.svelte
Normal file
31
frontend/src/lib/components/layout/site-header.svelte
Normal 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>
|
||||
33
frontend/src/lib/components/layout/theme-toggle.svelte
Normal file
33
frontend/src/lib/components/layout/theme-toggle.svelte
Normal 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>
|
||||
29
frontend/src/lib/components/layout/upload-button.svelte
Normal file
29
frontend/src/lib/components/layout/upload-button.svelte
Normal 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} />
|
||||
27
frontend/src/lib/components/reader/chapter-sidebar.svelte
Normal file
27
frontend/src/lib/components/reader/chapter-sidebar.svelte
Normal 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>
|
||||
354
frontend/src/lib/components/reader/epub-reader.svelte
Normal file
354
frontend/src/lib/components/reader/epub-reader.svelte
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
16
frontend/src/lib/components/ui/accordion/accordion.svelte
Normal file
16
frontend/src/lib/components/ui/accordion/accordion.svelte
Normal 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}
|
||||
/>
|
||||
16
frontend/src/lib/components/ui/accordion/index.ts
Normal file
16
frontend/src/lib/components/ui/accordion/index.ts
Normal 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
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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} />
|
||||
39
frontend/src/lib/components/ui/alert-dialog/index.ts
Normal file
39
frontend/src/lib/components/ui/alert-dialog/index.ts
Normal 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
|
||||
};
|
||||
49
frontend/src/lib/components/ui/badge/badge.svelte
Normal file
49
frontend/src/lib/components/ui/badge/badge.svelte
Normal 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>
|
||||
2
frontend/src/lib/components/ui/badge/index.ts
Normal file
2
frontend/src/lib/components/ui/badge/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Badge } from './badge.svelte';
|
||||
export { badgeVariants, type BadgeVariant } from './badge.svelte';
|
||||
83
frontend/src/lib/components/ui/button/button.svelte
Normal file
83
frontend/src/lib/components/ui/button/button.svelte
Normal 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}
|
||||
17
frontend/src/lib/components/ui/button/index.ts
Normal file
17
frontend/src/lib/components/ui/button/index.ts
Normal 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
|
||||
};
|
||||
20
frontend/src/lib/components/ui/card/card-action.svelte
Normal file
20
frontend/src/lib/components/ui/card/card-action.svelte
Normal 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>
|
||||
15
frontend/src/lib/components/ui/card/card-content.svelte
Normal file
15
frontend/src/lib/components/ui/card/card-content.svelte
Normal 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>
|
||||
20
frontend/src/lib/components/ui/card/card-description.svelte
Normal file
20
frontend/src/lib/components/ui/card/card-description.svelte
Normal 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>
|
||||
20
frontend/src/lib/components/ui/card/card-footer.svelte
Normal file
20
frontend/src/lib/components/ui/card/card-footer.svelte
Normal 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>
|
||||
23
frontend/src/lib/components/ui/card/card-header.svelte
Normal file
23
frontend/src/lib/components/ui/card/card-header.svelte
Normal 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>
|
||||
20
frontend/src/lib/components/ui/card/card-title.svelte
Normal file
20
frontend/src/lib/components/ui/card/card-title.svelte
Normal 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>
|
||||
23
frontend/src/lib/components/ui/card/card.svelte
Normal file
23
frontend/src/lib/components/ui/card/card.svelte
Normal 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>
|
||||
25
frontend/src/lib/components/ui/card/index.ts
Normal file
25
frontend/src/lib/components/ui/card/index.ts
Normal 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
|
||||
};
|
||||
36
frontend/src/lib/components/ui/checkbox/checkbox.svelte
Normal file
36
frontend/src/lib/components/ui/checkbox/checkbox.svelte
Normal 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>
|
||||
6
frontend/src/lib/components/ui/checkbox/index.ts
Normal file
6
frontend/src/lib/components/ui/checkbox/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import Root from './checkbox.svelte';
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Checkbox
|
||||
};
|
||||
@@ -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>
|
||||
1
frontend/src/lib/components/ui/collapsible-text/index.ts
Normal file
1
frontend/src/lib/components/ui/collapsible-text/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as CollapsibleText } from './collapsible-text.svelte';
|
||||
@@ -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} />
|
||||
@@ -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} />
|
||||
@@ -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} />
|
||||
13
frontend/src/lib/components/ui/collapsible/index.ts
Normal file
13
frontend/src/lib/components/ui/collapsible/index.ts
Normal 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
|
||||
};
|
||||
40
frontend/src/lib/components/ui/command/command-dialog.svelte
Normal file
40
frontend/src/lib/components/ui/command/command-dialog.svelte
Normal 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>
|
||||
17
frontend/src/lib/components/ui/command/command-empty.svelte
Normal file
17
frontend/src/lib/components/ui/command/command-empty.svelte
Normal 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}
|
||||
/>
|
||||
30
frontend/src/lib/components/ui/command/command-group.svelte
Normal file
30
frontend/src/lib/components/ui/command/command-group.svelte
Normal 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>
|
||||
26
frontend/src/lib/components/ui/command/command-input.svelte
Normal file
26
frontend/src/lib/components/ui/command/command-input.svelte
Normal 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>
|
||||
20
frontend/src/lib/components/ui/command/command-item.svelte
Normal file
20
frontend/src/lib/components/ui/command/command-item.svelte
Normal 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}
|
||||
/>
|
||||
@@ -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}
|
||||
/>
|
||||
17
frontend/src/lib/components/ui/command/command-list.svelte
Normal file
17
frontend/src/lib/components/ui/command/command-list.svelte
Normal 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}
|
||||
/>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
28
frontend/src/lib/components/ui/command/command.svelte
Normal file
28
frontend/src/lib/components/ui/command/command.svelte
Normal 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}
|
||||
/>
|
||||
40
frontend/src/lib/components/ui/command/index.ts
Normal file
40
frontend/src/lib/components/ui/command/index.ts
Normal 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
|
||||
};
|
||||
@@ -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} />
|
||||
43
frontend/src/lib/components/ui/dialog/dialog-content.svelte
Normal file
43
frontend/src/lib/components/ui/dialog/dialog-content.svelte
Normal 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>
|
||||
@@ -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}
|
||||
/>
|
||||
20
frontend/src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
20
frontend/src/lib/components/ui/dialog/dialog-footer.svelte
Normal 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>
|
||||
20
frontend/src/lib/components/ui/dialog/dialog-header.svelte
Normal file
20
frontend/src/lib/components/ui/dialog/dialog-header.svelte
Normal 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>
|
||||
20
frontend/src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
20
frontend/src/lib/components/ui/dialog/dialog-overlay.svelte
Normal 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}
|
||||
/>
|
||||
17
frontend/src/lib/components/ui/dialog/dialog-title.svelte
Normal file
17
frontend/src/lib/components/ui/dialog/dialog-title.svelte
Normal 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.TitleProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Title
|
||||
bind:ref
|
||||
data-slot="dialog-title"
|
||||
class={cn('text-lg leading-none font-semibold', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />
|
||||
37
frontend/src/lib/components/ui/dialog/index.ts
Normal file
37
frontend/src/lib/components/ui/dialog/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
|
||||
import Title from './dialog-title.svelte';
|
||||
import Footer from './dialog-footer.svelte';
|
||||
import Header from './dialog-header.svelte';
|
||||
import Overlay from './dialog-overlay.svelte';
|
||||
import Content from './dialog-content.svelte';
|
||||
import Description from './dialog-description.svelte';
|
||||
import Trigger from './dialog-trigger.svelte';
|
||||
import Close from './dialog-close.svelte';
|
||||
|
||||
const Root = DialogPrimitive.Root;
|
||||
const Portal = DialogPrimitive.Portal;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Title,
|
||||
Portal,
|
||||
Footer,
|
||||
Header,
|
||||
Trigger,
|
||||
Overlay,
|
||||
Content,
|
||||
Description,
|
||||
Close,
|
||||
//
|
||||
Root as Dialog,
|
||||
Title as DialogTitle,
|
||||
Portal as DialogPortal,
|
||||
Footer as DialogFooter,
|
||||
Header as DialogHeader,
|
||||
Trigger as DialogTrigger,
|
||||
Overlay as DialogOverlay,
|
||||
Content as DialogContent,
|
||||
Description as DialogDescription,
|
||||
Close as DialogClose
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } 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';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
checked = $bindable(false),
|
||||
indeterminate = $bindable(false),
|
||||
class: className,
|
||||
children: childrenProp,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
bind:ref
|
||||
bind:checked
|
||||
bind:indeterminate
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
class={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus: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",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ checked, indeterminate })}
|
||||
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
{#if indeterminate}
|
||||
<MinusIcon class="size-4" />
|
||||
{:else}
|
||||
<CheckIcon class={cn('size-4', !checked && 'text-transparent')} />
|
||||
{/if}
|
||||
</span>
|
||||
{@render childrenProp?.()}
|
||||
{/snippet}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
sideOffset = 4,
|
||||
portalProps,
|
||||
class: className,
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.ContentProps & {
|
||||
portalProps?: DropdownMenuPrimitive.PortalProps;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Portal {...portalProps}>
|
||||
<DropdownMenuPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-content"
|
||||
{sideOffset}
|
||||
class={cn(
|
||||
'z-50 max-h-(--bits-dropdown-menu-content-available-height) min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 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',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
...restProps
|
||||
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
|
||||
inset?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.GroupHeading
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-group-heading"
|
||||
data-inset={inset}
|
||||
class={cn('px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.GroupProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Group bind:ref data-slot="dropdown-menu-group" {...restProps} />
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.ItemProps & {
|
||||
inset?: boolean;
|
||||
variant?: 'default' | 'destructive';
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Item
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
class={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 data-[variant=destructive]:data-highlighted:text-destructive dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:!text-destructive",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
inset?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
class={cn('px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.RadioGroupProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
bind:ref
|
||||
bind:value
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
import CircleIcon from '@lucide/svelte/icons/circle';
|
||||
import { cn, type WithoutChild } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children: childrenProp,
|
||||
...restProps
|
||||
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
class={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus: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",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ checked })}
|
||||
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
{#if checked}
|
||||
<CircleIcon class="size-2 fill-current" />
|
||||
{/if}
|
||||
</span>
|
||||
{@render childrenProp?.({ checked })}
|
||||
{/snippet}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.SeparatorProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Separator
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-separator"
|
||||
class={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -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<HTMLSpanElement>> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={ref}
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
class={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.SubContentProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
class={cn(
|
||||
'z-50 min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 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',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user