Merge pull request #129 from maxdorninger/enhance-frontend

Enhance frontend
This commit is contained in:
Maximilian Dorninger
2025-08-11 22:04:10 +02:00
committed by GitHub
239 changed files with 7075 additions and 3184 deletions

View File

@@ -38,10 +38,6 @@ class PublicMovieFile(MovieFile):
downloaded: bool = False
class PublicMovie(Movie):
downloaded: bool = False
class MovieRequestBase(BaseModel):
min_quality: Quality
wanted_quality: Quality
@@ -87,6 +83,11 @@ class MovieTorrent(BaseModel):
usenet: bool
class PublicMovie(Movie):
downloaded: bool = False
torrents: list[MovieTorrent] = []
class RichMovieTorrent(BaseModel):
movie_id: MovieId
name: str

View File

@@ -259,8 +259,10 @@ class MovieService:
:return: A public movie.
"""
movie = self.movie_repository.get_movie_by_id(movie_id=movie_id)
torrents = self.get_torrents_for_movie(movie=movie).torrents
public_movie = PublicMovie.model_validate(movie)
public_movie.downloaded = self.is_movie_downloaded(movie_id=movie.id)
public_movie.torrents = torrents
return public_movie
def get_movie_by_id(self, movie_id: MovieId) -> Movie:

View File

@@ -163,5 +163,6 @@ class PublicShow(BaseModel):
ended: bool = False
continuous_download: bool = False
library: str
seasons: list[PublicSeason]

View File

@@ -1,45 +1,18 @@
import prettier from 'eslint-config-prettier';
// eslint.config.js
import js from '@eslint/js';
import { includeIgnoreFile } from '@eslint/compat';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import { fileURLToPath } from 'node:url';
import ts from 'typescript-eslint';
import unusedImports from 'eslint-plugin-unused-imports';
import svelteConfig from './svelte.config.js';
import { fileURLToPath } from 'node:url';
import { includeIgnoreFile } from '@eslint/compat';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default ts.config(
{
plugins: {
'unused-imports': unusedImports
},
rules: {
'@typescript-eslint/no-unused-vars': 'off',
'unused-imports/no-unused-imports': 'error',
'unused-imports/no-unused-vars': [
'warn',
{
vars: 'all',
varsIgnorePattern: '^_',
args: 'after-used',
argsIgnorePattern: '^_'
}
],
'sort-imports': [
'error',
{
ignoreDeclarationSort: true
}
]
}
},
includeIgnoreFile(gitignorePath),
includeIgnoreFile(gitignorePath, 'Imported .gitignore patterns'),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs['flat/recommended'],
prettier,
...svelte.configs['flat/prettier'],
...svelte.configs.recommended,
{
languageOptions: {
globals: {
@@ -49,12 +22,32 @@ export default ts.config(
}
},
{
files: ['**/*.svelte'],
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
// See more details at: https://typescript-eslint.io/packages/parser/
languageOptions: {
parserOptions: {
parser: ts.parser
projectService: true,
extraFileExtensions: ['.svelte'], // Add support for additional file extensions, such as .svelte
parser: ts.parser,
// Specify a parser for each language, if needed:
// parser: {
// ts: ts.parser,
// js: espree, // Use espree for .js files (add: import espree from 'espree')
// typescript: ts.parser
// },
// We recommend importing and specifying svelte.config.js.
// By doing so, some rules in eslint-plugin-svelte will automatically read the configuration and adjust their behavior accordingly.
// While certain Svelte settings may be statically loaded from svelte.config.js even if you dont specify it,
// explicitly specifying it ensures better compatibility and functionality.
svelteConfig
}
}
},
{
rules: {
// Override or add rule settings here, such as:
// 'svelte/rule-name': 'error'
}
}
);

5337
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,43 +16,61 @@
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@exodus/schemasafe": "^1.3.0",
"@fontsource/fira-mono": "^5.0.0",
"@lucide/svelte": "^0.482.0",
"@neoconfetti/svelte": "^2.0.0",
"@sinclair/typebox": "^0.34.38",
"@sveltejs/adapter-auto": "^6.0.2",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/enhanced-img": "^0.6.1",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@sveltejs/kit": "^2.27.3",
"@sveltejs/vite-plugin-svelte": "^6.1.1",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/table-core": "^8.21.3",
"@typeschema/class-validator": "^0.2.0",
"@vinejs/vine": "^1.8.0",
"arktype": "^2.1.20",
"autoprefixer": "^10.4.20",
"bits-ui": "^1.4.8",
"bits-ui": "^1.8.0",
"class-validator": "^0.14.2",
"clsx": "^2.1.1",
"embla-carousel-svelte": "^8.6.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^2.46.1",
"eslint-plugin-svelte": "^3.11.0",
"formsnap": "^2.0.0-next.1",
"globals": "^15.14.0",
"joi": "^17.13.3",
"mode-watcher": "^1.0.7",
"paneforge": "^1.0.0-next.6",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.10",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"prettier-plugin-tailwindcss": "^0.6.14",
"superstruct": "^2.0.2",
"svelte": "^5.38.0",
"svelte-check": "^4.3.1",
"svelte-sonner": "^0.3.28",
"tailwind-merge": "^3.2.0",
"sveltekit-superforms": "^2.27.1",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^0.2.1",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^6.0.0"
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.6",
"typescript": "^5.9.2",
"typescript-eslint": "^8.39.1",
"valibot": "^0.42.1",
"vaul-svelte": "^1.0.0-next.7",
"vite": "^7.1.1",
"yup": "^1.7.0",
"zod": "^3.25.76"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.806.0",
"@sveltejs/adapter-node": "^5.2.12",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-svelte": "^8.6.0",
"eslint-plugin-unused-imports": "^4.1.4",
"lucide-svelte": "^0.507.0",
"sharp": "^0.34.1",

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

View File

@@ -1,68 +1,113 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import 'tailwindcss';
@import 'tw-animate-css';
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 72.2% 50.6%;
--destructive-foreground: 0 0% 98%;
--ring: 240 10% 3.9%;
--radius: 0.5rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
@custom-variant dark (&:is(.dark *));
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--ring: 240 4.9% 83.9%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--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.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--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.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@theme inline {
/* Radius (for rounded-*) */
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
/* Colors */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-ring: var(--ring);
--color-radius: var(--radius);
--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 {

View File

@@ -6,6 +6,7 @@
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import type { MetaDataProviderSearchResult } from '$lib/types.js';
import { SvelteURLSearchParams } from 'svelte/reactivity';
const apiUrl = env.PUBLIC_API_URL;
let loading = $state(false);
@@ -17,7 +18,7 @@
async function addMedia() {
loading = true;
const endpoint = isShow ? '/tv/shows' : '/movies';
const urlParams = new URLSearchParams();
const urlParams = new SvelteURLSearchParams();
urlParams.append(isShow ? 'show_id' : 'movie_id', String(result.external_id));
urlParams.append('metadata_provider', result.metadata_provider);
const urlString = `${apiUrl}${endpoint}?${urlParams.toString()}`;
@@ -61,7 +62,7 @@
</div>
{/if}
</Card.Content>
<Card.Footer class="flex flex-col items-start gap-2 rounded-b-lg border-t bg-card p-4">
<Card.Footer class="bg-card flex flex-col items-start gap-2 rounded-b-lg border-t p-4">
<Button
class="w-full font-semibold"
disabled={result.added || loading}

View File

@@ -5,6 +5,7 @@
import { Label } from '$lib/components/ui/label';
import { toast } from 'svelte-sonner';
import { Badge } from '$lib/components/ui/badge/index.js';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import type { PublicIndexerQueryResult } from '$lib/types.js';
import { getFullyQualifiedMediaName } from '$lib/utils';
@@ -24,7 +25,7 @@
let filePathSuffix: string = $state('');
async function downloadTorrent(result_id: string) {
const urlParams = new URLSearchParams();
const urlParams = new SvelteURLSearchParams();
urlParams.append('public_indexer_result_id', result_id);
if (filePathSuffix !== '') {
urlParams.append('override_file_path_suffix', filePathSuffix);
@@ -72,7 +73,7 @@
torrentsError = null;
torrents = [];
const urlParams = new URLSearchParams();
const urlParams = new SvelteURLSearchParams();
if (override) {
urlParams.append('search_query_override', queryOverride);
}
@@ -165,14 +166,14 @@
<Select.Item value="360P">360p</Select.Item>
</Select.Content>
</Select.Root>
<p class="text-sm text-muted-foreground">
<p class="text-muted-foreground text-sm">
This is necessary to differentiate between versions of the same movie, for example a
1080p and a 4K version.
</p>
<Label for="file-suffix-display"
>The files will be saved in the following directory:</Label
>
<p class="text-sm text-muted-foreground" id="file-suffix-display">
<p class="text-muted-foreground text-sm" id="file-suffix-display">
{@render saveDirectoryPreview(movie, filePathSuffix)}
</p>
</div>
@@ -200,7 +201,7 @@
Search
</Button>
</div>
<p class="text-sm text-muted-foreground">
<p class="text-muted-foreground text-sm">
The custom query will override the default search string like "A Minecraft Movie
(2025)".
</p>
@@ -212,7 +213,7 @@
placeholder="1080P"
type="text"
/>
<p class="text-sm text-muted-foreground">
<p class="text-muted-foreground text-sm">
This is necessary to differentiate between versions of the same movie, for example a
1080p and a 4K version.
</p>
@@ -220,7 +221,7 @@
<Label for="file-suffix-display"
>The files will be saved in the following directory:</Label
>
<p class="text-sm text-muted-foreground" id="file-suffix-display">
<p class="text-muted-foreground text-sm" id="file-suffix-display">
{@render saveDirectoryPreview(movie, filePathSuffix)}
</p>
</div>
@@ -243,6 +244,7 @@
<Table.Head>Title</Table.Head>
<Table.Head>Size</Table.Head>
<Table.Head>Seeders</Table.Head>
<Table.Head>Score</Table.Head>
<Table.Head>Indexer Flags</Table.Head>
<Table.Head class="text-right">Actions</Table.Head>
</Table.Row>
@@ -253,8 +255,9 @@
<Table.Cell class="max-w-[300px] font-medium">{torrent.title}</Table.Cell>
<Table.Cell>{(torrent.size / 1024 / 1024 / 1024).toFixed(2)}GB</Table.Cell>
<Table.Cell>{torrent.seeders}</Table.Cell>
<Table.Cell>{torrent.score}</Table.Cell>
<Table.Cell>
{#each torrent.flags as flag}
{#each torrent.flags as flag (flag)}
<Badge variant="outline">{flag}</Badge>
{/each}
</Table.Cell>
@@ -274,6 +277,8 @@
</Table.Body>
</Table.Root>
</div>
{:else}
<p>No torrents found!</p>
{/if}
</div>
</Dialog.Content>

View File

@@ -4,6 +4,7 @@
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { toast } from 'svelte-sonner';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import type { PublicIndexerQueryResult } from '$lib/types.js';
import {
@@ -29,7 +30,7 @@
let filePathSuffix: string = $state('');
async function downloadTorrent(result_id: string) {
const urlParams = new URLSearchParams();
const urlParams = new SvelteURLSearchParams();
urlParams.append('public_indexer_result_id', result_id);
urlParams.append('show_id', show.id);
if (filePathSuffix !== '') {
@@ -82,7 +83,7 @@
torrentsError = null;
torrents = [];
const urlParams = new URLSearchParams();
const urlParams = new SvelteURLSearchParams();
urlParams.append('show_id', show.id);
if (override) {
urlParams.append('search_query_override', queryOverride);
@@ -125,19 +126,6 @@
isLoadingTorrents = false;
}
}
$effect(() => {
if (show?.id) {
console.log('selectedSeasonNumber changed:', selectedSeasonNumber);
getTorrents(selectedSeasonNumber).then((fetchedTorrents) => {
if (!isLoadingTorrents) {
torrents = fetchedTorrents;
} else if (fetchedTorrents.length > 0 || torrentsError) {
torrents = fetchedTorrents;
}
});
}
});
</script>
{#snippet saveDirectoryPreview(
@@ -168,14 +156,33 @@
<Label for="season-number"
>Enter a season number from 1 to {show.seasons.at(-1).number}</Label
>
<Input
type="number"
class="max-w-sm"
id="season-number"
bind:value={selectedSeasonNumber}
max={show.seasons.at(-1).number}
/>
<p class="text-sm text-muted-foreground">
<div class="flex w-full max-w-sm items-center space-x-2">
<Input
type="number"
class="max-w-sm"
id="season-number"
bind:value={selectedSeasonNumber}
max={show.seasons.at(-1).number}
/>
<Button
variant="secondary"
onclick={async () => {
isLoadingTorrents = true;
torrentsError = null;
torrents = [];
try {
torrents = await getTorrents(selectedSeasonNumber, false);
} catch (error) {
console.log(error);
} finally {
isLoadingTorrents = false;
}
}}
>
Search
</Button>
</div>
<p class="text-muted-foreground text-sm">
Enter the season's number you want to search for. The first, usually 1, or the last
season number usually yield the most season packs. Note that only Seasons which are
listed in the "Seasons" cell will be imported!
@@ -192,18 +199,18 @@
<Select.Item value="360P">360p</Select.Item>
</Select.Content>
</Select.Root>
<p class="text-sm text-muted-foreground">
<p class="text-muted-foreground text-sm">
This is necessary to differentiate between versions of the same season/show, for
example a 1080p and a 4K version of a season.
</p>
<Label for="file-suffix-display"
>The files will be saved in the following directory:</Label
>
<p class="text-sm text-muted-foreground" id="file-suffix-display">
<p class="text-muted-foreground text-sm" id="file-suffix-display">
{@render saveDirectoryPreview(show, filePathSuffix)}
</p>
{:else}
<p class="text-sm text-muted-foreground">
<p class="text-muted-foreground text-sm">
No season information available for this show.
</p>
{/if}
@@ -233,7 +240,7 @@
Search
</Button>
</div>
<p class="text-sm text-muted-foreground">
<p class="text-muted-foreground text-sm">
The custom query will override the default search string like "The Simpsons Season 3".
Note that only Seasons which are listed in the "Seasons" cell will be imported!
</p>
@@ -245,7 +252,7 @@
bind:value={filePathSuffix}
placeholder="1080P"
/>
<p class="text-sm text-muted-foreground">
<p class="text-muted-foreground text-sm">
This is necessary to differentiate between versions of the same season/show, for
example a 1080p and a 4K version of a season.
</p>
@@ -253,11 +260,11 @@
<Label for="file-suffix-display"
>The files will be saved in the following directory:</Label
>
<p class="text-sm text-muted-foreground" id="file-suffix-display">
<p class="text-muted-foreground text-sm" id="file-suffix-display">
{@render saveDirectoryPreview(show, filePathSuffix)}
</p>
{:else}
<p class="text-sm text-muted-foreground">
<p class="text-muted-foreground text-sm">
No season information available for this show.
</p>
{/if}
@@ -283,6 +290,7 @@
<Table.Head>Usenet</Table.Head>
<Table.Head>Seeders</Table.Head>
<Table.Head>Age</Table.Head>
<Table.Head>Score</Table.Head>
<Table.Head>Indexer Flags</Table.Head>
<Table.Head>Seasons</Table.Head>
<Table.Head class="text-right">Actions</Table.Head>
@@ -298,8 +306,9 @@
<Table.Cell
>{torrent.usenet ? formatSecondsToOptimalUnit(torrent.age) : 'N/A'}</Table.Cell
>
<Table.Cell>{torrent.score}</Table.Cell>
<Table.Cell>
{#each torrent.flags as flag}
{#each torrent.flags as flag (flag)}
<Badge variant="outline">{flag}</Badge>
{/each}
</Table.Cell>

View File

@@ -9,6 +9,7 @@
import { onMount } from 'svelte';
import { env } from '$env/dynamic/public';
import { toast } from 'svelte-sonner';
import { SvelteURLSearchParams } from 'svelte/reactivity';
const apiUrl = env.PUBLIC_API_URL;
@@ -58,7 +59,7 @@
const endpoint =
mediaType === 'tv' ? `/tv/shows/${media.id}/library` : `/movies/${media.id}/library`;
const urlParams = new URLSearchParams();
const urlParams = new SvelteURLSearchParams();
urlParams.append('library', selectedLabel);
const urlString = `${apiUrl}${endpoint}?${urlParams.toString()}`;
try {
@@ -97,7 +98,7 @@
role="combobox"
aria-expanded={open}
>
{selectedLabel || 'Select Library'}
Select Library
<ChevronsUpDownIcon class="opacity-50" />
</Button>
{/snippet}

View File

@@ -0,0 +1,175 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { goto } from '$app/navigation';
import { env } from '$env/dynamic/public';
import { toast } from 'svelte-sonner';
import { base } from '$app/paths';
import * as Alert from '$lib/components/ui/alert/index.js';
import AlertCircleIcon from '@lucide/svelte/icons/alert-circle';
import LoadingBar from '$lib/components/loading-bar.svelte';
import { SvelteURLSearchParams } from 'svelte/reactivity';
const apiUrl = env.PUBLIC_API_URL;
let {
oauthProvider
}: {
oauthProvider: {
oauth_name: string;
};
} = $props();
let email = $state('');
let password = $state('');
let errorMessage = $state('');
let isLoading = $state(false);
async function handleLogin(event: Event) {
event.preventDefault();
isLoading = true;
errorMessage = '';
const formData = new SvelteURLSearchParams();
formData.append('username', email);
formData.append('password', password);
try {
const response = await fetch(apiUrl + '/auth/cookie/login', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: formData.toString(),
credentials: 'include'
});
if (response.ok) {
console.log('Login successful!');
console.log('Received User Data: ', response);
errorMessage = 'Login successful! Redirecting...';
toast.success(errorMessage);
goto(base + '/dashboard');
} else {
let errorText = await response.text();
try {
const errorData = JSON.parse(errorText);
errorMessage = errorData.message || 'Login failed. Please check your credentials.';
} catch {
errorMessage = errorText || 'Login failed. Please check your credentials.';
}
toast.error(errorMessage);
console.error('Login failed:', response.status, errorText);
}
} catch (error) {
console.error('Login request failed:', error);
errorMessage = 'An error occurred during the login request.';
toast.error(errorMessage);
} finally {
isLoading = false;
}
}
async function handleOauth() {
try {
const response = await fetch(
apiUrl + '/auth/cookie/' + oauthProvider.oauth_name + '/authorize?scopes=email',
{
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
}
);
if (response.ok) {
let result = await response.json();
console.log(
'Redirecting to OAuth provider:',
oauthProvider.oauth_name,
'url: ',
result.authorization_url
);
toast.success('Redirecting to ' + oauthProvider.oauth_name + ' for authentication...');
window.location = result.authorization_url;
} else {
let errorText = await response.text();
toast.error(errorMessage);
console.error('Login failed:', response.status, errorText);
}
} catch (error) {
console.error('Login request failed:', error);
errorMessage = 'An error occurred during the login request.';
toast.error(errorMessage);
}
}
</script>
<Card.Root class="mx-auto max-w-sm">
<Card.Header>
<Card.Title class="text-2xl">Login</Card.Title>
<Card.Description>Enter your email below to log in to your account</Card.Description>
</Card.Header>
<Card.Content>
<form class="grid gap-4" onsubmit={handleLogin}>
<div class="grid gap-2">
<Label for="email">Email</Label>
<Input
bind:value={email}
id="email"
placeholder="m@example.com"
required
type="email"
autocomplete="email"
/>
</div>
<div class="grid gap-2">
<div class="flex items-center">
<Label for="password">Password</Label>
<a class="ml-auto inline-block text-sm underline" href="{base}/login/forgot-password">
Forgot your password?
</a>
</div>
<Input
bind:value={password}
id="password"
required
type="password"
autocomplete="current-password"
/>
</div>
{#if errorMessage}
<Alert.Root variant="destructive">
<AlertCircleIcon class="size-4" />
<Alert.Title>Error</Alert.Title>
<Alert.Description>{errorMessage}</Alert.Description>
</Alert.Root>
{/if}
{#if isLoading}
<LoadingBar />
{/if}
<Button class="w-full" disabled={isLoading} type="submit">Login</Button>
</form>
{#await oauthProvider}
<LoadingBar />
{:then result}
{#if result.oauth_name != null}
<div
class="after:border-border relative mt-2 text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t"
>
<span class="bg-background text-muted-foreground relative z-10 px-2">
Or continue with
</span>
</div>
<Button class="mt-2 w-full" onclick={() => handleOauth()} variant="outline"
>Login with {result.oauth_name}</Button
>
{/if}
{/await}
<div class="mt-4 text-center text-sm">
<Button href="{base}/login/signup/" variant="link">Don't have an account? Sign up</Button>
</div>
</Card.Content>
</Card.Root>

View File

@@ -1,265 +0,0 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { goto } from '$app/navigation';
import { env } from '$env/dynamic/public';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import { toast } from 'svelte-sonner';
import LoadingBar from '$lib/components/loading-bar.svelte';
import { base } from '$app/paths';
const apiUrl = env.PUBLIC_API_URL;
let { oauthProvider } = $props();
let oauthProviderName = $derived(oauthProvider.oauth_name);
let email = $state('');
let password = $state('');
let errorMessage = $state('');
let isLoading = $state(false);
let tabValue = $state('login');
async function handleLogin(event: Event) {
event.preventDefault();
isLoading = true;
errorMessage = '';
const formData = new URLSearchParams();
formData.append('username', email);
formData.append('password', password);
try {
const response = await fetch(apiUrl + '/auth/cookie/login', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: formData.toString(),
credentials: 'include'
});
if (response.ok) {
console.log('Login successful!');
console.log('Received User Data: ', response);
errorMessage = 'Login successful! Redirecting...';
toast.success(errorMessage);
goto(base + '/dashboard');
} else {
let errorText = await response.text();
try {
const errorData = JSON.parse(errorText);
errorMessage = errorData.message || 'Login failed. Please check your credentials.';
} catch {
errorMessage = errorText || 'Login failed. Please check your credentials.';
}
toast.error(errorMessage);
console.error('Login failed:', response.status, errorText);
}
} catch (error) {
console.error('Login request failed:', error);
errorMessage = 'An error occurred during the login request.';
toast.error(errorMessage);
} finally {
isLoading = false;
}
}
async function handleSignup(event: Event) {
event.preventDefault();
isLoading = true;
errorMessage = '';
try {
const response = await fetch(apiUrl + '/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: email,
password: password
}),
credentials: 'include'
});
if (response.ok) {
console.log('Registration successful!');
console.log('Received User Data: ', response);
tabValue = 'login'; // Switch to login tab after successful registration
errorMessage = 'Registration successful! Please login.';
toast.success(errorMessage);
} else {
let errorText = await response.text();
try {
const errorData = JSON.parse(errorText);
errorMessage = errorData.message || 'Registration failed. Please check your credentials.';
} catch {
errorMessage = errorText || 'Registration failed. Please check your credentials.';
}
toast.error(errorMessage);
console.error('Registration failed:', response.status, errorText);
}
} catch (error) {
console.error('Registration request failed:', error);
errorMessage = 'An error occurred during the Registration request.';
toast.error(errorMessage);
} finally {
isLoading = false;
}
}
async function handleOauth() {
try {
const response = await fetch(
apiUrl + '/auth/cookie/' + oauthProviderName + '/authorize?scopes=email',
{
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
}
);
if (response.ok) {
let result = await response.json();
console.log(
'Redirecting to OAuth provider:',
oauthProviderName,
'url: ',
result.authorization_url
);
toast.success('Redirecting to ' + oauthProviderName + ' for authentication...');
window.location = result.authorization_url;
} else {
let errorText = await response.text();
toast.error(errorMessage);
console.error('Login failed:', response.status, errorText);
}
} catch (error) {
console.error('Login request failed:', error);
errorMessage = 'An error occurred during the login request.';
toast.error(errorMessage);
}
}
</script>
{#snippet oauthLogin()}
{#await oauthProvider}
<LoadingBar />
{:then result}
{#if result.oauth_name != null}
<div
class="relative mt-2 text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border"
>
<span class="relative z-10 bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
<Button class="mt-2 w-full" onclick={() => handleOauth()} variant="outline"
>Login with {result.oauth_name}</Button
>
{/if}
{/await}
{/snippet}
<Tabs.Root value={tabValue}>
<Tabs.Content value="login">
<Card.Root class="mx-auto max-w-sm">
<Card.Header>
<Card.Title class="text-2xl">Login</Card.Title>
<Card.Description>Enter your email below to log in to your account</Card.Description>
</Card.Header>
<Card.Content>
<form class="grid gap-4" onsubmit={handleLogin}>
<div class="grid gap-2">
<Label for="email">Email</Label>
<Input
bind:value={email}
id="email"
placeholder="m@example.com"
required
type="email"
/>
</div>
<div class="grid gap-2">
<div class="flex items-center">
<Label for="password">Password</Label>
<a class="ml-auto inline-block text-sm underline" href="{base}/login/forgot-password">
Forgot your password?
</a>
</div>
<Input bind:value={password} id="password" required type="password" />
</div>
{#if errorMessage}
<p class="text-sm text-red-500">{errorMessage}</p>
{/if}
<Button class="w-full" disabled={isLoading} type="submit">
{#if isLoading}
Logging in...
{:else}
Login
{/if}
</Button>
</form>
{@render oauthLogin()}
<div class="mt-4 text-center text-sm">
<Button onclick={() => (tabValue = 'register')} variant="link">
Don't have an account? Sign up
</Button>
</div>
</Card.Content>
</Card.Root>
</Tabs.Content>
<Tabs.Content value="register">
<Card.Root class="mx-auto max-w-sm">
<Card.Header>
<Card.Title class="text-2xl">Sign up</Card.Title>
<Card.Description>Enter your email and password below to sign up.</Card.Description>
</Card.Header>
<Card.Content>
<form class="grid gap-4" onsubmit={handleSignup}>
<div class="grid gap-2">
<Label for="email2">Email</Label>
<Input
bind:value={email}
id="email2"
placeholder="m@example.com"
required
type="email"
/>
</div>
<div class="grid gap-2">
<div class="flex items-center">
<Label for="password2">Password</Label>
</div>
<Input bind:value={password} id="password2" required type="password" />
</div>
{#if errorMessage}
<p class="text-sm text-red-500">{errorMessage}</p>
{/if}
<Button class="w-full" disabled={isLoading} type="submit">
{#if isLoading}
Signing up...
{:else}
Sign up
{/if}
</Button>
</form>
{@render oauthLogin()}
<div class="mt-4 text-center text-sm">
<Button onclick={() => (tabValue = 'login')} variant="link"
>Already have an account? Login
</Button>
</div>
</Card.Content>
</Card.Root>
</Tabs.Content>
</Tabs.Root>

View File

@@ -12,7 +12,7 @@
<source srcset="{apiUrl}/static/image/{media.id}.webp" type="image/webp" />
<img
alt="{getFullyQualifiedMediaName(media)}'s Poster Image"
class="aspect-9/16 center h-auto w-full rounded-lg object-cover"
class="h-full w-full rounded-lg object-cover"
src="{apiUrl}/static/image/{media.id}.jpeg"
/>
</picture>

View File

@@ -39,7 +39,7 @@
</DropdownMenu.Trigger>
<DropdownMenu.Content
align="end"
class="w-[var(--bits-dropdown-menu-anchor-width)] min-w-56 rounded-lg"
class="w-(--bits-dropdown-menu-anchor-width) min-w-56 rounded-lg"
side={sidebar.isMobile ? 'bottom' : 'right'}
sideOffset={4}
>

View File

@@ -26,7 +26,7 @@
<Skeleton class="h-[70vh] w-full" />
<Skeleton class="h-[70vh] w-full" />
{:else}
{#each media.slice(0, 3) as mediaItem}
{#each media.slice(0, 3) as mediaItem (mediaItem.external_id)}
<AddMediaCard {isShow} result={mediaItem} />
{/each}
{/if}

View File

@@ -80,7 +80,7 @@
</Dialog.Header>
<div class="grid gap-4 py-4">
<!-- Min Quality Select -->
<div class="grid grid-cols-[1fr,3fr] items-center gap-4 md:grid-cols-[100px,1fr]">
<div class="grid grid-cols-[1fr_3fr] items-center gap-4 md:grid-cols-[100px_1fr]">
<Label class="text-right" for="min-quality">Min Quality</Label>
<Select.Root bind:value={minQuality} type="single">
<Select.Trigger class="w-full" id="min-quality">
@@ -95,7 +95,7 @@
</div>
<!-- Wanted Quality Select -->
<div class="grid grid-cols-[1fr,3fr] items-center gap-4 md:grid-cols-[100px,1fr]">
<div class="grid grid-cols-[1fr_3fr] items-center gap-4 md:grid-cols-[100px_1fr]">
<Label class="text-right" for="wanted-quality">Wanted Quality</Label>
<Select.Root bind:value={wantedQuality} type="single">
<Select.Trigger class="w-full" id="wanted-quality">

View File

@@ -97,7 +97,7 @@
</Dialog.Header>
<div class="grid gap-4 py-4">
<!-- Season Select -->
<div class="grid grid-cols-[1fr,3fr] items-center gap-4 md:grid-cols-[100px,1fr]">
<div class="grid grid-cols-[1fr_3fr] items-center gap-4 md:grid-cols-[100px_1fr]">
<Label class="text-right" for="season">Season</Label>
<Select.Root bind:value={selectedSeasonsIds} type="multiple">
<Select.Trigger class="w-full" id="season">
@@ -120,7 +120,7 @@
</div>
<!-- Min Quality Select -->
<div class="grid grid-cols-[1fr,3fr] items-center gap-4 md:grid-cols-[100px,1fr]">
<div class="grid grid-cols-[1fr_3fr] items-center gap-4 md:grid-cols-[100px_1fr]">
<Label class="text-right" for="min-quality">Min Quality</Label>
<Select.Root bind:value={minQuality} type="single">
<Select.Trigger class="w-full" id="min-quality">
@@ -135,7 +135,7 @@
</div>
<!-- Wanted Quality Select -->
<div class="grid grid-cols-[1fr,3fr] items-center gap-4 md:grid-cols-[100px,1fr]">
<div class="grid grid-cols-[1fr_3fr] items-center gap-4 md:grid-cols-[100px_1fr]">
<Label class="text-right" for="wanted-quality">Wanted Quality</Label>
<Select.Root bind:value={wantedQuality} type="single">
<Select.Trigger class="w-full" id="wanted-quality">

View File

@@ -0,0 +1,161 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { env } from '$env/dynamic/public';
import { toast } from 'svelte-sonner';
import * as Alert from '$lib/components/ui/alert/index.js';
import AlertCircleIcon from '@lucide/svelte/icons/alert-circle';
import LoadingBar from '$lib/components/loading-bar.svelte';
import CheckCircle2Icon from '@lucide/svelte/icons/check-circle-2';
import { base } from '$app/paths';
const apiUrl = env.PUBLIC_API_URL;
let email = $state('');
let password = $state('');
let errorMessage = $state('');
let successMessage = $state('');
let isLoading = $state(false);
let confirmPassword = $state('');
let {
oauthProvider
}: {
oauthProvider: {
oauth_name: string;
};
} = $props();
async function handleSignup(event: Event) {
event.preventDefault();
isLoading = true;
errorMessage = '';
successMessage = '';
try {
const response = await fetch(apiUrl + '/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: email,
password: password
}),
credentials: 'include'
});
if (response.ok) {
console.log('Registration successful!');
console.log('Received User Data: ', response);
successMessage = 'Registration successful! Please login.';
toast.success(successMessage);
} else {
let errorText = await response.text();
try {
const errorData = JSON.parse(errorText);
errorMessage = errorData.message || 'Registration failed. Please check your credentials.';
} catch {
errorMessage = errorText || 'Registration failed. Please check your credentials.';
}
toast.error(errorMessage);
console.error('Registration failed:', response.status, errorText);
}
} catch (error) {
console.error('Registration request failed:', error);
errorMessage = 'An error occurred during the Registration request.';
toast.error(errorMessage);
} finally {
isLoading = false;
}
}
function handleOauth() {
// Implement OAuth logic or leave as stub if not needed
}
</script>
<Card.Root class="mx-auto max-w-sm">
<Card.Header>
<Card.Title class="text-xl">Sign Up</Card.Title>
<Card.Description>Enter your information to create an account</Card.Description>
</Card.Header>
<Card.Content>
<form class="grid gap-4" onsubmit={handleSignup}>
<div class="grid gap-2">
<Label for="email">Email</Label>
<Input
bind:value={email}
id="email"
placeholder="m@example.com"
required
type="email"
autocomplete="email"
/>
</div>
<div class="grid gap-2">
<Label for="password">Password</Label>
<Input
bind:value={password}
id="password"
required
type="password"
autocomplete="new-password"
/>
</div>
<div class="grid gap-2">
<Label for="password">Confirm Password</Label>
<Input
bind:value={confirmPassword}
id="confirm-password"
required
type="password"
autocomplete="new-password"
/>
</div>
{#if errorMessage}
<Alert.Root variant="destructive">
<AlertCircleIcon class="size-4" />
<Alert.Title>Error</Alert.Title>
<Alert.Description>{errorMessage}</Alert.Description>
</Alert.Root>
{/if}
{#if successMessage}
<Alert.Root variant="default">
<CheckCircle2Icon class="size-4" />
<Alert.Title>Success</Alert.Title>
<Alert.Description>{successMessage}</Alert.Description>
</Alert.Root>
{/if}
{#if isLoading}
<LoadingBar />
{/if}
<Button
class="w-full"
disabled={isLoading || password !== confirmPassword || password === ''}
type="submit">Create an account</Button
>
</form>
{#await oauthProvider}
<LoadingBar />
{:then result}
{#if result.oauth_name != null}
<div
class="after:border-border relative mt-2 text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t"
>
<span class="bg-background text-muted-foreground relative z-10 px-2">
Or continue with
</span>
</div>
<Button class="mt-2 w-full" onclick={() => handleOauth()} variant="outline"
>Login with {result.oauth_name}</Button
>
{/if}
{/await}
<div class="mt-4 text-center text-sm">
<Button href="{base}/login/" variant="link">Already have an account? Login</Button>
</div>
</Card.Content>
</Card.Root>

View File

@@ -25,10 +25,10 @@
</Table.Row>
</Table.Header>
<Table.Body>
{#each torrents as torrent}
{#each torrents as torrent (torrent.id)}
<Table.Row>
<Table.Cell class="font-medium">
{isShow ? torrent.torrent_title : torrent.title}
{torrent.torrent_title}
</Table.Cell>
{#if isShow}
<Table.Cell>
@@ -41,7 +41,7 @@
<Table.Cell class="font-medium">
{getTorrentQualityString(torrent.quality)}
</Table.Cell>
<Table.Cell class="font-medium">
<Table.Cell>
{torrent.file_path_suffix}
</Table.Cell>
<Table.Cell>

View File

@@ -13,12 +13,12 @@
<AccordionPrimitive.Content
bind:ref
class={cn(
'overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down',
'data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm',
className
)}
{...restProps}
>
<div class="pb-4 pt-0">
<div class="pt-0 pb-4">
{@render children?.()}
</div>
</AccordionPrimitive.Content>

View File

@@ -9,4 +9,4 @@
}: AccordionPrimitive.ItemProps = $props();
</script>
<AccordionPrimitive.Item {...restProps} bind:ref class={cn('border-b', className)} />
<AccordionPrimitive.Item bind:ref class={cn('border-b', className)} {...restProps} />

View File

@@ -14,7 +14,7 @@
} = $props();
</script>
<AccordionPrimitive.Header class="flex" {level}>
<AccordionPrimitive.Header {level} class="flex">
<AccordionPrimitive.Trigger
bind:ref
class={cn(
@@ -24,6 +24,6 @@
{...restProps}
>
{@render children?.()}
<ChevronDown class="size-4 shrink-0 text-muted-foreground transition-transform duration-200" />
<ChevronDown class="text-muted-foreground size-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>

View File

@@ -0,0 +1,13 @@
<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 {
class: className,
ref = $bindable(null),
...restProps
}: AlertDialogPrimitive.ActionProps = $props();
</script>
<AlertDialogPrimitive.Action bind:ref class={cn(buttonVariants(), className)} {...restProps} />

View File

@@ -0,0 +1,17 @@
<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 {
class: className,
ref = $bindable(null),
...restProps
}: AlertDialogPrimitive.CancelProps = $props();
</script>
<AlertDialogPrimitive.Cancel
bind:ref
class={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
{...restProps}
/>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,40 @@
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
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 Trigger = AlertDialogPrimitive.Trigger;
const Portal = AlertDialogPrimitive.Portal;
export {
Root,
Title,
Action,
Cancel,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
//
Root as AlertDialog,
Title as AlertDialogTitle,
Action as AlertDialogAction,
Cancel as AlertDialogCancel,
Portal as AlertDialogPortal,
Footer as AlertDialogFooter,
Header as AlertDialogHeader,
Trigger as AlertDialogTrigger,
Overlay as AlertDialogOverlay,
Content as AlertDialogContent,
Description as AlertDialogDescription
};

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import type { WithElementRef } from 'bits-ui';
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} class={cn('text-sm [&_p]:leading-relaxed', className)} {...restProps}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import type { WithElementRef } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
level = 5,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
level?: 1 | 2 | 3 | 4 | 5 | 6;
} = $props();
</script>
<div
bind:this={ref}
aria-level={level}
class={cn('mb-1 leading-none font-medium tracking-tight', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,39 @@
<script lang="ts" module>
import { type VariantProps, tv } from 'tailwind-variants';
export const alertVariants = tv({
base: '[&>svg]:text-foreground relative w-full rounded-lg border px-4 py-3 text-sm [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg~*]:pl-7',
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive'
}
},
defaultVariants: {
variant: 'default'
}
});
export type AlertVariant = VariantProps<typeof alertVariants>['variant'];
</script>
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import type { WithElementRef } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
variant = 'default',
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
variant?: AlertVariant;
} = $props();
</script>
<div bind:this={ref} class={cn(alertVariants({ variant }), className)} {...restProps} role="alert">
{@render children?.()}
</div>

View File

@@ -0,0 +1,14 @@
import Root from './alert.svelte';
import Description from './alert-description.svelte';
import Title from './alert-title.svelte';
export { alertVariants, type AlertVariant } from './alert.svelte';
export {
Root,
Description,
Title,
//
Root as Alert,
Description as AlertDescription,
Title as AlertTitle
};

View File

@@ -11,6 +11,6 @@
<AvatarPrimitive.Fallback
bind:ref
class={cn('flex size-full items-center justify-center bg-muted', className)}
class={cn('bg-muted flex size-full items-center justify-center', className)}
{...restProps}
/>

View File

@@ -1,6 +1,5 @@
<script lang="ts" module>
import { type VariantProps, tv } from 'tailwind-variants';
export const badgeVariants = tv({
base: 'focus:ring-ring inline-flex select-none items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2',
variants: {
@@ -40,10 +39,10 @@
<svelte:element
this={href ? 'a' : 'span'}
{...restProps}
bind:this={ref}
class={cn(badgeVariants({ variant }), className)}
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</svelte:element>

View File

@@ -12,11 +12,11 @@
</script>
<span
{...restProps}
aria-hidden="true"
bind:this={ref}
class={cn('flex size-9 items-center justify-center', className)}
role="presentation"
aria-hidden="true"
class={cn('flex size-9 items-center justify-center', className)}
{...restProps}
>
<Ellipsis class="size-4" />
<span class="sr-only">More</span>

View File

@@ -14,7 +14,7 @@
<ol
bind:this={ref}
class={cn(
'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5',
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5',
className
)}
{...restProps}

View File

@@ -12,12 +12,12 @@
</script>
<span
{...restProps}
aria-current="page"
aria-disabled="true"
bind:this={ref}
class={cn('font-normal text-foreground', className)}
role="link"
aria-disabled="true"
aria-current="page"
class={cn('text-foreground font-normal', className)}
{...restProps}
>
{@render children?.()}
</span>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.CellProps = $props();
</script>
<CalendarPrimitive.Cell
class={cn(
'[&:has([data-selected])]:bg-accent [&:has([data-selected][data-outside-month])]:bg-accent/50 relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:rounded-md',
className
)}
bind:ref
{...restProps}
/>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } 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
}: CalendarPrimitive.DayProps = $props();
</script>
<CalendarPrimitive.Day
class={cn(
buttonVariants({ variant: 'ghost' }),
'size-8 p-0 font-normal',
// Today
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
// Selected
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground data-[selected]:opacity-100',
// Disabled
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
// Unavailable
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
// Outside months
'data-[outside-month]:text-muted-foreground [&[data-outside-month][data-selected]]:bg-accent/50 [&[data-outside-month][data-selected]]:text-muted-foreground data-[outside-month]:pointer-events-none data-[outside-month]:opacity-50 [&[data-outside-month][data-selected]]:opacity-30',
className
)}
bind:ref
{...restProps}
/>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.GridBodyProps = $props();
</script>
<CalendarPrimitive.GridBody bind:ref class={cn(className)} {...restProps} />

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.GridHeadProps = $props();
</script>
<CalendarPrimitive.GridHead bind:ref class={cn(className)} {...restProps} />

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.GridRowProps = $props();
</script>
<CalendarPrimitive.GridRow bind:ref class={cn('flex', className)} {...restProps} />

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.GridProps = $props();
</script>
<CalendarPrimitive.Grid
bind:ref
class={cn('w-full border-collapse space-y-1', className)}
{...restProps}
/>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.HeadCellProps = $props();
</script>
<CalendarPrimitive.HeadCell
bind:ref
class={cn('text-muted-foreground w-8 rounded-md text-[0.8rem] font-normal', className)}
{...restProps}
/>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.HeaderProps = $props();
</script>
<CalendarPrimitive.Header
bind:ref
class={cn('relative flex w-full items-center justify-between pt-1', className)}
{...restProps}
/>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.HeadingProps = $props();
</script>
<CalendarPrimitive.Heading bind:ref class={cn('text-sm font-medium', className)} {...restProps} />

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { WithElementRef } from 'bits-ui';
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
class={cn('mt-4 flex flex-col space-y-4 sm:flex-row sm:space-y-0 sm:space-x-4', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import { buttonVariants } from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: CalendarPrimitive.NextButtonProps = $props();
</script>
{#snippet Fallback()}
<ChevronRight />
{/snippet}
<CalendarPrimitive.NextButton
bind:ref
class={cn(
buttonVariants({ variant: 'outline' }),
'size-7 bg-transparent p-0 opacity-50 hover:opacity-100',
className
)}
{...restProps}
children={children || Fallback}
/>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import { buttonVariants } from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: CalendarPrimitive.PrevButtonProps = $props();
</script>
{#snippet Fallback()}
<ChevronLeft />
{/snippet}
<CalendarPrimitive.PrevButton
bind:ref
class={cn(
buttonVariants({ variant: 'outline' }),
'size-7 bg-transparent p-0 opacity-50 hover:opacity-100',
className
)}
{...restProps}
children={children || Fallback}
/>

View File

@@ -0,0 +1,61 @@
<script lang="ts">
import { Calendar as CalendarPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
import * as Calendar from './index.js';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
value = $bindable(),
placeholder = $bindable(),
class: className,
weekdayFormat = 'short',
...restProps
}: WithoutChildrenOrChild<CalendarPrimitive.RootProps> = $props();
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<CalendarPrimitive.Root
bind:value={value as never}
bind:ref
bind:placeholder
{weekdayFormat}
class={cn('p-3', className)}
{...restProps}
>
{#snippet children({ months, weekdays })}
<Calendar.Header>
<Calendar.PrevButton />
<Calendar.Heading />
<Calendar.NextButton />
</Calendar.Header>
<Calendar.Months>
{#each months as month (month)}
<Calendar.Grid>
<Calendar.GridHead>
<Calendar.GridRow class="flex">
{#each weekdays as weekday (weekday)}
<Calendar.HeadCell>
{weekday.slice(0, 2)}
</Calendar.HeadCell>
{/each}
</Calendar.GridRow>
</Calendar.GridHead>
<Calendar.GridBody>
{#each month.weeks as weekDates (weekDates)}
<Calendar.GridRow class="mt-2 w-full">
{#each weekDates as date (date)}
<Calendar.Cell {date} month={month.value}>
<Calendar.Day />
</Calendar.Cell>
{/each}
</Calendar.GridRow>
{/each}
</Calendar.GridBody>
</Calendar.Grid>
{/each}
</Calendar.Months>
{/snippet}
</CalendarPrimitive.Root>

View File

@@ -0,0 +1,30 @@
import Root from './calendar.svelte';
import Cell from './calendar-cell.svelte';
import Day from './calendar-day.svelte';
import Grid from './calendar-grid.svelte';
import Header from './calendar-header.svelte';
import Months from './calendar-months.svelte';
import GridRow from './calendar-grid-row.svelte';
import Heading from './calendar-heading.svelte';
import GridBody from './calendar-grid-body.svelte';
import GridHead from './calendar-grid-head.svelte';
import HeadCell from './calendar-head-cell.svelte';
import NextButton from './calendar-next-button.svelte';
import PrevButton from './calendar-prev-button.svelte';
export {
Day,
Cell,
Grid,
Header,
Months,
GridRow,
Heading,
GridBody,
GridHead,
HeadCell,
NextButton,
PrevButton,
//
Root as Calendar
};

View File

@@ -11,6 +11,6 @@
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div {...restProps} bind:this={ref} class={cn('p-6', className)}>
<div bind:this={ref} class={cn('p-6', className)} {...restProps}>
{@render children?.()}
</div>

View File

@@ -11,6 +11,6 @@
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p {...restProps} bind:this={ref} class={cn('text-sm text-muted-foreground', className)}>
<p bind:this={ref} class={cn('text-muted-foreground text-sm', className)} {...restProps}>
{@render children?.()}
</p>

View File

@@ -11,6 +11,6 @@
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div {...restProps} bind:this={ref} class={cn('flex items-center p-6 pt-0', className)}>
<div bind:this={ref} class={cn('flex items-center p-6 pt-0', className)} {...restProps}>
{@render children?.()}
</div>

View File

@@ -11,6 +11,6 @@
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div {...restProps} bind:this={ref} class={cn('flex flex-col space-y-1.5 p-6 pb-0', className)}>
<div bind:this={ref} class={cn('flex flex-col space-y-1.5 p-6 pb-0', className)} {...restProps}>
{@render children?.()}
</div>

View File

@@ -15,11 +15,11 @@
</script>
<div
{...restProps}
role="heading"
aria-level={level}
bind:this={ref}
class={cn('font-semibold leading-none tracking-tight', className)}
role="heading"
class={cn('leading-none font-semibold tracking-tight', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -12,9 +12,9 @@
</script>
<div
{...restProps}
bind:this={ref}
class={cn('rounded-xl border bg-card text-card-foreground shadow', className)}
class={cn('bg-card text-card-foreground rounded-xl border shadow', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -18,7 +18,6 @@
<!-- svelte-ignore event_directive_deprecated -->
<div
class="overflow-hidden"
on:emblaInit={emblaCtx.onInit}
use:emblaCarouselSvelte={{
options: {
container: '[data-embla-container]',
@@ -28,9 +27,9 @@
},
plugins: emblaCtx.plugins
}}
on:emblaInit={emblaCtx.onInit}
>
<div
{...restProps}
bind:this={ref}
class={cn(
'flex',
@@ -38,6 +37,7 @@
className
)}
data-embla-container=""
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -15,16 +15,16 @@
</script>
<div
{...restProps}
aria-roledescription="slide"
bind:this={ref}
role="group"
aria-roledescription="slide"
class={cn(
'min-w-0 shrink-0 grow-0 basis-full',
emblaCtx.orientation === 'horizontal' ? 'pl-4' : 'pt-4',
className
)}
data-embla-slide=""
role="group"
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -17,20 +17,20 @@
</script>
<Button
{...restProps}
bind:ref
{variant}
{size}
class={cn(
'absolute size-8 touch-manipulation rounded-full',
emblaCtx.orientation === 'horizontal'
? '-right-12 top-1/2 -translate-y-1/2'
? 'top-1/2 -right-12 -translate-y-1/2'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
className
)}
disabled={!emblaCtx.canScrollNext}
onclick={emblaCtx.scrollNext}
onkeydown={emblaCtx.handleKeyDown}
{size}
{variant}
bind:ref
{...restProps}
>
<ArrowRight />
<span class="sr-only">Next slide</span>

View File

@@ -17,20 +17,20 @@
</script>
<Button
{...restProps}
bind:ref
{variant}
{size}
class={cn(
'absolute size-8 touch-manipulation rounded-full',
emblaCtx.orientation === 'horizontal'
? '-left-12 top-1/2 -translate-y-1/2'
? 'top-1/2 -left-12 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
className
)}
disabled={!emblaCtx.canScrollPrev}
onclick={emblaCtx.scrollPrev}
onkeydown={emblaCtx.handleKeyDown}
{size}
{variant}
{...restProps}
bind:ref
>
<ArrowLeft />
<span class="sr-only">Previous slide</span>

View File

@@ -38,11 +38,9 @@
function scrollPrev() {
carouselState.api?.scrollPrev();
}
function scrollNext() {
carouselState.api?.scrollNext();
}
function scrollTo(index: number, jump?: boolean) {
carouselState.api?.scrollTo(index, jump);
}
@@ -89,6 +87,6 @@
});
</script>
<div {...restProps} aria-roledescription="carousel" class={cn('relative', className)} role="region">
<div class={cn('relative', className)} role="region" aria-roledescription="carousel" {...restProps}>
{@render children?.()}
</div>

View File

@@ -14,14 +14,14 @@
</script>
<CheckboxPrimitive.Root
{...restProps}
bind:checked
bind:indeterminate
bind:ref
class={cn(
'peer box-content size-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[disabled=true]:cursor-not-allowed data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[disabled=true]:opacity-50',
'border-primary focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground peer box-content size-4 shrink-0 rounded-sm border shadow focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50',
className
)}
bind:checked
bind:ref
bind:indeterminate
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<span class="flex items-center justify-center text-current">

View File

@@ -1,5 +1,4 @@
import Root from './checkbox.svelte';
export {
Root,
//

View File

@@ -25,7 +25,7 @@
<Dialog.Root bind:open {...restProps}>
<Dialog.Content class="overflow-hidden p-0" {portalProps}>
<Command
class="[&_[data-cmdk-group-heading]]:px-2 [&_[data-cmdk-group-heading]]:font-medium [&_[data-cmdk-group]:not([hidden])_~[data-cmdk-group]]:pt-0 [&_[data-cmdk-group]]:px-2 [&_[data-cmdk-input-wrapper]_svg]:h-5 [&_[data-cmdk-input-wrapper]_svg]:w-5 [&_[data-cmdk-input]]:h-12 [&_[data-cmdk-item]]:px-2 [&_[data-cmdk-item]]:py-3 [&_[data-cmdk-item]_svg]:h-5 [&_[data-cmdk-item]_svg]:w-5"
class="[&_[data-cmdk-group-heading]]:px-2 [&_[data-cmdk-group-heading]]:font-medium [&_[data-cmdk-group]]:px-2 [&_[data-cmdk-group]:not([hidden])_~[data-cmdk-group]]:pt-0 [&_[data-cmdk-input-wrapper]_svg]:h-5 [&_[data-cmdk-input-wrapper]_svg]:w-5 [&_[data-cmdk-input]]:h-12 [&_[data-cmdk-item]]:px-2 [&_[data-cmdk-item]]:py-3 [&_[data-cmdk-item]_svg]:h-5 [&_[data-cmdk-item]_svg]:w-5"
{...restProps}
bind:value
bind:ref

View File

@@ -15,13 +15,13 @@
</script>
<CommandPrimitive.Group
class={cn('overflow-hidden p-1 text-foreground', className)}
class={cn('text-foreground overflow-hidden p-1', className)}
bind:ref
value={value ?? heading ?? `----${useId()}`}
{...restProps}
>
{#if heading}
<CommandPrimitive.GroupHeading class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
<CommandPrimitive.GroupHeading class="text-muted-foreground px-2 py-1.5 text-xs font-medium">
{heading}
</CommandPrimitive.GroupHeading>
{/if}

View File

@@ -15,7 +15,7 @@
<Search class="mr-2 size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
class={cn(
'flex h-10 w-full rounded-md bg-transparent py-3 text-base outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-base outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
bind:ref

View File

@@ -11,7 +11,7 @@
<CommandPrimitive.Item
class={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
'aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
className
)}
bind:ref

View File

@@ -11,7 +11,7 @@
<CommandPrimitive.LinkItem
class={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
'aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
className
)}
bind:ref

View File

@@ -10,7 +10,7 @@
</script>
<CommandPrimitive.List
class={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
class={cn('max-h-[300px] overflow-x-hidden overflow-y-auto', className)}
bind:ref
{...restProps}
/>

View File

@@ -9,4 +9,4 @@
}: CommandPrimitive.SeparatorProps = $props();
</script>
<CommandPrimitive.Separator bind:ref class={cn('-mx-1 h-px bg-border', className)} {...restProps} />
<CommandPrimitive.Separator bind:ref class={cn('bg-border -mx-1 h-px', className)} {...restProps} />

View File

@@ -12,7 +12,7 @@
</script>
<span
class={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
class={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...restProps}
bind:this={ref}
>

View File

@@ -12,7 +12,7 @@
<CommandPrimitive.Root
class={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
className
)}
bind:ref

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
import Check from '@lucide/svelte/icons/check';
import Minus from '@lucide/svelte/icons/minus';
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
checked = $bindable(false),
indeterminate = $bindable(false),
children: childrenProp,
...restProps
}: WithoutChildrenOrChild<ContextMenuPrimitive.CheckboxItemProps> & {
children?: Snippet;
} = $props();
</script>
<ContextMenuPrimitive.CheckboxItem
bind:ref
bind:checked
bind:indeterminate
class={cn(
'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<span class="absolute left-2 flex size-3.5 items-center justify-center">
{#if indeterminate}
<Minus class="size-4" />
{:else}
<Check class={cn('size-4', !checked && 'text-transparent')} />
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</ContextMenuPrimitive.CheckboxItem>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
portalProps,
...restProps
}: ContextMenuPrimitive.ContentProps & {
portalProps?: ContextMenuPrimitive.PortalProps;
} = $props();
</script>
<ContextMenuPrimitive.Portal {...portalProps}>
<ContextMenuPrimitive.Content
class={cn(
'bg-popover text-popover-foreground z-50 min-w-[8rem] rounded-md border p-1 shadow-md focus:outline-none',
className
)}
{...restProps}
bind:ref
/>
</ContextMenuPrimitive.Portal>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ContextMenuPrimitive.GroupHeadingProps & {
inset?: boolean;
} = $props();
</script>
<ContextMenuPrimitive.GroupHeading
bind:ref
class={cn('text-foreground px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
{...restProps}
/>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ContextMenuPrimitive.ItemProps & {
inset?: boolean;
} = $props();
</script>
<ContextMenuPrimitive.Item
class={cn(
'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
bind:ref
{...restProps}
/>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive, type WithoutChild } from 'bits-ui';
import Circle from '@lucide/svelte/icons/circle';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children: childrenProp,
...restProps
}: WithoutChild<ContextMenuPrimitive.RadioItemProps> = $props();
</script>
<ContextMenuPrimitive.RadioItem
bind:ref
class={cn(
'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span class="absolute left-2 flex size-3.5 items-center justify-center">
{#if checked}
<Circle class="size-2 fill-current" />
{/if}
</span>
{@render childrenProp?.({ checked })}
{/snippet}
</ContextMenuPrimitive.RadioItem>

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: ContextMenuPrimitive.SubContentProps = $props();
</script>
<ContextMenuPrimitive.SubContent
bind:ref
class={cn(
'bg-popover text-popover-foreground z-50 min-w-[8rem] rounded-md border p-1 shadow-lg focus:outline-none',
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive, type WithoutChild } from 'bits-ui';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
inset,
...restProps
}: WithoutChild<ContextMenuPrimitive.SubTriggerProps> & {
inset?: boolean;
} = $props();
</script>
<ContextMenuPrimitive.SubTrigger
bind:ref
class={cn(
'data-[highlighted]:bg-accent data-[state=open]:bg-accent data-[highlighted]:text-accent-foreground data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none',
inset && 'pl-8',
className
)}
{...restProps}
>
{@render children?.()}
<ChevronRight class="ml-auto size-4" />
</ContextMenuPrimitive.SubTrigger>

View File

@@ -0,0 +1,49 @@
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
import Item from './context-menu-item.svelte';
import GroupHeading from './context-menu-group-heading.svelte';
import Content from './context-menu-content.svelte';
import Shortcut from './context-menu-shortcut.svelte';
import RadioItem from './context-menu-radio-item.svelte';
import Separator from './context-menu-separator.svelte';
import SubContent from './context-menu-sub-content.svelte';
import SubTrigger from './context-menu-sub-trigger.svelte';
import CheckboxItem from './context-menu-checkbox-item.svelte';
const Sub: typeof ContextMenuPrimitive.Sub = ContextMenuPrimitive.Sub;
const Root: typeof ContextMenuPrimitive.Root = ContextMenuPrimitive.Root;
const Trigger: typeof ContextMenuPrimitive.Trigger = ContextMenuPrimitive.Trigger;
const Group: typeof ContextMenuPrimitive.Group = ContextMenuPrimitive.Group;
const RadioGroup: typeof ContextMenuPrimitive.RadioGroup = ContextMenuPrimitive.RadioGroup;
export {
Sub,
Root,
Item,
Group,
Trigger,
Content,
Shortcut,
Separator,
RadioItem,
GroupHeading,
SubContent,
SubTrigger,
RadioGroup,
CheckboxItem,
//
Root as ContextMenu,
Sub as ContextMenuSub,
Item as ContextMenuItem,
Group as ContextMenuGroup,
Content as ContextMenuContent,
Trigger as ContextMenuTrigger,
Shortcut as ContextMenuShortcut,
RadioItem as ContextMenuRadioItem,
Separator as ContextMenuSeparator,
GroupHeading as ContextMenuGroupHeading,
RadioGroup as ContextMenuRadioGroup,
SubContent as ContextMenuSubContent,
SubTrigger as ContextMenuSubTrigger,
CheckboxItem as ContextMenuCheckboxItem
};

View File

@@ -0,0 +1,112 @@
import type { RowData, TableOptions, TableOptionsResolved, TableState } from '@tanstack/table-core';
import { createTable } from '@tanstack/table-core';
/**
* Creates a reactive TanStack table object for Svelte.
* @param options Table options to create the table with.
* @returns A reactive table object.
* @example
* ```svelte
* <script>
* const table = createSvelteTable({ ... })
* </script>
*
* <table>
* <thead>
* {#each table.getHeaderGroups() as headerGroup}
* <tr>
* {#each headerGroup.headers as header}
* <th colspan={header.colSpan}>
* <FlexRender content={header.column.columnDef.header} context={header.getContext()} />
* </th>
* {/each}
* </tr>
* {/each}
* </thead>
* <!-- ... -->
* </table>
* ```
*/
export function createSvelteTable<TData extends RowData>(options: TableOptions<TData>) {
const resolvedOptions: TableOptionsResolved<TData> = mergeObjects(
{
state: {},
onStateChange() {},
renderFallbackValue: null,
mergeOptions: (
defaultOptions: TableOptions<TData>,
options: Partial<TableOptions<TData>>
) => {
return mergeObjects(defaultOptions, options);
}
},
options
);
const table = createTable(resolvedOptions);
let state = $state<Partial<TableState>>(table.initialState);
function updateOptions() {
table.setOptions((prev) => {
return mergeObjects(prev, options, {
state: mergeObjects(state, options.state || {}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onStateChange: (updater: any) => {
if (updater instanceof Function) state = updater(state);
else state = mergeObjects(state, updater);
options.onStateChange?.(updater);
}
});
});
}
updateOptions();
$effect.pre(() => {
updateOptions();
});
return table;
}
/**
* Merges objects together while keeping their getters alive.
* Taken from SolidJS: {@link https://github.com/solidjs/solid/blob/24abc825c0996fd2bc8c1de1491efe9a7e743aff/packages/solid/src/server/rendering.ts#L82-L115}
*/
export function mergeObjects<T>(source: T): T;
export function mergeObjects<T, U>(source: T, source1: U): T & U;
export function mergeObjects<T, U, V>(source: T, source1: U, source2: V): T & U & V;
export function mergeObjects<T, U, V, W>(
source: T,
source1: U,
source2: V,
source3: W
): T & U & V & W;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function mergeObjects(...sources: any): any {
const target = {};
for (let i = 0; i < sources.length; i++) {
let source = sources[i];
if (typeof source === 'function') source = source();
if (source) {
const descriptors = Object.getOwnPropertyDescriptors(source);
for (const key in descriptors) {
if (key in target) continue;
Object.defineProperty(target, key, {
enumerable: true,
get() {
for (let i = sources.length - 1; i >= 0; i--) {
let s = sources[i];
if (typeof s === 'function') s = s();
const v = (s || {})[key];
if (v !== undefined) return v;
}
}
});
}
}
}
return target;
}

View File

@@ -0,0 +1,43 @@
<script lang="ts" module>
import type { CellContext, ColumnDefTemplate, HeaderContext } from '@tanstack/table-core';
type TData = unknown;
type TValue = unknown;
type TContext = unknown;
</script>
<script
lang="ts"
generics="TData, TValue, TContext extends HeaderContext<TData, TValue> | CellContext<TData, TValue>"
>
import { RenderComponentConfig, RenderSnippetConfig } from './render-helpers.js';
type Props = {
/** The cell or header field of the current cell's column definition. */
content?: TContext extends HeaderContext<TData, TValue>
? ColumnDefTemplate<HeaderContext<TData, TValue>>
: TContext extends CellContext<TData, TValue>
? ColumnDefTemplate<CellContext<TData, TValue>>
: never;
/** The result of the `getContext()` function of the header or cell */
context: TContext;
};
let { content, context }: Props = $props();
</script>
{#if typeof content === 'string'}
{content}
{:else if content instanceof Function}
<!-- It's unlikely that a CellContext will be passed to a Header -->
<!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -->
{@const result = content(context as any)}
{#if result instanceof RenderComponentConfig}
{@const { component: Component, props } = result}
<Component {...props} />
{:else if result instanceof RenderSnippetConfig}
{@const { snippet, params } = result}
{@render snippet(params)}
{:else}
{result}
{/if}
{/if}

View File

@@ -0,0 +1,3 @@
export { default as FlexRender } from './flex-render.svelte';
export { renderComponent, renderSnippet } from './render-helpers.js';
export { createSvelteTable } from './data-table.svelte.js';

View File

@@ -0,0 +1,111 @@
import type { Component, ComponentProps, Snippet } from 'svelte';
/**
* A helper class to make it easy to identify Svelte components in
* `columnDef.cell` and `columnDef.header` properties.
*
* > NOTE: This class should only be used internally by the adapter. If you're
* reading this and you don't know what this is for, you probably don't need it.
*
* @example
* ```svelte
* {@const result = content(context as any)}
* {#if result instanceof RenderComponentConfig}
* {@const { component: Component, props } = result}
* <Component {...props} />
* {/if}
* ```
*/
export class RenderComponentConfig<TComponent extends Component> {
component: TComponent;
props: ComponentProps<TComponent> | Record<string, never>;
constructor(
component: TComponent,
props: ComponentProps<TComponent> | Record<string, never> = {}
) {
this.component = component;
this.props = props;
}
}
/**
* A helper class to make it easy to identify Svelte Snippets in `columnDef.cell` and `columnDef.header` properties.
*
* > NOTE: This class should only be used internally by the adapter. If you're
* reading this and you don't know what this is for, you probably don't need it.
*
* @example
* ```svelte
* {@const result = content(context as any)}
* {#if result instanceof RenderSnippetConfig}
* {@const { snippet, params } = result}
* {@render snippet(params)}
* {/if}
* ```
*/
export class RenderSnippetConfig<TProps> {
snippet: Snippet<[TProps]>;
params: TProps;
constructor(snippet: Snippet<[TProps]>, params: TProps) {
this.snippet = snippet;
this.params = params;
}
}
/**
* A helper function to help create cells from Svelte components through ColumnDef's `cell` and `header` properties.
*
* This is only to be used with Svelte Components - use `renderSnippet` for Svelte Snippets.
*
* @param component A Svelte component
* @param props The props to pass to `component`
* @returns A `RenderComponentConfig` object that helps svelte-table know how to render the header/cell component.
* @example
* ```ts
* // +page.svelte
* const defaultColumns = [
* columnHelper.accessor('name', {
* header: header => renderComponent(SortHeader, { label: 'Name', header }),
* }),
* columnHelper.accessor('state', {
* header: header => renderComponent(SortHeader, { label: 'State', header }),
* }),
* ]
* ```
* @see {@link https://tanstack.com/table/latest/docs/guide/column-defs}
*/
export function renderComponent<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
T extends Component<any>,
Props extends ComponentProps<T>
>(component: T, props: Props) {
return new RenderComponentConfig(component, props);
}
/**
* A helper function to help create cells from Svelte Snippets through ColumnDef's `cell` and `header` properties.
*
* The snippet must only take one parameter.
*
* This is only to be used with Snippets - use `renderComponent` for Svelte Components.
*
* @param snippet
* @param params
* @returns - A `RenderSnippetConfig` object that helps svelte-table know how to render the header/cell snippet.
* @example
* ```ts
* // +page.svelte
* const defaultColumns = [
* columnHelper.accessor('name', {
* cell: cell => renderSnippet(nameSnippet, { name: cell.row.name }),
* }),
* columnHelper.accessor('state', {
* cell: cell => renderSnippet(stateSnippet, { state: cell.row.state }),
* }),
* ]
* ```
* @see {@link https://tanstack.com/table/latest/docs/guide/column-defs}
*/
export function renderSnippet<TProps>(snippet: Snippet<[TProps]>, params: TProps) {
return new RenderSnippetConfig(snippet, params);
}

View File

@@ -22,14 +22,14 @@
<DialogPrimitive.Content
bind:ref
class={cn(
'fixed left-[50%] top-[50%] z-50 grid translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
className
)}
{...restProps}
>
{@render children?.()}
<DialogPrimitive.Close
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none"
>
<X class="size-4" />
<span class="sr-only">Close</span>

View File

@@ -11,6 +11,6 @@
<DialogPrimitive.Description
bind:ref
class={cn('text-sm text-muted-foreground', className)}
class={cn('text-muted-foreground text-sm', className)}
{...restProps}
/>

View File

@@ -12,7 +12,7 @@
<DialogPrimitive.Overlay
bind:ref
class={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',
className
)}
{...restProps}

View File

@@ -11,6 +11,6 @@
<DialogPrimitive.Title
bind:ref
class={cn('text-lg font-semibold leading-none tracking-tight', className)}
class={cn('text-lg leading-none font-semibold tracking-tight', className)}
{...restProps}
/>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
import DrawerOverlay from './drawer-overlay.svelte';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
portalProps,
children,
...restProps
}: DrawerPrimitive.ContentProps & {
portalProps?: DrawerPrimitive.PortalProps;
} = $props();
</script>
<DrawerPrimitive.Portal {...portalProps}>
<DrawerOverlay />
<DrawerPrimitive.Content
bind:ref
class={cn(
'bg-background fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border',
className
)}
{...restProps}
>
<div class="bg-muted mx-auto mt-4 h-2 w-[100px] rounded-full"></div>
{@render children?.()}
</DrawerPrimitive.Content>
</DrawerPrimitive.Portal>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: DrawerPrimitive.DescriptionProps = $props();
</script>
<DrawerPrimitive.Description
bind:ref
class={cn('text-muted-foreground text-sm', className)}
{...restProps}
/>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { WithElementRef } from 'bits-ui';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
export { className as class };
</script>
<div bind:this={ref} class={cn('mt-auto flex flex-col gap-2 p-4', className)} {...restProps}>
{@render children?.()}
</div>

View File

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

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