mirror of
https://github.com/maxdorninger/MediaManager.git
synced 2026-04-17 15:13:24 +02:00
Merge pull request #129 from maxdorninger/enhance-frontend
Enhance frontend
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -163,5 +163,6 @@ class PublicShow(BaseModel):
|
||||
|
||||
ended: bool = False
|
||||
continuous_download: bool = False
|
||||
library: str
|
||||
|
||||
seasons: list[PublicSeason]
|
||||
|
||||
@@ -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 don’t 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
5337
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
171
web/src/app.css
171
web/src/app.css
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
175
web/src/lib/components/login-card.svelte
Normal file
175
web/src/lib/components/login-card.svelte
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
161
web/src/lib/components/signup-card.svelte
Normal file
161
web/src/lib/components/signup-card.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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}
|
||||
/>
|
||||
40
web/src/lib/components/ui/alert-dialog/index.ts
Normal file
40
web/src/lib/components/ui/alert-dialog/index.ts
Normal 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
|
||||
};
|
||||
16
web/src/lib/components/ui/alert/alert-description.svelte
Normal file
16
web/src/lib/components/ui/alert/alert-description.svelte
Normal 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>
|
||||
24
web/src/lib/components/ui/alert/alert-title.svelte
Normal file
24
web/src/lib/components/ui/alert/alert-title.svelte
Normal 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>
|
||||
39
web/src/lib/components/ui/alert/alert.svelte
Normal file
39
web/src/lib/components/ui/alert/alert.svelte
Normal 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>
|
||||
14
web/src/lib/components/ui/alert/index.ts
Normal file
14
web/src/lib/components/ui/alert/index.ts
Normal 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
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
19
web/src/lib/components/ui/calendar/calendar-cell.svelte
Normal file
19
web/src/lib/components/ui/calendar/calendar-cell.svelte
Normal 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}
|
||||
/>
|
||||
31
web/src/lib/components/ui/calendar/calendar-day.svelte
Normal file
31
web/src/lib/components/ui/calendar/calendar-day.svelte
Normal 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}
|
||||
/>
|
||||
12
web/src/lib/components/ui/calendar/calendar-grid-body.svelte
Normal file
12
web/src/lib/components/ui/calendar/calendar-grid-body.svelte
Normal 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} />
|
||||
12
web/src/lib/components/ui/calendar/calendar-grid-head.svelte
Normal file
12
web/src/lib/components/ui/calendar/calendar-grid-head.svelte
Normal 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} />
|
||||
12
web/src/lib/components/ui/calendar/calendar-grid-row.svelte
Normal file
12
web/src/lib/components/ui/calendar/calendar-grid-row.svelte
Normal 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} />
|
||||
16
web/src/lib/components/ui/calendar/calendar-grid.svelte
Normal file
16
web/src/lib/components/ui/calendar/calendar-grid.svelte
Normal 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}
|
||||
/>
|
||||
16
web/src/lib/components/ui/calendar/calendar-head-cell.svelte
Normal file
16
web/src/lib/components/ui/calendar/calendar-head-cell.svelte
Normal 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}
|
||||
/>
|
||||
16
web/src/lib/components/ui/calendar/calendar-header.svelte
Normal file
16
web/src/lib/components/ui/calendar/calendar-header.svelte
Normal 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}
|
||||
/>
|
||||
12
web/src/lib/components/ui/calendar/calendar-heading.svelte
Normal file
12
web/src/lib/components/ui/calendar/calendar-heading.svelte
Normal 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} />
|
||||
20
web/src/lib/components/ui/calendar/calendar-months.svelte
Normal file
20
web/src/lib/components/ui/calendar/calendar-months.svelte
Normal 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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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}
|
||||
/>
|
||||
61
web/src/lib/components/ui/calendar/calendar.svelte
Normal file
61
web/src/lib/components/ui/calendar/calendar.svelte
Normal 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>
|
||||
30
web/src/lib/components/ui/calendar/index.ts
Normal file
30
web/src/lib/components/ui/calendar/index.ts
Normal 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
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Root from './checkbox.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
49
web/src/lib/components/ui/context-menu/index.ts
Normal file
49
web/src/lib/components/ui/context-menu/index.ts
Normal 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
|
||||
};
|
||||
112
web/src/lib/components/ui/data-table/data-table.svelte.ts
Normal file
112
web/src/lib/components/ui/data-table/data-table.svelte.ts
Normal 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;
|
||||
}
|
||||
43
web/src/lib/components/ui/data-table/flex-render.svelte
Normal file
43
web/src/lib/components/ui/data-table/flex-render.svelte
Normal 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}
|
||||
3
web/src/lib/components/ui/data-table/index.ts
Normal file
3
web/src/lib/components/ui/data-table/index.ts
Normal 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';
|
||||
111
web/src/lib/components/ui/data-table/render-helpers.ts
Normal file
111
web/src/lib/components/ui/data-table/render-helpers.ts
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
30
web/src/lib/components/ui/drawer/drawer-content.svelte
Normal file
30
web/src/lib/components/ui/drawer/drawer-content.svelte
Normal 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>
|
||||
16
web/src/lib/components/ui/drawer/drawer-description.svelte
Normal file
16
web/src/lib/components/ui/drawer/drawer-description.svelte
Normal 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}
|
||||
/>
|
||||
18
web/src/lib/components/ui/drawer/drawer-footer.svelte
Normal file
18
web/src/lib/components/ui/drawer/drawer-footer.svelte
Normal 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>
|
||||
20
web/src/lib/components/ui/drawer/drawer-header.svelte
Normal file
20
web/src/lib/components/ui/drawer/drawer-header.svelte
Normal 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
Reference in New Issue
Block a user