style: apply prettier formatting to all files
All checks were successful
Build and Push Backend Image / build (push) Successful in 46s
Build and Push Frontend Image / build (push) Successful in 5m12s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 22:27:54 +01:00
parent 18116072c9
commit efc7624ba3
184 changed files with 10327 additions and 10220 deletions

View File

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

View File

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

View File

@@ -8,82 +8,82 @@
@custom-variant hover (&:hover);
@theme {
--animate-vibrate: vibrate 0.3s linear infinite;
--animate-fade-in: fadeIn 0.3s ease-out;
--animate-slide-up: slideUp 0.4s cubic-bezier(0.4, 0, 0.2, 1);
--animate-zoom-in: zoomIn 0.4s cubic-bezier(0.4, 0, 0.2, 1);
--animate-pulse-glow: pulseGlow 2s infinite;
--animate-vibrate: vibrate 0.3s linear infinite;
--animate-fade-in: fadeIn 0.3s ease-out;
--animate-slide-up: slideUp 0.4s cubic-bezier(0.4, 0, 0.2, 1);
--animate-zoom-in: zoomIn 0.4s cubic-bezier(0.4, 0, 0.2, 1);
--animate-pulse-glow: pulseGlow 2s infinite;
@keyframes vibrate {
0% {
transform: translate(0);
}
@keyframes vibrate {
0% {
transform: translate(0);
}
20% {
transform: translate(-2px, 2px);
}
20% {
transform: translate(-2px, 2px);
}
40% {
transform: translate(-2px, -2px);
}
40% {
transform: translate(-2px, -2px);
}
60% {
transform: translate(2px, 2px);
}
60% {
transform: translate(2px, 2px);
}
80% {
transform: translate(2px, -2px);
}
80% {
transform: translate(2px, -2px);
}
100% {
transform: translate(0);
}
}
100% {
transform: translate(0);
}
}
@keyframes fadeIn {
0% {
opacity: 0;
}
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
100% {
opacity: 1;
}
}
@keyframes slideUp {
0% {
opacity: 0;
transform: translateY(30px) scale(0.95);
}
@keyframes slideUp {
0% {
opacity: 0;
transform: translateY(30px) scale(0.95);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes zoomIn {
0% {
opacity: 0;
transform: scale(0.9);
}
@keyframes zoomIn {
0% {
opacity: 0;
transform: scale(0.9);
}
100% {
opacity: 1;
transform: scale(1);
}
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes pulseGlow {
0%,
100% {
boxShadow: 0 0 20px rgba(183, 0, 217, 0.3);
}
@keyframes pulseGlow {
0%,
100% {
boxshadow: 0 0 20px rgba(183, 0, 217, 0.3);
}
50% {
boxShadow: 0 0 40px rgba(183, 0, 217, 0.6);
}
}
50% {
boxshadow: 0 0 40px rgba(183, 0, 217, 0.6);
}
}
}
/*
@@ -95,134 +95,134 @@
color utility to any element that depends on these defaults.
*/
@layer base {
* {
@supports (color: color-mix(in lab, red, red)) {
outline-color: color-mix(in oklab, var(--ring) 50%, transparent);
}
}
* {
@supports (color: color-mix(in lab, red, red)) {
outline-color: color-mix(in oklab, var(--ring) 50%, transparent);
}
}
* {
border-color: var(--border);
outline-color: var(--ring);
}
* {
border-color: var(--border);
outline-color: var(--ring);
}
.prose h2 {
@apply text-2xl font-bold mt-8 mb-4 text-foreground;
}
.prose h2 {
@apply text-2xl font-bold mt-8 mb-4 text-foreground;
}
.prose h3 {
@apply text-xl font-semibold mt-6 mb-3 text-foreground;
}
.prose h3 {
@apply text-xl font-semibold mt-6 mb-3 text-foreground;
}
.prose p {
@apply mb-4 leading-relaxed;
}
.prose p {
@apply mb-4 leading-relaxed;
}
.prose ul {
@apply mb-4 pl-6;
}
.prose ul {
@apply mb-4 pl-6;
}
.prose li {
@apply mb-2;
}
.prose li {
@apply mb-2;
}
}
:root {
--default-font-family: "Noto Sans", sans-serif;
--background: oklch(0.98 0.01 320);
--foreground: oklch(0.08 0.02 280);
--muted: oklch(0.95 0.01 280);
--muted-foreground: oklch(0.4 0.02 280);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--card: oklch(0.99 0.005 320);
--card-foreground: oklch(0.08 0.02 280);
--border: oklch(0.85 0.02 280);
--input: oklch(0.922 0 0);
--primary: oklch(56.971% 0.27455 319.257);
--primary-foreground: oklch(0.98 0.01 320);
--secondary: oklch(0.92 0.02 260);
--secondary-foreground: oklch(0.15 0.05 260);
--accent: oklch(0.45 0.35 280);
--accent-foreground: oklch(0.98 0.01 280);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--ring: oklch(0.55 0.3 320);
--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);
--default-font-family: "Noto Sans", sans-serif;
--background: oklch(0.98 0.01 320);
--foreground: oklch(0.08 0.02 280);
--muted: oklch(0.95 0.01 280);
--muted-foreground: oklch(0.4 0.02 280);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--card: oklch(0.99 0.005 320);
--card-foreground: oklch(0.08 0.02 280);
--border: oklch(0.85 0.02 280);
--input: oklch(0.922 0 0);
--primary: oklch(56.971% 0.27455 319.257);
--primary-foreground: oklch(0.98 0.01 320);
--secondary: oklch(0.92 0.02 260);
--secondary-foreground: oklch(0.15 0.05 260);
--accent: oklch(0.45 0.35 280);
--accent-foreground: oklch(0.98 0.01 280);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--ring: oklch(0.55 0.3 320);
--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.08 0.02 280);
--foreground: oklch(0.98 0.01 280);
--muted: oklch(0.12 0.03 280);
--muted-foreground: oklch(0.6 0.02 280);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--card: oklch(0.1 0.02 280);
--card-foreground: oklch(0.95 0.01 280);
--border: oklch(0.2 0.05 280);
--input: oklch(1 0 0 / 0.15);
--primary: oklch(0.65 0.25 320);
--primary-foreground: oklch(0.98 0.01 320);
--secondary: oklch(0.15 0.05 260);
--secondary-foreground: oklch(0.9 0.02 260);
--accent: oklch(0.55 0.3 280);
--accent-foreground: oklch(0.98 0.01 280);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--ring: oklch(0.65 0.25 320);
--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 / 0.1);
--sidebar-ring: oklch(0.556 0 0);
--background: oklch(0.08 0.02 280);
--foreground: oklch(0.98 0.01 280);
--muted: oklch(0.12 0.03 280);
--muted-foreground: oklch(0.6 0.02 280);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--card: oklch(0.1 0.02 280);
--card-foreground: oklch(0.95 0.01 280);
--border: oklch(0.2 0.05 280);
--input: oklch(1 0 0 / 0.15);
--primary: oklch(0.65 0.25 320);
--primary-foreground: oklch(0.98 0.01 320);
--secondary: oklch(0.15 0.05 260);
--secondary-foreground: oklch(0.9 0.02 260);
--accent: oklch(0.55 0.3 280);
--accent-foreground: oklch(0.98 0.01 280);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--ring: oklch(0.65 0.25 320);
--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 / 0.1);
--sidebar-ring: oklch(0.556 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
}

View File

@@ -4,22 +4,22 @@ import type { AuthStatus } from "$lib/types";
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
interface Locals {
authStatus: AuthStatus;
requestId: string;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
interface Window {
sidebar: {
addPanel: () => void;
};
opera: object;
}
namespace App {
// interface Error {}
interface Locals {
authStatus: AuthStatus;
requestId: string;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
interface Window {
sidebar: {
addPanel: () => void;
};
opera: object;
}
}
export {};

View File

@@ -1,24 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@400..700&family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@400..700&family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap"
rel="stylesheet"
/>
<link rel="manifest" href="/site.webmanifest" />
%sveltekit.head%
</head>
</head>
<body data-sveltekit-preload-data="hover" class="dark">
<body data-sveltekit-preload-data="hover" class="dark">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
</body>
</html>

View File

@@ -6,88 +6,88 @@ import type { Handle } from "@sveltejs/kit";
logger.startup();
export const handle: Handle = async ({ event, resolve }) => {
const { cookies, locals, url, request } = event;
const startTime = Date.now();
const { cookies, locals, url, request } = event;
const startTime = Date.now();
// Generate unique request ID
const requestId = generateRequestId();
// Generate unique request ID
const requestId = generateRequestId();
// Add request ID to locals for access in other handlers
locals.requestId = requestId;
// Add request ID to locals for access in other handlers
locals.requestId = requestId;
// Log incoming request
logger.request(request.method, url.pathname, {
requestId,
context: {
userAgent: request.headers.get('user-agent')?.substring(0, 100),
referer: request.headers.get('referer'),
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip'),
},
});
// Log incoming request
logger.request(request.method, url.pathname, {
requestId,
context: {
userAgent: request.headers.get("user-agent")?.substring(0, 100),
referer: request.headers.get("referer"),
ip: request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip"),
},
});
// Handle authentication
const token = cookies.get("session_token");
// Handle authentication
const token = cookies.get("session_token");
if (token) {
try {
locals.authStatus = await isAuthenticated(token);
if (token) {
try {
locals.authStatus = await isAuthenticated(token);
if (locals.authStatus.authenticated) {
logger.auth('Token validated', true, {
requestId,
userId: locals.authStatus.user?.id,
context: {
email: locals.authStatus.user?.email,
role: locals.authStatus.user?.role,
},
});
} else {
logger.auth('Token invalid', false, { requestId });
}
} catch (error) {
logger.error('Authentication check failed', {
requestId,
error: error instanceof Error ? error : new Error(String(error)),
});
locals.authStatus = { authenticated: false };
}
} else {
logger.debug('No session token found', { requestId });
locals.authStatus = { authenticated: false };
}
if (locals.authStatus.authenticated) {
logger.auth("Token validated", true, {
requestId,
userId: locals.authStatus.user?.id,
context: {
email: locals.authStatus.user?.email,
role: locals.authStatus.user?.role,
},
});
} else {
logger.auth("Token invalid", false, { requestId });
}
} catch (error) {
logger.error("Authentication check failed", {
requestId,
error: error instanceof Error ? error : new Error(String(error)),
});
locals.authStatus = { authenticated: false };
}
} else {
logger.debug("No session token found", { requestId });
locals.authStatus = { authenticated: false };
}
// Resolve the request
let response: Response;
try {
response = await resolve(event, {
filterSerializedResponseHeaders: (key) => {
return key.toLowerCase() === "content-type";
},
});
} catch (error) {
const duration = Date.now() - startTime;
logger.error('Request handler error', {
requestId,
method: request.method,
path: url.pathname,
duration,
error: error instanceof Error ? error : new Error(String(error)),
});
throw error;
}
// Resolve the request
let response: Response;
try {
response = await resolve(event, {
filterSerializedResponseHeaders: (key) => {
return key.toLowerCase() === "content-type";
},
});
} catch (error) {
const duration = Date.now() - startTime;
logger.error("Request handler error", {
requestId,
method: request.method,
path: url.pathname,
duration,
error: error instanceof Error ? error : new Error(String(error)),
});
throw error;
}
// Log response
const duration = Date.now() - startTime;
logger.response(request.method, url.pathname, response.status, duration, {
requestId,
userId: locals.authStatus.authenticated ? locals.authStatus.user?.id : undefined,
context: {
cached: response.headers.get('x-sveltekit-page') === 'true',
},
});
// Log response
const duration = Date.now() - startTime;
logger.response(request.method, url.pathname, response.status, duration, {
requestId,
userId: locals.authStatus.authenticated ? locals.authStatus.user?.id : undefined,
context: {
cached: response.headers.get("x-sveltekit-page") === "true",
},
});
// Add request ID to response headers (useful for debugging)
response.headers.set('x-request-id', requestId);
// Add request ID to response headers (useful for debugging)
response.headers.set("x-request-id", requestId);
return response;
return response;
};

View File

@@ -1,77 +1,70 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "$lib/components/ui/dialog";
import { Button } from "$lib/components/ui/button";
import { Separator } from "$lib/components/ui/separator";
import { onMount } from "svelte";
import { _ } from "svelte-i18n";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "$lib/components/ui/dialog";
import { Button } from "$lib/components/ui/button";
import { Separator } from "$lib/components/ui/separator";
import { onMount } from "svelte";
const AGE_VERIFICATION_KEY = "age-verified";
const AGE_VERIFICATION_KEY = "age-verified";
let isOpen = true;
let isOpen = true;
function handleAgeConfirmation() {
localStorage.setItem(AGE_VERIFICATION_KEY, "true");
isOpen = false;
}
function handleAgeConfirmation() {
localStorage.setItem(AGE_VERIFICATION_KEY, "true");
isOpen = false;
}
onMount(() => {
const storedVerification = localStorage.getItem(AGE_VERIFICATION_KEY);
if (storedVerification === "true") {
isOpen = false;
}
});
onMount(() => {
const storedVerification = localStorage.getItem(AGE_VERIFICATION_KEY);
if (storedVerification === "true") {
isOpen = false;
}
});
</script>
<Dialog bind:open={isOpen}>
<DialogContent
class="sm:max-w-md"
onInteractOutside={(e) => e.preventDefault()}
showCloseButton={false}
>
<DialogHeader class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-10 h-10 shrink-0 grow-0 rounded-full bg-gradient-to-br from-primary to-purple-600 flex items-center justify-center"
>
<span class="text-primary-foreground text-sm"
>{$_("age_verification_dialog.age")}</span
>
</div>
<div class="">
<DialogTitle class="text-left text-xl font-semibold text-primary-foreground"
>{$_("age_verification_dialog.title")}</DialogTitle
>
<DialogDescription class="text-left text-sm">
{$_("age_verification_dialog.description")}
</DialogDescription>
</div>
</div>
</div>
</DialogHeader>
<Separator class="my-4" />
<!-- Close Button -->
<div class="flex justify-end gap-4">
<Button variant="destructive" href={$_("age_verification_dialog.exit_url")} size="sm">
{$_("age_verification_dialog.exit")}
</Button>
<Button
variant="default"
size="sm"
onclick={handleAgeConfirmation}
class="cursor-pointer"
<DialogContent
class="sm:max-w-md"
onInteractOutside={(e) => e.preventDefault()}
showCloseButton={false}
>
<DialogHeader class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-10 h-10 shrink-0 grow-0 rounded-full bg-gradient-to-br from-primary to-purple-600 flex items-center justify-center"
>
<span class="text-primary-foreground text-sm">{$_("age_verification_dialog.age")}</span>
</div>
<div class="">
<DialogTitle class="text-left text-xl font-semibold text-primary-foreground"
>{$_("age_verification_dialog.title")}</DialogTitle
>
<span class="icon-[ri--check-line]"></span>
{$_("age_verification_dialog.confirm")}
</Button>
<DialogDescription class="text-left text-sm">
{$_("age_verification_dialog.description")}
</DialogDescription>
</div>
</div>
</DialogContent>
</div>
</DialogHeader>
<Separator class="my-4" />
<!-- Close Button -->
<div class="flex justify-end gap-4">
<Button variant="destructive" href={$_("age_verification_dialog.exit_url")} size="sm">
{$_("age_verification_dialog.exit")}
</Button>
<Button variant="default" size="sm" onclick={handleAgeConfirmation} class="cursor-pointer">
<span class="icon-[ri--check-line]"></span>
{$_("age_verification_dialog.confirm")}
</Button>
</div>
</DialogContent>
</Dialog>

View File

@@ -1,55 +1,55 @@
<!-- Advanced Plasma Background -->
<div class="absolute inset-0 pointer-events-none">
<!-- Primary gradient layers -->
<div
class="absolute inset-0 bg-gradient-to-br from-primary/6 via-accent/10 to-primary/4 opacity-60"
></div>
<div
class="absolute inset-0 bg-gradient-to-tl from-accent/4 via-primary/8 to-accent/6 opacity-40"
></div>
<!-- Primary gradient layers -->
<div
class="absolute inset-0 bg-gradient-to-br from-primary/6 via-accent/10 to-primary/4 opacity-60"
></div>
<div
class="absolute inset-0 bg-gradient-to-tl from-accent/4 via-primary/8 to-accent/6 opacity-40"
></div>
<!-- Large floating orbs -->
<!-- <div
<!-- Large floating orbs -->
<!-- <div
class="absolute top-20 left-20 w-80 h-80 bg-gradient-to-br from-primary/12 via-accent/18 to-primary/8 rounded-full blur-3xl animate-blob-slow"
></div>
<div
class="absolute bottom-20 right-20 w-96 h-96 bg-gradient-to-tl from-accent/12 via-primary/18 to-accent/8 rounded-full blur-3xl animate-blob-slow animation-delay-6000"
></div> -->
<!-- Medium morphing elements -->
<!-- <div
<!-- Medium morphing elements -->
<!-- <div
class="absolute top-1/2 left-1/3 w-64 h-64 bg-gradient-to-r from-primary/10 via-accent/15 to-primary/8 rounded-full blur-2xl animate-blob-reverse animation-delay-3000"
></div>
<div
class="absolute bottom-1/3 right-1/3 w-72 h-72 bg-gradient-to-l from-accent/10 via-primary/15 to-accent/8 rounded-full blur-2xl animate-blob-reverse animation-delay-9000"
></div> -->
<!-- Soft particle effects -->
<!-- <div
<!-- Soft particle effects -->
<!-- <div
class="absolute top-1/4 right-1/4 w-48 h-48 bg-gradient-to-br from-primary/15 to-accent/12 rounded-full blur-xl animate-float animation-delay-2000"
></div>
<div
class="absolute bottom-1/4 left-1/4 w-56 h-56 bg-gradient-to-tl from-accent/15 to-primary/12 rounded-full blur-xl animate-float animation-delay-8000"
></div> -->
<!-- Premium glassmorphism overlay -->
<!-- <div
<!-- Premium glassmorphism overlay -->
<!-- <div
class="absolute inset-0 bg-gradient-to-br from-primary/2 via-transparent to-accent/3 backdrop-blur-[1px]"
></div> -->
<!-- Animated Plasma Background -->
<div
class="absolute top-1/3 left-1/3 w-72 h-72 bg-gradient-to-r from-accent/20 via-primary/25 to-accent/15 rounded-full blur-2xl animate-blob"
></div>
<div
class="absolute bottom-1/3 right-1/3 w-88 h-88 bg-gradient-to-r from-primary/20 via-accent/25 to-primary/15 rounded-full blur-3xl animate-blob-reverse animation-delay-3000"
></div>
<div
class="absolute top-1/2 right-1/4 w-64 h-64 bg-gradient-to-r from-accent/15 via-primary/20 to-accent/10 rounded-full blur-2xl animate-float animation-delay-1000"
></div>
<!-- Animated Plasma Background -->
<div
class="absolute top-1/3 left-1/3 w-72 h-72 bg-gradient-to-r from-accent/20 via-primary/25 to-accent/15 rounded-full blur-2xl animate-blob"
></div>
<div
class="absolute bottom-1/3 right-1/3 w-88 h-88 bg-gradient-to-r from-primary/20 via-accent/25 to-primary/15 rounded-full blur-3xl animate-blob-reverse animation-delay-3000"
></div>
<div
class="absolute top-1/2 right-1/4 w-64 h-64 bg-gradient-to-r from-accent/15 via-primary/20 to-accent/10 rounded-full blur-2xl animate-float animation-delay-1000"
></div>
<!-- Global Plasma Background -->
<!-- <div
<!-- Global Plasma Background -->
<!-- <div
class="absolute top-32 right-32 w-72 h-72 bg-gradient-to-r from-accent/18 via-primary/22 to-accent/12 rounded-full blur-3xl animate-blob"
></div>
<div

View File

@@ -1,12 +1,8 @@
<script lang="ts">
const { isMobileMenuOpen = $bindable(), label, onclick } = $props();
const { isMobileMenuOpen = $bindable(), label, onclick } = $props();
</script>
<button
class="block rounded-full cursor-pointer"
onclick={onclick}
aria-label={label}
>
<button class="block rounded-full cursor-pointer" {onclick} aria-label={label}>
<div
class="relative flex overflow-hidden items-center justify-center rounded-full w-[50px] h-[50px] transform transition-all duration-200 shadow-md opacity-90 translate-x-3"
>
@@ -14,23 +10,23 @@ const { isMobileMenuOpen = $bindable(), label, onclick } = $props();
class="flex flex-col justify-between w-[16px] h-[10px] transform transition-all duration-300 origin-center overflow-hidden"
>
<div
class={`bg-white h-[2px] w-7 transform transition-all duration-300 origin-left ${isMobileMenuOpen ? 'translate-x-10' : ''}`}
class={`bg-white h-[2px] w-7 transform transition-all duration-300 origin-left ${isMobileMenuOpen ? "translate-x-10" : ""}`}
></div>
<div
class={`bg-white h-[2px] w-7 rounded transform transition-all duration-300 delay-75 ${isMobileMenuOpen ? 'translate-x-10' : ''}`}
class={`bg-white h-[2px] w-7 rounded transform transition-all duration-300 delay-75 ${isMobileMenuOpen ? "translate-x-10" : ""}`}
></div>
<div
class={`bg-white h-[2px] w-7 transform transition-all duration-300 origin-left delay-150 ${isMobileMenuOpen ? 'translate-x-10' : ''}`}
class={`bg-white h-[2px] w-7 transform transition-all duration-300 origin-left delay-150 ${isMobileMenuOpen ? "translate-x-10" : ""}`}
></div>
<div
class={`absolute items-center justify-between transform transition-all duration-500 top-6.5 -translate-x-10 flex w-0 ${isMobileMenuOpen ? 'translate-x-0 w-12' : ''}`}
class={`absolute items-center justify-between transform transition-all duration-500 top-6.5 -translate-x-10 flex w-0 ${isMobileMenuOpen ? "translate-x-0 w-12" : ""}`}
>
<div
class={`absolute bg-white h-[2px] w-4 transform transition-all duration-500 rotate-0 delay-300 ${isMobileMenuOpen ? 'rotate-45' : ''}`}
class={`absolute bg-white h-[2px] w-4 transform transition-all duration-500 rotate-0 delay-300 ${isMobileMenuOpen ? "rotate-45" : ""}`}
></div>
<div
class={`absolute bg-white h-[2px] w-4 transform transition-all duration-500 -rotate-0 delay-300 ${isMobileMenuOpen ? '-rotate-45' : ''}`}
class={`absolute bg-white h-[2px] w-4 transform transition-all duration-500 -rotate-0 delay-300 ${isMobileMenuOpen ? "-rotate-45" : ""}`}
></div>
</div>
</div>

View File

@@ -1,99 +1,92 @@
<script lang="ts">
import { cn } from "$lib/utils";
import { Slider } from "$lib/components/ui/slider";
import { Label } from "$lib/components/ui/label";
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
import type { BluetoothDevice } from "$lib/types";
import { _ } from "svelte-i18n";
import { cn } from "$lib/utils";
import { Slider } from "$lib/components/ui/slider";
import { Label } from "$lib/components/ui/label";
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
import type { BluetoothDevice } from "$lib/types";
import { _ } from "svelte-i18n";
interface Props {
device: BluetoothDevice;
onChange: (scalarIndex: number, val: number) => void;
onStop: () => void;
}
interface Props {
device: BluetoothDevice;
onChange: (scalarIndex: number, val: number) => void;
onStop: () => void;
}
let { device, onChange, onStop }: Props = $props();
let { device, onChange, onStop }: Props = $props();
function getBatteryColor(level: number) {
if (!device.hasBattery) {
return "text-gray-400";
}
if (level > 60) return "text-green-400";
if (level > 30) return "text-yellow-400";
return "text-red-400";
}
function getBatteryColor(level: number) {
if (!device.hasBattery) {
return "text-gray-400";
}
if (level > 60) return "text-green-400";
if (level > 30) return "text-yellow-400";
return "text-red-400";
}
function getBatteryBgColor(level: number) {
if (!device.hasBattery) {
return "bg-gray-400/20";
}
if (level > 60) return "bg-green-400/20";
if (level > 30) return "bg-yellow-400/20";
return "bg-red-400/20";
}
function getBatteryBgColor(level: number) {
if (!device.hasBattery) {
return "bg-gray-400/20";
}
if (level > 60) return "bg-green-400/20";
if (level > 30) return "bg-yellow-400/20";
return "bg-red-400/20";
}
function getScalarAnimations() {
return device.actuators
.filter((a) => a.value > 0)
.map((a) => `animate-${a.outputType.toLowerCase()}`);
}
function getScalarAnimations() {
return device.actuators
.filter((a) => a.value > 0)
.map((a) => `animate-${a.outputType.toLowerCase()}`);
}
function isActive() {
return device.actuators.some((a) => a.value > 0);
}
function isActive() {
return device.actuators.some((a) => a.value > 0);
}
</script>
<Card
class="group hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 border-border/50 hover:border-primary/30 bg-card/50 backdrop-blur-sm"
class="group hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 border-border/50 hover:border-primary/30 bg-card/50 backdrop-blur-sm"
>
<CardHeader class="pb-3">
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<div
class="p-2 rounded-lg bg-gradient-to-br from-primary/20 to-accent/20 border border-primary/30 flex shrink-0 grow-0"
>
<span class={cn([...getScalarAnimations(), "icon-[ri--rocket-line] w-5 h-5 text-primary"])}></span>
</div>
<div>
<h3
class="font-semibold text-card-foreground group-hover:text-primary transition-colors"
>
{device.name}
</h3>
<!-- <p class="text-sm text-muted-foreground">
<CardHeader class="pb-3">
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<div
class="p-2 rounded-lg bg-gradient-to-br from-primary/20 to-accent/20 border border-primary/30 flex shrink-0 grow-0"
>
<span
class={cn([...getScalarAnimations(), "icon-[ri--rocket-line] w-5 h-5 text-primary"])}
></span>
</div>
<div>
<h3 class="font-semibold text-card-foreground group-hover:text-primary transition-colors">
{device.name}
</h3>
<!-- <p class="text-sm text-muted-foreground">
{device.deviceType}
</p> -->
</div>
</div>
<button class={`${isActive() ? "cursor-pointer" : ""} flex items-center gap-2`} onclick={() => isActive() && onStop()}>
<div class="relative">
<div
class="w-2 h-2 rounded-full {isActive()
? 'bg-green-400'
: 'bg-red-400'}"
></div>
{#if isActive()}
<div
class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-75"
></div>
{/if}
</div>
<span
class="text-xs font-medium {isActive()
? 'text-green-400'
: 'text-red-400'}"
>
{isActive()
? $_("device_card.active")
: $_("device_card.paused")}
</span>
</button>
</div>
</CardHeader>
</div>
<button
class={`${isActive() ? "cursor-pointer" : ""} flex items-center gap-2`}
onclick={() => isActive() && onStop()}
>
<div class="relative">
<div class="w-2 h-2 rounded-full {isActive() ? 'bg-green-400' : 'bg-red-400'}"></div>
{#if isActive()}
<div
class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-75"
></div>
{/if}
</div>
<span class="text-xs font-medium {isActive() ? 'text-green-400' : 'text-red-400'}">
{isActive() ? $_("device_card.active") : $_("device_card.paused")}
</span>
</button>
</div>
</CardHeader>
<CardContent class="space-y-4">
<!-- Current Value -->
<!-- <div
<CardContent class="space-y-4">
<!-- Current Value -->
<!-- <div
class="flex items-center justify-between p-3 rounded-lg bg-muted/30 border border-border/30"
>
<span class="text-sm text-muted-foreground"
@@ -103,58 +96,54 @@ function isActive() {
>
</div> -->
<!-- Battery Level -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span
class="icon-[ri--battery-2-charge-line] w-4 h-4 {getBatteryColor(
device.batteryLevel,
)}"
></span>
<span class="text-sm text-muted-foreground">{$_("device_card.battery")}</span>
</div>
{#if device.hasBattery}
<span class="text-sm font-medium {getBatteryColor(device.batteryLevel)}">
{device.batteryLevel}%
</span>
{/if}
</div>
<div class="w-full bg-muted/50 rounded-full h-2 overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500 {getBatteryBgColor(
device.batteryLevel,
)} bg-gradient-to-r from-current to-current/80"
style="width: {device.batteryLevel}%"
></div>
</div>
<!-- Battery Level -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span
class="icon-[ri--battery-2-charge-line] w-4 h-4 {getBatteryColor(device.batteryLevel)}"
></span>
<span class="text-sm text-muted-foreground">{$_("device_card.battery")}</span>
</div>
{#if device.hasBattery}
<span class="text-sm font-medium {getBatteryColor(device.batteryLevel)}">
{device.batteryLevel}%
</span>
{/if}
</div>
<div class="w-full bg-muted/50 rounded-full h-2 overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500 {getBatteryBgColor(
device.batteryLevel,
)} bg-gradient-to-r from-current to-current/80"
style="width: {device.batteryLevel}%"
></div>
</div>
</div>
<!-- Last Seen -->
<!-- <div
<!-- Last Seen -->
<!-- <div
class="flex items-center justify-between text-xs text-muted-foreground"
>
<span>{$_("device_card.last_seen")}</span>
<span>{device.lastSeen.toLocaleTimeString()}</span>
</div> -->
<!-- Action Button -->
{#each device.actuators as actuator, idx (idx)}
<div class="space-y-2">
<Label for={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}
>{$_(
`device_card.actuator_types.${actuator.outputType.toLowerCase()}`,
)}</Label
>
<Slider
id={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}
type="single"
value={actuator.value}
onValueChange={(val) => onChange(idx, val)}
max={actuator.maxSteps}
step={1}
/>
</div>
{/each}
</CardContent>
<!-- Action Button -->
{#each device.actuators as actuator, idx (idx)}
<div class="space-y-2">
<Label for={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}
>{$_(`device_card.actuator_types.${actuator.outputType.toLowerCase()}`)}</Label
>
<Slider
id={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}
type="single"
value={actuator.value}
onValueChange={(val) => onChange(idx, val)}
max={actuator.maxSteps}
step={1}
/>
</div>
{/each}
</CardContent>
</Card>

View File

@@ -1,120 +1,120 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import Logo from "../logo/logo.svelte";
import { _ } from "svelte-i18n";
import Logo from "../logo/logo.svelte";
</script>
<footer
class="bg-gradient-to-t from-card/95 to-card/85 backdrop-blur-xl mt-20 shadow-2xl shadow-primary/10"
class="bg-gradient-to-t from-card/95 to-card/85 backdrop-blur-xl mt-20 shadow-2xl shadow-primary/10"
>
<div class="container mx-auto px-4 py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<!-- Brand -->
<div class="space-y-4">
<div class="flex items-center gap-3 text-xl font-bold">
<Logo />
</div>
<p class="text-sm text-muted-foreground">{$_("brand.description")}</p>
<div class="flex gap-3">
<a
aria-label="Email"
href="mailto:{$_('footer.contact.email')}"
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
>
<span class="icon-[ri--mail-line] w-4 h-4 text-primary"></span>
</a>
<a
aria-label="X"
href="https://www.x.com/{$_('footer.contact.x')}"
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
>
<span class="icon-[ri--twitter-x-line] w-4 h-4 text-primary"></span>
</a>
<a
aria-label="YouTube"
href="https://www.youtube.com/@{$_('footer.contact.youtube')}"
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
>
<span class="icon-[ri--youtube-line] w-4 h-4 text-primary"></span>
</a>
</div>
</div>
<!-- Quick Links -->
<div class="space-y-4">
<h3 class="font-semibold text-foreground">
{$_("footer.quick_links")}
</h3>
<div class="space-y-2">
<a
href="/models"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.models")}</a
>
<a
href="/videos"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.videos")}</a
>
<a
href="/magazine"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.magazine")}</a
>
<a
href="/about"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.about")}</a
>
</div>
</div>
<!-- Support -->
<div class="space-y-4">
<h3 class="font-semibold text-foreground">{$_("footer.support")}</h3>
<div class="space-y-2">
<a
href="mailto:{$_('footer.contact_support_email')}"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.contact_support")}</a
>
<a
href="mailto:{$_('footer.model_applications_email')}"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.model_applications")}</a
>
<a
href="/faq"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.faq")}</a
>
</div>
</div>
<!-- Legal -->
<div class="space-y-4">
<h3 class="font-semibold text-foreground">{$_("footer.legal")}</h3>
<div class="space-y-2">
<a
href="/legal"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.privacy_policy")}</a
>
<a
href="/legal"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.terms_of_service")}</a
>
<a
href="/imprint"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.imprint")}</a
>
</div>
</div>
<div class="container mx-auto px-4 py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<!-- Brand -->
<div class="space-y-4">
<div class="flex items-center gap-3 text-xl font-bold">
<Logo />
</div>
<div class="border-t border-border/50 mt-8 pt-8 text-center">
<p class="text-sm text-muted-foreground">{$_("footer.copyright")}</p>
<p class="text-sm text-muted-foreground">{$_("brand.description")}</p>
<div class="flex gap-3">
<a
aria-label="Email"
href="mailto:{$_('footer.contact.email')}"
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
>
<span class="icon-[ri--mail-line] w-4 h-4 text-primary"></span>
</a>
<a
aria-label="X"
href="https://www.x.com/{$_('footer.contact.x')}"
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
>
<span class="icon-[ri--twitter-x-line] w-4 h-4 text-primary"></span>
</a>
<a
aria-label="YouTube"
href="https://www.youtube.com/@{$_('footer.contact.youtube')}"
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
>
<span class="icon-[ri--youtube-line] w-4 h-4 text-primary"></span>
</a>
</div>
</div>
<!-- Quick Links -->
<div class="space-y-4">
<h3 class="font-semibold text-foreground">
{$_("footer.quick_links")}
</h3>
<div class="space-y-2">
<a
href="/models"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.models")}</a
>
<a
href="/videos"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.videos")}</a
>
<a
href="/magazine"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.magazine")}</a
>
<a
href="/about"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.about")}</a
>
</div>
</div>
<!-- Support -->
<div class="space-y-4">
<h3 class="font-semibold text-foreground">{$_("footer.support")}</h3>
<div class="space-y-2">
<a
href="mailto:{$_('footer.contact_support_email')}"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.contact_support")}</a
>
<a
href="mailto:{$_('footer.model_applications_email')}"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.model_applications")}</a
>
<a
href="/faq"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.faq")}</a
>
</div>
</div>
<!-- Legal -->
<div class="space-y-4">
<h3 class="font-semibold text-foreground">{$_("footer.legal")}</h3>
<div class="space-y-2">
<a
href="/legal"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.privacy_policy")}</a
>
<a
href="/legal"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.terms_of_service")}</a
>
<a
href="/imprint"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.imprint")}</a
>
</div>
</div>
</div>
<div class="border-t border-border/50 mt-8 pt-8 text-center">
<p class="text-sm text-muted-foreground">{$_("footer.copyright")}</p>
</div>
</div>
</footer>

View File

@@ -7,9 +7,7 @@
stroke="#ce47eb"
preserveAspectRatio="xMidYMid meet"
>
<metadata>
Created by potrace 1.15, written by Peter Selinger 2001-2017
</metadata>
<metadata> Created by potrace 1.15, written by Peter Selinger 2001-2017 </metadata>
<g transform="translate(0.000000,904.000000) scale(0.100000,-0.100000)">
<path
d="M7930 7043 c-73 -10 -95 -18 -134 -51 -25 -20 -66 -53 -91 -72 -26
@@ -117,4 +115,4 @@ m-3487 -790 c-17 -35 -55 -110 -84 -168 -29 -58 -72 -163 -96 -235 -45 -134
/>
</g>
</svg>
</div>
</div>

View File

@@ -1,51 +1,51 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import { page } from "$app/state";
import { Button } from "$lib/components/ui/button";
import type { AuthStatus } from "$lib/types";
import { logout } from "$lib/services";
import { goto } from "$app/navigation";
import { getAssetUrl } from "$lib/directus";
import LogoutButton from "../logout-button/logout-button.svelte";
import Separator from "../ui/separator/separator.svelte";
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
import { getUserInitials } from "$lib/utils";
import BurgerMenuButton from "../burger-menu-button/burger-menu-button.svelte";
import Girls from "../girls/girls.svelte";
import Logo from "../logo/logo.svelte";
import { _ } from "svelte-i18n";
import { page } from "$app/state";
import { Button } from "$lib/components/ui/button";
import type { AuthStatus } from "$lib/types";
import { logout } from "$lib/services";
import { goto } from "$app/navigation";
import { getAssetUrl } from "$lib/directus";
import LogoutButton from "../logout-button/logout-button.svelte";
import Separator from "../ui/separator/separator.svelte";
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
import { getUserInitials } from "$lib/utils";
import BurgerMenuButton from "../burger-menu-button/burger-menu-button.svelte";
import Girls from "../girls/girls.svelte";
import Logo from "../logo/logo.svelte";
interface Props {
authStatus: AuthStatus;
}
interface Props {
authStatus: AuthStatus;
}
let { authStatus }: Props = $props();
let { authStatus }: Props = $props();
let isMobileMenuOpen = $state(false);
let isMobileMenuOpen = $state(false);
const navLinks = [
{ name: $_("header.home"), href: "/" },
{ name: $_("header.models"), href: "/models" },
{ name: $_("header.videos"), href: "/videos" },
{ name: $_("header.magazine"), href: "/magazine" },
{ name: $_("header.about"), href: "/about" },
];
const navLinks = [
{ name: $_("header.home"), href: "/" },
{ name: $_("header.models"), href: "/models" },
{ name: $_("header.videos"), href: "/videos" },
{ name: $_("header.magazine"), href: "/magazine" },
{ name: $_("header.about"), href: "/about" },
];
async function handleLogout() {
closeMenu();
await logout();
goto("/login", { invalidateAll: true });
}
async function handleLogout() {
closeMenu();
await logout();
goto("/login", { invalidateAll: true });
}
function closeMenu() {
isMobileMenuOpen = false;
}
function closeMenu() {
isMobileMenuOpen = false;
}
function isActiveLink(link: any) {
return (
(page.url.pathname === "/" && link === navLinks[0]) ||
(page.url.pathname.startsWith(link.href) && link !== navLinks[0])
);
}
function isActiveLink(link: any) {
return (
(page.url.pathname === "/" && link === navLinks[0]) ||
(page.url.pathname.startsWith(link.href) && link !== navLinks[0])
);
}
</script>
<header
@@ -58,7 +58,7 @@ function isActiveLink(link: any) {
href="/"
class="flex w-full items-center gap-3 hover:scale-105 transition-all duration-300"
>
<Logo hideName={true} />
<Logo hideName={true} />
</a>
<!-- Desktop Navigation -->
@@ -67,12 +67,12 @@ function isActiveLink(link: any) {
<a
href={link.href}
class={`text-sm hover:text-foreground transition-colors duration-200 font-medium relative group ${
isActiveLink(link) ? 'text-foreground' : 'text-foreground/85'
isActiveLink(link) ? "text-foreground" : "text-foreground/85"
}`}
>
{link.name}
<span
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink(link) ? 'w-full' : 'group-hover:w-full'}`}
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink(link) ? "w-full" : "group-hover:w-full"}`}
></span>
</a>
{/each}
@@ -95,29 +95,29 @@ function isActiveLink(link: any) {
<Button
variant="link"
size="icon"
class={`hidden sm:flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: '/me' }) ? 'text-foreground' : 'hover:text-foreground'}`}
class={`hidden sm:flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/me" }) ? "text-foreground" : "hover:text-foreground"}`}
href="/me"
title={$_('header.dashboard')}
title={$_("header.dashboard")}
>
<span class="icon-[ri--dashboard-2-line] h-4 w-4"></span>
<span
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: '/me' }) ? 'w-full' : 'group-hover:w-full'}`}
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: "/me" }) ? "w-full" : "group-hover:w-full"}`}
></span>
<span class="sr-only">{$_('header.dashboard')}</span>
<span class="sr-only">{$_("header.dashboard")}</span>
</Button>
<Button
variant="link"
size="icon"
class={`hidden sm:flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: '/play' }) ? 'text-foreground' : 'hover:text-foreground'}`}
class={`hidden sm:flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/play" }) ? "text-foreground" : "hover:text-foreground"}`}
href="/play"
title={$_('header.play')}
title={$_("header.play")}
>
<span class="icon-[ri--rocket-line] h-4 w-4"></span>
<span
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: '/play' }) ? 'w-full' : 'group-hover:w-full'}`}
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: "/play" }) ? "w-full" : "group-hover:w-full"}`}
></span>
<span class="sr-only">{$_('header.play')}</span>
<span class="sr-only">{$_("header.play")}</span>
</Button>
<Separator orientation="vertical" class="hidden md:flex mx-1 h-6 bg-border/50" />
@@ -126,9 +126,10 @@ function isActiveLink(link: any) {
<LogoutButton
user={{
name: authStatus.user!.artist_name || authStatus.user!.email.split('@')[0] || 'User',
avatar: getAssetUrl(authStatus.user!.avatar?.id, 'mini')!,
email: authStatus.user!.email
name:
authStatus.user!.artist_name || authStatus.user!.email.split("@")[0] || "User",
avatar: getAssetUrl(authStatus.user!.avatar?.id, "mini")!,
email: authStatus.user!.email,
}}
onLogout={handleLogout}
/>
@@ -136,18 +137,16 @@ function isActiveLink(link: any) {
</div>
{:else}
<div class="flex w-full items-center justify-end gap-4">
<Button variant="outline" class="font-medium" href="/login"
>{$_('header.login')}</Button
>
<Button variant="outline" class="font-medium" href="/login">{$_("header.login")}</Button>
<Button
href="/signup"
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 font-medium"
>{$_('header.signup')}</Button
>{$_("header.signup")}</Button
>
</div>
{/if}
<BurgerMenuButton
label={$_('header.navigation')}
label={$_("header.navigation")}
bind:isMobileMenuOpen
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
/>
@@ -155,26 +154,24 @@ function isActiveLink(link: any) {
</div>
<!-- Mobile Navigation -->
<div
class={`border-t border-border/20 bg-background/95 bg-gradient-to-br from-primary to-accent backdrop-blur-xl max-h-[calc(100vh-4rem)] overflow-y-auto shadow-xl/30 transition-all duration-250 ${isMobileMenuOpen ? 'opacity-100' : 'opacity-0'}`}
class={`border-t border-border/20 bg-background/95 bg-gradient-to-br from-primary to-accent backdrop-blur-xl max-h-[calc(100vh-4rem)] overflow-y-auto shadow-xl/30 transition-all duration-250 ${isMobileMenuOpen ? "opacity-100" : "opacity-0"}`}
>
{#if isMobileMenuOpen}
<div class="container mx-auto grid grid-cols-1 lg:grid-cols-3">
<div class="hidden lg:flex col-span-2">
<Girls />
</div>
<div class="py-6 px-4 space-y-6 lg:col-start-3 border-t border-border/20 bg-background/95 ">
<div class="py-6 px-4 space-y-6 lg:col-start-3 border-t border-border/20 bg-background/95">
<!-- User Profile Card -->
{#if authStatus.authenticated}
<div
class="relative overflow-hidden rounded-2xl border border-border/50 bg-gradient-to-br from-card to-card/50 p-4 backdrop-blur-sm"
>
<div
class="absolute inset-0 bg-gradient-to-br from-primary/5 to-accent/5"
></div>
<div class="absolute inset-0 bg-gradient-to-br from-primary/5 to-accent/5"></div>
<div class="relative flex items-center gap-4">
<Avatar class="h-14 w-14 ring-2 ring-primary/30">
<AvatarImage
src={getAssetUrl(authStatus.user!.avatar?.id, 'mini')}
src={getAssetUrl(authStatus.user!.avatar?.id, "mini")}
alt={authStatus.user!.artist_name}
/>
<AvatarFallback
@@ -212,17 +209,15 @@ function isActiveLink(link: any) {
{/if}
<!-- Navigation Cards -->
<div class="space-y-3">
<h3
class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider"
>
{$_('header.navigation')}
<h3 class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{$_("header.navigation")}
</h3>
<div class="grid gap-2">
{#each navLinks as link (link.href)}
<a
href={link.href}
class="flex items-center justify-between rounded-xl border border-border/50 bg-card/50 p-4 backdrop-blur-sm transition-all hover:bg-card hover:border-primary/20 {isActiveLink(
link
link,
)
? 'border-primary/30 bg-primary/5'
: ''}"
@@ -233,8 +228,7 @@ function isActiveLink(link: any) {
<!-- {#if isActiveLink(link)}
<div class="h-2 w-2 rounded-full bg-primary"></div>
{/if} -->
<span
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground"
<span class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground"
></span>
</div>
</a>
@@ -244,16 +238,14 @@ function isActiveLink(link: any) {
<!-- Account Actions -->
<div class="space-y-3">
<h3
class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider"
>
{$_('header.account')}
<h3 class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{$_("header.account")}
</h3>
<div class="grid gap-2">
{#if authStatus.authenticated}
<a
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: '/me' }) ? 'border-primary/30 bg-primary/5' : ''}`}
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: "/me" }) ? "border-primary/30 bg-primary/5" : ""}`}
href="/me"
onclick={closeMenu}
>
@@ -266,13 +258,9 @@ function isActiveLink(link: any) {
</div>
<div class="flex flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<span class="font-medium text-foreground"
>{$_('header.dashboard')}</span
>
<span class="font-medium text-foreground">{$_("header.dashboard")}</span>
</div>
<span class="text-sm text-muted-foreground"
>{$_('header.dashboard_hint')}</span
>
<span class="text-sm text-muted-foreground">{$_("header.dashboard_hint")}</span>
</div>
<span
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
@@ -280,7 +268,7 @@ function isActiveLink(link: any) {
</a>
<a
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: '/play' }) ? 'border-primary/30 bg-primary/5' : ''}`}
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: "/play" }) ? "border-primary/30 bg-primary/5" : ""}`}
href="/play"
onclick={closeMenu}
>
@@ -293,13 +281,9 @@ function isActiveLink(link: any) {
</div>
<div class="flex flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<span class="font-medium text-foreground"
>{$_('header.play')}</span
>
<span class="font-medium text-foreground">{$_("header.play")}</span>
</div>
<span class="text-sm text-muted-foreground"
>{$_('header.play_hint')}</span
>
<span class="text-sm text-muted-foreground">{$_("header.play_hint")}</span>
</div>
<span
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
@@ -307,7 +291,7 @@ function isActiveLink(link: any) {
</a>
{:else}
<a
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: '/login' }) ? 'border-primary/30 bg-primary/5' : ''}`}
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: "/login" }) ? "border-primary/30 bg-primary/5" : ""}`}
href="/login"
onclick={closeMenu}
>
@@ -320,13 +304,9 @@ function isActiveLink(link: any) {
</div>
<div class="flex flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<span class="font-medium text-foreground"
>{$_('header.login')}</span
>
<span class="font-medium text-foreground">{$_("header.login")}</span>
</div>
<span class="text-sm text-muted-foreground"
>{$_('header.login_hint')}</span
>
<span class="text-sm text-muted-foreground">{$_("header.login_hint")}</span>
</div>
<span
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
@@ -334,7 +314,7 @@ function isActiveLink(link: any) {
</a>
<a
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: '/signup' }) ? 'border-primary/30 bg-primary/5' : ''}`}
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: "/signup" }) ? "border-primary/30 bg-primary/5" : ""}`}
href="/signup"
onclick={closeMenu}
>
@@ -347,13 +327,9 @@ function isActiveLink(link: any) {
</div>
<div class="flex flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<span class="font-medium text-foreground"
>{$_('header.signup')}</span
>
<span class="font-medium text-foreground">{$_("header.signup")}</span>
</div>
<span class="text-sm text-muted-foreground"
>{$_('header.signup_hint')}</span
>
<span class="text-sm text-muted-foreground">{$_("header.signup_hint")}</span>
</div>
<span
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
@@ -372,17 +348,11 @@ function isActiveLink(link: any) {
<div
class="flex h-10 w-10 items-center justify-center rounded-xl bg-destructive/10 group-hover:bg-destructive/20 transition-all"
>
<span
class="icon-[ri--logout-circle-r-line] h-4 w-4 text-destructive"
></span>
<span class="icon-[ri--logout-circle-r-line] h-4 w-4 text-destructive"></span>
</div>
<div class="flex flex-1 flex-col gap-1">
<span class="font-medium text-foreground"
>{$_('header.logout')}</span
>
<span class="text-sm text-muted-foreground"
>{$_('header.logout_hint')}</span
>
<span class="font-medium text-foreground">{$_("header.logout")}</span>
<span class="text-sm text-muted-foreground">{$_("header.logout_hint")}</span>
</div>
</button>
{/if}

View File

@@ -1,25 +1,24 @@
<script lang="ts">
interface Props {
class?: string;
size?: string | number;
}
interface Props {
class?: string;
size?: string | number;
}
let { class: className = "", size = "24" }: Props = $props();
let { class: className = "", size = "24" }: Props = $props();
</script>
<svg
width={size}
height={size}
viewBox="0 0 512 512"
class={className}
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 512 512"
class={className}
xmlns="http://www.w3.org/2000/svg"
>
<g class="" transform="translate(0,0)" style=""
><path
d="M418.813 30.625c-21.178 26.27-49.712 50.982-84.125 70.844-36.778 21.225-75.064 33.62-110.313 38.06a310.317 310.317 0 0 0 6.813 18.25c16.01.277 29.366-.434 36.406-1.5l9.47-1.53 8.436-1.28.22 10.186a307.48 307.48 0 0 1-1.095 18.72l56.625 8.843c.86-.095 1.713-.15 2.563-.157 11.188-.114 21.44 7.29 24.468 18.593.657 2.448.922 4.903.845 7.313 5.972-2.075 11.753-4.305 17.28-6.72l9.595-4.188 2.313 10.22a340.211 340.211 0 0 1 7.375 48.062C438.29 247.836 468.438 225.71 493 197.5c-3.22-36.73-16.154-78.04-39.125-117.813a290.509 290.509 0 0 0-2.22-3.78l-27.56 71.374c5.154.762 10.123 3.158 14.092 7.126 9.81 9.807 9.813 25.69 0 35.5-9.812 9.81-25.722 9.807-35.53 0-8.86-8.858-9.69-22.68-2.532-32.5l38.938-100.844a322.02 322.02 0 0 0-20.25-25.937zM51.842 118.72c-8.46 17.373-15.76 36.198-21.187 56.436-14.108 52.617-13.96 103.682-2.812 143.438 13.3-2.605 26.442-3.96 39.312-4.03 1.855-.012 3.688.02 5.53.06 20.857.48 40.98 4.332 59.97 11.5a355.064 355.064 0 0 1-1.656-34.218c0-27.8 3.135-54.377 9-78.937l2.47-10.407 9.655 4.562c29.467 13.98 66.194 23.424 106.28 25.22 5.136-20.05 8.19-39.78 9.408-58.75-35.198 4.83-75.387 2.766-116.407-8.22-38.363-10.272-72.314-26.78-99.562-46.656zm230.594 82.218c-1.535 10.452-3.615 21.03-6.218 31.687a312.754 312.754 0 0 0 46-3.97 24.98 24.98 0 0 1-1.532-21.748l-38.25-5.97zM105 201.375l4.156 18.22-21.594 4.905c8.75 5.174 13.353 15.703 10.594 26-3.32 12.394-16.045 19.758-28.437 16.438-12.394-3.32-19.76-16.075-16.44-28.47a23.235 23.235 0 0 1 3.126-6.874l-21.062 4.78-4.125-18.218 73.78-16.78zm388.594 22.813c-25.53 25.46-55.306 45.445-86.906 60.5.05 2.397.093 4.8.093 7.218 0 9.188-.354 18.232-1.03 27.125 16.635 1.33 32.045-1.7 45.344-9.374 25.925-14.962 40.608-45.694 42.5-85.47zm-338.844 3c-4.03 19.993-6.33 41.31-6.406 63.593l.125-.342c30.568 10.174 62.622 17.572 95.25 21.375l7.5.875.718 7.5 5.687 60.125-18.625 1.75-2.53-26.75a23.117 23.117 0 0 1-14.845.968c-12.393-3.32-19.76-16.042-16.438-28.436.285-1.06.647-2.08 1.063-3.063a496.627 496.627 0 0 1-57.406-14.53c2.69 49.62 16.154 94.04 36.094 126.656 22.366 36.588 52.13 57.78 83.968 57.78 31.838.003 61.602-21.19 83.97-57.78 19.536-31.96 32.846-75.244 35.905-123.656a499.132 499.132 0 0 1-48.25 11.656c1.914 4.57 2.415 9.78 1.033 14.938-3.322 12.394-16.045 19.758-28.438 16.437a23.01 23.01 0 0 1-2.125-.686l-2.5 26.47-18.594-1.752 5.688-60.125.72-7.5 7.498-.875c29.245-3.407 57.995-9.717 85.657-18.312v-1.594c0-21.573-2.27-42.23-6.064-61.75C351.132 242.653 313.092 250 272.312 250c-43.59 0-83.986-8.658-117.562-22.813zm-87.5 105.968c-10.87.102-21.995 1.22-33.375 3.313 12.695 31.62 33.117 53.07 59 60 16.9 4.523 34.896 2.536 52.813-5.25-4.382-13.89-7.874-28.606-10.344-43.97-21.115-9.623-43.934-14.32-68.094-14.094zm137.5 80.22h130.813c-40.082 44.594-92.623 42.844-130.813 0z"
fill-opacity="1"
style="fill: currentColor; stroke: #ce47eb; stroke-width: 10px;"
></path></g
></svg
<g class="" transform="translate(0,0)" style=""
><path
d="M418.813 30.625c-21.178 26.27-49.712 50.982-84.125 70.844-36.778 21.225-75.064 33.62-110.313 38.06a310.317 310.317 0 0 0 6.813 18.25c16.01.277 29.366-.434 36.406-1.5l9.47-1.53 8.436-1.28.22 10.186a307.48 307.48 0 0 1-1.095 18.72l56.625 8.843c.86-.095 1.713-.15 2.563-.157 11.188-.114 21.44 7.29 24.468 18.593.657 2.448.922 4.903.845 7.313 5.972-2.075 11.753-4.305 17.28-6.72l9.595-4.188 2.313 10.22a340.211 340.211 0 0 1 7.375 48.062C438.29 247.836 468.438 225.71 493 197.5c-3.22-36.73-16.154-78.04-39.125-117.813a290.509 290.509 0 0 0-2.22-3.78l-27.56 71.374c5.154.762 10.123 3.158 14.092 7.126 9.81 9.807 9.813 25.69 0 35.5-9.812 9.81-25.722 9.807-35.53 0-8.86-8.858-9.69-22.68-2.532-32.5l38.938-100.844a322.02 322.02 0 0 0-20.25-25.937zM51.842 118.72c-8.46 17.373-15.76 36.198-21.187 56.436-14.108 52.617-13.96 103.682-2.812 143.438 13.3-2.605 26.442-3.96 39.312-4.03 1.855-.012 3.688.02 5.53.06 20.857.48 40.98 4.332 59.97 11.5a355.064 355.064 0 0 1-1.656-34.218c0-27.8 3.135-54.377 9-78.937l2.47-10.407 9.655 4.562c29.467 13.98 66.194 23.424 106.28 25.22 5.136-20.05 8.19-39.78 9.408-58.75-35.198 4.83-75.387 2.766-116.407-8.22-38.363-10.272-72.314-26.78-99.562-46.656zm230.594 82.218c-1.535 10.452-3.615 21.03-6.218 31.687a312.754 312.754 0 0 0 46-3.97 24.98 24.98 0 0 1-1.532-21.748l-38.25-5.97zM105 201.375l4.156 18.22-21.594 4.905c8.75 5.174 13.353 15.703 10.594 26-3.32 12.394-16.045 19.758-28.437 16.438-12.394-3.32-19.76-16.075-16.44-28.47a23.235 23.235 0 0 1 3.126-6.874l-21.062 4.78-4.125-18.218 73.78-16.78zm388.594 22.813c-25.53 25.46-55.306 45.445-86.906 60.5.05 2.397.093 4.8.093 7.218 0 9.188-.354 18.232-1.03 27.125 16.635 1.33 32.045-1.7 45.344-9.374 25.925-14.962 40.608-45.694 42.5-85.47zm-338.844 3c-4.03 19.993-6.33 41.31-6.406 63.593l.125-.342c30.568 10.174 62.622 17.572 95.25 21.375l7.5.875.718 7.5 5.687 60.125-18.625 1.75-2.53-26.75a23.117 23.117 0 0 1-14.845.968c-12.393-3.32-19.76-16.042-16.438-28.436.285-1.06.647-2.08 1.063-3.063a496.627 496.627 0 0 1-57.406-14.53c2.69 49.62 16.154 94.04 36.094 126.656 22.366 36.588 52.13 57.78 83.968 57.78 31.838.003 61.602-21.19 83.97-57.78 19.536-31.96 32.846-75.244 35.905-123.656a499.132 499.132 0 0 1-48.25 11.656c1.914 4.57 2.415 9.78 1.033 14.938-3.322 12.394-16.045 19.758-28.438 16.437a23.01 23.01 0 0 1-2.125-.686l-2.5 26.47-18.594-1.752 5.688-60.125.72-7.5 7.498-.875c29.245-3.407 57.995-9.717 85.657-18.312v-1.594c0-21.573-2.27-42.23-6.064-61.75C351.132 242.653 313.092 250 272.312 250c-43.59 0-83.986-8.658-117.562-22.813zm-87.5 105.968c-10.87.102-21.995 1.22-33.375 3.313 12.695 31.62 33.117 53.07 59 60 16.9 4.523 34.896 2.536 52.813-5.25-4.382-13.89-7.874-28.606-10.344-43.97-21.115-9.623-43.934-14.32-68.094-14.094zm137.5 80.22h130.813c-40.082 44.594-92.623 42.844-130.813 0z"
fill-opacity="1"
style="fill: currentColor; stroke: #ce47eb; stroke-width: 10px;"
></path></g
></svg
>

View File

@@ -1,109 +1,107 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import { browser } from "$app/environment";
import { onMount, onDestroy } from "svelte";
import Button from "../ui/button/button.svelte";
import { _ } from "svelte-i18n";
import { browser } from "$app/environment";
import { onMount, onDestroy } from "svelte";
import Button from "../ui/button/button.svelte";
const { images = [] } = $props();
const { images = [] } = $props();
let isViewerOpen = $state(false);
let currentImageIndex = $state(0);
let imageLoading = $state(false);
let isViewerOpen = $state(false);
let currentImageIndex = $state(0);
let imageLoading = $state(false);
let currentImage = $derived(images[currentImageIndex]);
let canGoPrev = $derived(currentImageIndex > 0);
let canGoNext = $derived(currentImageIndex < images.length - 1);
let currentImage = $derived(images[currentImageIndex]);
let canGoPrev = $derived(currentImageIndex > 0);
let canGoNext = $derived(currentImageIndex < images.length - 1);
function openViewer(index) {
currentImageIndex = index;
isViewerOpen = true;
imageLoading = true;
document.body.style.overflow = "hidden";
}
function openViewer(index) {
currentImageIndex = index;
isViewerOpen = true;
imageLoading = true;
document.body.style.overflow = "hidden";
}
function closeViewer() {
isViewerOpen = false;
document.body.style.overflow = "";
}
function closeViewer() {
isViewerOpen = false;
document.body.style.overflow = "";
}
function navigatePrev() {
if (canGoPrev) {
currentImageIndex--;
imageLoading = true;
}
}
function navigatePrev() {
if (canGoPrev) {
currentImageIndex--;
imageLoading = true;
}
}
function navigateNext() {
if (canGoNext) {
currentImageIndex++;
imageLoading = true;
}
}
function navigateNext() {
if (canGoNext) {
currentImageIndex++;
imageLoading = true;
}
}
function downloadImage() {
const link = document.createElement("a");
link.href = currentImage.url;
link.download = currentImage.title.replace(/\\s+/g, "_") + ".jpg";
link.target = "_blank";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function downloadImage() {
const link = document.createElement("a");
link.href = currentImage.url;
link.download = currentImage.title.replace(/\\s+/g, "_") + ".jpg";
link.target = "_blank";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function handleKeydown(event) {
if (!isViewerOpen) return;
function handleKeydown(event) {
if (!isViewerOpen) return;
switch (event.key) {
case "ArrowLeft":
event.preventDefault();
navigatePrev();
break;
case "ArrowRight":
event.preventDefault();
navigateNext();
break;
case "Escape":
event.preventDefault();
closeViewer();
break;
case "d":
case "D":
event.preventDefault();
downloadImage();
break;
}
}
switch (event.key) {
case "ArrowLeft":
event.preventDefault();
navigatePrev();
break;
case "ArrowRight":
event.preventDefault();
navigateNext();
break;
case "Escape":
event.preventDefault();
closeViewer();
break;
case "d":
case "D":
event.preventDefault();
downloadImage();
break;
}
}
function handleImageLoad() {
imageLoading = false;
}
function handleImageLoad() {
imageLoading = false;
}
onMount(() => {
if (!browser) {
return;
}
window.addEventListener("keydown", handleKeydown);
// Preload images
images.forEach((img) => {
const preload = new Image();
preload.src = img.url;
});
});
onMount(() => {
if (!browser) {
return;
}
window.addEventListener("keydown", handleKeydown);
// Preload images
images.forEach((img) => {
const preload = new Image();
preload.src = img.url;
});
});
onDestroy(() => {
if (!browser) {
return;
}
window.removeEventListener("keydown", handleKeydown);
document.body.style.overflow = "";
});
onDestroy(() => {
if (!browser) {
return;
}
window.removeEventListener("keydown", handleKeydown);
document.body.style.overflow = "";
});
</script>
<!-- Gallery Grid -->
<div class="w-full mx-auto">
<div
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 animate-fade-in"
>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 animate-fade-in">
{#each images as image, index (index)}
<button
onclick={() => openViewer(index)}
@@ -145,14 +143,9 @@ onDestroy(() => {
<!-- Image Viewer Modal -->
{#if isViewerOpen}
<div
class="fixed inset-0 z-50 flex items-center justify-center animate-fade-in"
>
<div class="fixed inset-0 z-50 flex items-center justify-center animate-fade-in">
<!-- Backdrop -->
<div
class="absolute inset-0 bg-black/95 backdrop-blur-xl"
onclick={closeViewer}
></div>
<div class="absolute inset-0 bg-black/95 backdrop-blur-xl" onclick={closeViewer}></div>
<!-- Viewer Content -->
<div class="relative w-[90vw] h-[90vh] flex flex-col animate-slide-up">
@@ -166,9 +159,9 @@ onDestroy(() => {
<div class="text-primary font-medium mb-3">
{$_("image_viewer.index", {
values: {
index: currentImageIndex + 1,
size: images.length
}
index: currentImageIndex + 1,
size: images.length,
},
})}
</div>
<p class="text-zinc-400 max-w-2xl">

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import PeonyIcon from "../icon/peony-icon.svelte";
import { _ } from "svelte-i18n";
import PeonyIcon from "../icon/peony-icon.svelte";
const { hideName = false } = $props();
const { hideName = false } = $props();
</script>
<div class="relative">
@@ -11,11 +11,11 @@ const { hideName = false } = $props();
<span
class={`logo text-3xl text-foreground opacity-90 tracking-wide font-extrabold drop-shadow-x ${hideName ? "hidden sm:inline-block" : ""}`}
>
{$_('brand.name')}
{$_("brand.name")}
</span>
<style>
.logo {
font-family: 'Dancing Script', cursive;
font-family: "Dancing Script", cursive;
}
</style>

View File

@@ -1,148 +1,184 @@
<script lang="ts">
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
import { getUserInitials } from "$lib/utils";
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
import { getUserInitials } from "$lib/utils";
interface User {
name?: string;
email: string;
avatar?: string;
}
interface User {
name?: string;
email: string;
avatar?: string;
}
interface Props {
user: User;
onLogout: () => void;
}
interface Props {
user: User;
onLogout: () => void;
}
let { user, onLogout }: Props = $props();
let { user, onLogout }: Props = $props();
let isDragging = $state(false);
let slidePosition = $state(0);
let startX = 0;
let currentX = 0;
let maxSlide = 117; // Maximum slide distance
let threshold = 0.75; // 70% threshold to trigger logout
let isDragging = $state(false);
let slidePosition = $state(0);
let startX = 0;
let currentX = 0;
let maxSlide = 117; // Maximum slide distance
let threshold = 0.75; // 70% threshold to trigger logout
// Calculate slide progress (0 to 1)
const slideProgress = $derived(Math.min(slidePosition / maxSlide, 1));
const isNearThreshold = $derived(slideProgress > threshold);
// Calculate slide progress (0 to 1)
const slideProgress = $derived(Math.min(slidePosition / maxSlide, 1));
const isNearThreshold = $derived(slideProgress > threshold);
const handleStart = (clientX: number) => {
isDragging = true;
startX = clientX;
currentX = clientX;
};
const handleStart = (clientX: number) => {
isDragging = true;
startX = clientX;
currentX = clientX;
};
const handleMove = (clientX: number) => {
if (!isDragging) return;
const handleMove = (clientX: number) => {
if (!isDragging) return;
currentX = clientX;
const deltaX = currentX - startX;
slidePosition = Math.max(0, Math.min(deltaX, maxSlide));
};
currentX = clientX;
const deltaX = currentX - startX;
slidePosition = Math.max(0, Math.min(deltaX, maxSlide));
};
const handleEnd = () => {
if (!isDragging) return;
const handleEnd = () => {
if (!isDragging) return;
isDragging = false;
isDragging = false;
if (slideProgress >= threshold) {
// Trigger logout
slidePosition = maxSlide;
onLogout();
} else {
// Snap back
slidePosition = 0;
}
};
if (slideProgress >= threshold) {
// Trigger logout
slidePosition = maxSlide;
onLogout();
} else {
// Snap back
slidePosition = 0;
}
};
// Mouse events
const handleMouseDown = (e: MouseEvent) => {
e.preventDefault();
handleStart(e.clientX);
};
// Mouse events
const handleMouseDown = (e: MouseEvent) => {
e.preventDefault();
handleStart(e.clientX);
};
const handleMouseMove = (e: MouseEvent) => {
handleMove(e.clientX);
};
const handleMouseMove = (e: MouseEvent) => {
handleMove(e.clientX);
};
const handleMouseUp = () => {
handleEnd();
};
const handleMouseUp = () => {
handleEnd();
};
// Touch events
const handleTouchStart = (e: TouchEvent) => {
handleStart(e.touches[0].clientX);
};
// Touch events
const handleTouchStart = (e: TouchEvent) => {
handleStart(e.touches[0].clientX);
};
const handleTouchMove = (e: TouchEvent) => {
e.preventDefault();
handleMove(e.touches[0].clientX);
};
const handleTouchMove = (e: TouchEvent) => {
e.preventDefault();
handleMove(e.touches[0].clientX);
};
const handleTouchEnd = () => {
handleEnd();
};
const handleTouchEnd = () => {
handleEnd();
};
// Add global event listeners when dragging
$effect(() => {
if (isDragging) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.addEventListener("touchmove", handleTouchMove, { passive: false });
document.addEventListener("touchend", handleTouchEnd);
// Add global event listeners when dragging
$effect(() => {
if (isDragging) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.addEventListener("touchmove", handleTouchMove, { passive: false });
document.addEventListener("touchend", handleTouchEnd);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("touchmove", handleTouchMove);
document.removeEventListener("touchend", handleTouchEnd);
};
}
});
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("touchmove", handleTouchMove);
document.removeEventListener("touchend", handleTouchEnd);
};
}
});
</script>
<div
class="relative h-10 w-40 rounded-full bg-muted/30 overflow-hidden select-none transition-all duration-300 bg-muted/40 shadow-lg shadow-accent/10 {isDragging ? 'cursor-grabbing' : ''}"
style="background: linear-gradient(90deg,
class="relative h-10 w-40 rounded-full bg-muted/30 overflow-hidden select-none transition-all duration-300 bg-muted/40 shadow-lg shadow-accent/10 {isDragging
? 'cursor-grabbing'
: ''}"
style="background: linear-gradient(90deg,
oklch(var(--primary) / 0.3) 0%,
oklch(var(--primary) / 0.3) {(1 - slideProgress) * 100}%,
oklch(var(--accent) / {0.1 + slideProgress * 0.2}) {(1 - slideProgress) * 100}%,
oklch(var(--accent) / {0.2 + slideProgress * 0.3}) 100%
)"
>
<!-- Background slide indicator -->
<div
class="absolute inset-0 rounded-full transition-all duration-200"
style="background: linear-gradient(90deg,
<!-- Background slide indicator -->
<div
class="absolute inset-0 rounded-full transition-all duration-200"
style="background: linear-gradient(90deg,
transparent 0%,
transparent {Math.max(0, slideProgress * 100 - 20)}%,
oklch(var(--accent) / {slideProgress * 0.1}) {slideProgress * 100}%,
oklch(var(--accent) / {slideProgress * 0.2}) 100%
)"
></div>
></div>
<!-- Sliding user info -->
<button class="cursor-grab absolute left-0 top-0 h-full flex items-center gap-3 px-2 transition-all duration-200 ease-out rounded-full bg-background/80 backdrop-blur-sm border border-border/50 bg-background/90 border-primary/20 {isDragging ? '' : 'transition-all duration-300 ease-out'}" style="transform: translateX({slidePosition}px); width: calc(100% - {slidePosition}px);" onmousedown={handleMouseDown} ontouchstart={handleTouchStart}>
<Avatar class="h-7 w-7 ring-2 ring-accent/20 transition-all duration-200 {isNearThreshold ? 'ring-destructive/40' : ''}" style="opacity: {Math.max(0.1, 1 - slideProgress * 1.8)}">
<AvatarImage src={user.avatar} alt={user.name || user.email} />
<AvatarFallback class="bg-gradient-to-br from-primary to-accent text-primary-foreground text-xs font-semibold transition-all duration-200 {isNearThreshold ? 'from-destructive to-destructive/80' : ''}">
{getUserInitials(user.name || user.email)}
</AvatarFallback>
</Avatar>
<div class="text-left flex flex-col min-w-0 flex-1">
<span class="text-sm font-medium text-foreground leading-none truncate transition-all duration-200 {isNearThreshold ? 'text-destructive' : ''}" style="opacity: {Math.max(0.15, 1 - slideProgress * 1.5)}">{user?.name ? user.name.split(" ")[0] : "User"}</span>
<span class="text-xs text-muted-foreground leading-none transition-all duration-200 {isNearThreshold ? 'text-destructive/70' : ''}" style="opacity: {Math.max(0.1, 1 - slideProgress * 1.8)}">
{slideProgress > 0.3 ? "Logout" : "Online"}
</span>
</div>
</button>
<!-- Sliding user info -->
<button
class="cursor-grab absolute left-0 top-0 h-full flex items-center gap-3 px-2 transition-all duration-200 ease-out rounded-full bg-background/80 backdrop-blur-sm border border-border/50 bg-background/90 border-primary/20 {isDragging
? ''
: 'transition-all duration-300 ease-out'}"
style="transform: translateX({slidePosition}px); width: calc(100% - {slidePosition}px);"
onmousedown={handleMouseDown}
ontouchstart={handleTouchStart}
>
<Avatar
class="h-7 w-7 ring-2 ring-accent/20 transition-all duration-200 {isNearThreshold
? 'ring-destructive/40'
: ''}"
style="opacity: {Math.max(0.1, 1 - slideProgress * 1.8)}"
>
<AvatarImage src={user.avatar} alt={user.name || user.email} />
<AvatarFallback
class="bg-gradient-to-br from-primary to-accent text-primary-foreground text-xs font-semibold transition-all duration-200 {isNearThreshold
? 'from-destructive to-destructive/80'
: ''}"
>
{getUserInitials(user.name || user.email)}
</AvatarFallback>
</Avatar>
<div class="text-left flex flex-col min-w-0 flex-1">
<span
class="text-sm font-medium text-foreground leading-none truncate transition-all duration-200 {isNearThreshold
? 'text-destructive'
: ''}"
style="opacity: {Math.max(0.15, 1 - slideProgress * 1.5)}"
>{user?.name ? user.name.split(" ")[0] : "User"}</span
>
<span
class="text-xs text-muted-foreground leading-none transition-all duration-200 {isNearThreshold
? 'text-destructive/70'
: ''}"
style="opacity: {Math.max(0.1, 1 - slideProgress * 1.8)}"
>
{slideProgress > 0.3 ? "Logout" : "Online"}
</span>
</div>
</button>
<!-- Logout icon area -->
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center w-8 h-8 rounded-full transition-all duration-200 {isNearThreshold ? 'bg-destructive text-destructive-foreground scale-110' : 'bg-transparent text-foreground'}">
<span class="icon-[ri--logout-circle-r-line] h-4 w-4 transition-transform duration-200 {isNearThreshold ? 'scale-110' : ''}" ></span>
</div>
<!-- Logout icon area -->
<div
class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center w-8 h-8 rounded-full transition-all duration-200 {isNearThreshold
? 'bg-destructive text-destructive-foreground scale-110'
: 'bg-transparent text-foreground'}"
>
<span
class="icon-[ri--logout-circle-r-line] h-4 w-4 transition-transform duration-200 {isNearThreshold
? 'scale-110'
: ''}"
></span>
</div>
<!-- Progress indicator -->
<!-- <div class="absolute bottom-0 left-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-200 rounded-full" style="width: {slideProgress * 100}%"></div> -->
<!-- Progress indicator -->
<!-- <div class="absolute bottom-0 left-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-200 rounded-full" style="width: {slideProgress * 100}%"></div> -->
</div>

View File

@@ -1,27 +1,24 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import { env } from "$env/dynamic/public";
import { _ } from "svelte-i18n";
import { env } from "$env/dynamic/public";
interface Props {
title: string;
description: string;
image?: string;
}
interface Props {
title: string;
description: string;
image?: string;
}
let {
title,
description,
image = `${env.PUBLIC_URL || "http://localhost:3000"}/img/kamasutra.jpg`,
}: Props = $props();
let {
title,
description,
image = `${env.PUBLIC_URL || "http://localhost:3000"}/img/kamasutra.jpg`,
}: Props = $props();
</script>
<svelte:head>
<title>{$_("head.title", { values: { title } })}</title>
<meta name="description" content={description} />
<meta
property="og:title"
content={$_("head.title", { values: { title } })}
/>
<meta property="og:description" content={description} />
<meta property="og:image" content={image} />
<title>{$_("head.title", { values: { title } })}</title>
<meta name="description" content={description} />
<meta property="og:title" content={$_("head.title", { values: { title } })} />
<meta property="og:description" content={description} />
<meta property="og:image" content={image} />
</svelte:head>

View File

@@ -1,180 +1,163 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
import { Button } from "$lib/components/ui/button";
import type { Recording } from "$lib/types";
import { cn } from "$lib/utils";
import { _ } from "svelte-i18n";
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
import { Button } from "$lib/components/ui/button";
import type { Recording } from "$lib/types";
import { cn } from "$lib/utils";
interface Props {
recording: Recording;
onPlay?: (id: string) => void;
onDelete?: (id: string) => void;
}
interface Props {
recording: Recording;
onPlay?: (id: string) => void;
onDelete?: (id: string) => void;
}
let { recording, onPlay, onDelete }: Props = $props();
let { recording, onPlay, onDelete }: Props = $props();
function formatDuration(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
function formatDuration(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
function getStatusColor(status: string): string {
switch (status) {
case "published":
return "text-green-400 bg-green-400/20";
case "draft":
return "text-yellow-400 bg-yellow-400/20";
case "archived":
return "text-red-400 bg-red-400/20";
default:
return "text-gray-400 bg-gray-400/20";
}
}
function getStatusColor(status: string): string {
switch (status) {
case "published":
return "text-green-400 bg-green-400/20";
case "draft":
return "text-yellow-400 bg-yellow-400/20";
case "archived":
return "text-red-400 bg-red-400/20";
default:
return "text-gray-400 bg-gray-400/20";
}
}
</script>
<Card
class="group hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 border-border/50 hover:border-primary/30 bg-card/50 backdrop-blur-sm"
class="group hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 border-border/50 hover:border-primary/30 bg-card/50 backdrop-blur-sm"
>
<CardHeader class="pb-3">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<h3
class="font-semibold text-card-foreground group-hover:text-primary transition-colors"
>
{recording.title}
</h3>
<span
class={cn(
"text-xs px-2 py-0.5 rounded-full",
getStatusColor(recording.status),
)}
>
{$_(`recording_card.status_${recording.status}`)}
</span>
</div>
{#if recording.description}
<p class="text-sm text-muted-foreground line-clamp-2">
{recording.description}
</p>
{/if}
</div>
</div>
</CardHeader>
<CardHeader class="pb-3">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<h3 class="font-semibold text-card-foreground group-hover:text-primary transition-colors">
{recording.title}
</h3>
<span class={cn("text-xs px-2 py-0.5 rounded-full", getStatusColor(recording.status))}>
{$_(`recording_card.status_${recording.status}`)}
</span>
</div>
{#if recording.description}
<p class="text-sm text-muted-foreground line-clamp-2">
{recording.description}
</p>
{/if}
</div>
</div>
</CardHeader>
<CardContent class="space-y-4">
<!-- Stats Grid -->
<div class="grid grid-cols-3 gap-3">
<div
class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30"
>
<span class="icon-[ri--time-line] w-4 h-4 text-primary mb-1"></span>
<span class="text-xs text-muted-foreground"
>{$_("recording_card.duration")}</span
>
<span class="font-medium text-sm">{formatDuration(recording.duration)}</span>
</div>
<div
class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30"
>
<span class="icon-[ri--pulse-line] w-4 h-4 text-accent mb-1"></span>
<span class="text-xs text-muted-foreground">{$_("recording_card.events")}</span>
<span class="font-medium text-sm">{recording.events.length}</span>
</div>
<div
class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30"
>
<span class="icon-[ri--gamepad-line] w-4 h-4 text-primary mb-1"></span>
<span class="text-xs text-muted-foreground">{$_("recording_card.devices")}</span>
<span class="font-medium text-sm">{recording.device_info.length}</span>
</div>
</div>
<CardContent class="space-y-4">
<!-- Stats Grid -->
<div class="grid grid-cols-3 gap-3">
<div class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30">
<span class="icon-[ri--time-line] w-4 h-4 text-primary mb-1"></span>
<span class="text-xs text-muted-foreground">{$_("recording_card.duration")}</span>
<span class="font-medium text-sm">{formatDuration(recording.duration)}</span>
</div>
<div class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30">
<span class="icon-[ri--pulse-line] w-4 h-4 text-accent mb-1"></span>
<span class="text-xs text-muted-foreground">{$_("recording_card.events")}</span>
<span class="font-medium text-sm">{recording.events.length}</span>
</div>
<div class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30">
<span class="icon-[ri--gamepad-line] w-4 h-4 text-primary mb-1"></span>
<span class="text-xs text-muted-foreground">{$_("recording_card.devices")}</span>
<span class="font-medium text-sm">{recording.device_info.length}</span>
</div>
</div>
<!-- Device Info -->
<div class="space-y-1">
{#each recording.device_info.slice(0, 2) as device (device.name)}
<div
class="flex items-center gap-2 text-xs text-muted-foreground bg-muted/20 rounded px-2 py-1"
>
<span class="icon-[ri--rocket-line] w-3 h-3"></span>
<span>{device.name}</span>
<span class="text-xs opacity-60">{device.capabilities.join(", ")}</span>
</div>
{/each}
{#if recording.device_info.length > 2}
<div class="text-xs text-muted-foreground/60 px-2">
+{recording.device_info.length - 2} more device{recording.device_info.length -
2 >
1
? "s"
: ""}
</div>
{/if}
</div>
<!-- Device Info -->
<div class="space-y-1">
{#each recording.device_info.slice(0, 2) as device (device.name)}
<div
class="flex items-center gap-2 text-xs text-muted-foreground bg-muted/20 rounded px-2 py-1"
>
<span class="icon-[ri--rocket-line] w-3 h-3"></span>
<span>{device.name}</span>
<span class="text-xs opacity-60">{device.capabilities.join(", ")}</span>
</div>
{/each}
{#if recording.device_info.length > 2}
<div class="text-xs text-muted-foreground/60 px-2">
+{recording.device_info.length - 2} more device{recording.device_info.length - 2 > 1
? "s"
: ""}
</div>
{/if}
</div>
<!-- Tags -->
{#if recording.tags && recording.tags.length > 0}
<div class="flex flex-wrap gap-1">
{#each recording.tags as tag (tag)}
<span
class="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary border border-primary/20"
>
{tag}
</span>
{/each}
</div>
{/if}
<!-- Tags -->
{#if recording.tags && recording.tags.length > 0}
<div class="flex flex-wrap gap-1">
{#each recording.tags as tag (tag)}
<span
class="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary border border-primary/20"
>
{tag}
</span>
{/each}
</div>
{/if}
<!-- Metadata -->
<div class="flex items-center justify-between text-xs text-muted-foreground pt-2">
<div class="flex items-center gap-3">
<span>
{new Date(recording.date_created).toLocaleDateString()}
</span>
{#if recording.public}
<span class="flex items-center gap-1">
<span class="icon-[ri--global-line] w-3 h-3"></span>
{$_("recording_card.public")}
</span>
{:else}
<span class="flex items-center gap-1">
<span class="icon-[ri--lock-line] w-3 h-3"></span>
{$_("recording_card.private")}
</span>
{/if}
</div>
{#if recording.linked_video}
<span class="flex items-center gap-1 text-accent">
<span class="icon-[ri--video-line] w-3 h-3"></span>
{$_("recording_card.linked_video")}
</span>
{/if}
</div>
<!-- Metadata -->
<div class="flex items-center justify-between text-xs text-muted-foreground pt-2">
<div class="flex items-center gap-3">
<span>
{new Date(recording.date_created).toLocaleDateString()}
</span>
{#if recording.public}
<span class="flex items-center gap-1">
<span class="icon-[ri--global-line] w-3 h-3"></span>
{$_("recording_card.public")}
</span>
{:else}
<span class="flex items-center gap-1">
<span class="icon-[ri--lock-line] w-3 h-3"></span>
{$_("recording_card.private")}
</span>
{/if}
</div>
{#if recording.linked_video}
<span class="flex items-center gap-1 text-accent">
<span class="icon-[ri--video-line] w-3 h-3"></span>
{$_("recording_card.linked_video")}
</span>
{/if}
</div>
<!-- Actions -->
<div class="flex gap-2 pt-2">
{#if onPlay}
<Button
size="sm"
onclick={() => onPlay?.(recording.id)}
class="flex-1 cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
<span class="icon-[ri--play-fill] w-4 h-4 mr-1"></span>
{$_("recording_card.play")}
</Button>
{/if}
{#if onDelete}
<Button
size="sm"
variant="outline"
onclick={() => onDelete?.(recording.id)}
class="cursor-pointer border-destructive/20 hover:bg-destructive/10 hover:text-destructive"
>
<span class="icon-[ri--delete-bin-line] w-4 h-4"></span>
</Button>
{/if}
</div>
</CardContent>
<!-- Actions -->
<div class="flex gap-2 pt-2">
{#if onPlay}
<Button
size="sm"
onclick={() => onPlay?.(recording.id)}
class="flex-1 cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
<span class="icon-[ri--play-fill] w-4 h-4 mr-1"></span>
{$_("recording_card.play")}
</Button>
{/if}
{#if onDelete}
<Button
size="sm"
variant="outline"
onclick={() => onDelete?.(recording.id)}
class="cursor-pointer border-destructive/20 hover:bg-destructive/10 hover:text-destructive"
>
<span class="icon-[ri--delete-bin-line] w-4 h-4"></span>
</Button>
{/if}
</div>
</CardContent>
</Card>

View File

@@ -1,17 +1,17 @@
<script lang="ts">
interface Props {
onclick: () => void;
icon: string;
label: string;
}
interface Props {
onclick: () => void;
icon: string;
label: string;
}
let { onclick, icon, label }: Props = $props();
let { onclick, icon, label }: Props = $props();
</script>
<button
{onclick}
aria-label={label}
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors cursor-pointer"
{onclick}
aria-label={label}
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors cursor-pointer"
>
<span class={icon + " w-4 h-4 text-primary"}></span>
<span class={icon + " w-4 h-4 text-primary"}></span>
</button>

View File

@@ -1,110 +1,110 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import ShareButton from "./share-button.svelte";
import { toast } from "svelte-sonner";
import type { ShareContent } from "$lib/types";
import { _ } from "svelte-i18n";
import ShareButton from "./share-button.svelte";
import { toast } from "svelte-sonner";
import type { ShareContent } from "$lib/types";
interface Props {
content: ShareContent;
}
interface Props {
content: ShareContent;
}
let { content }: Props = $props();
let { content }: Props = $props();
// Share handlers
const shareToX = () => {
const text = `${content.title} - ${content.description}`;
const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(content.url)}`;
window.open(url, "_blank", "width=600,height=400");
toast.success($_("sharing_popup.success.x"));
};
// Share handlers
const shareToX = () => {
const text = `${content.title} - ${content.description}`;
const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(content.url)}`;
window.open(url, "_blank", "width=600,height=400");
toast.success($_("sharing_popup.success.x"));
};
const shareToFacebook = () => {
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(content.url)}&quote=${encodeURIComponent(content.title)}`;
window.open(url, "_blank", "width=600,height=400");
toast.success($_("sharing_popup.success.facebook"));
};
const shareToFacebook = () => {
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(content.url)}&quote=${encodeURIComponent(content.title)}`;
window.open(url, "_blank", "width=600,height=400");
toast.success($_("sharing_popup.success.facebook"));
};
const shareViaEmail = () => {
const subject = encodeURIComponent(content.title);
const body = encodeURIComponent(`${content.description}\n\n${content.url}`);
const url = `mailto:?subject=${subject}&body=${body}`;
window.location.href = url;
toast.success($_("sharing_popup.success.email"));
};
const shareViaEmail = () => {
const subject = encodeURIComponent(content.title);
const body = encodeURIComponent(`${content.description}\n\n${content.url}`);
const url = `mailto:?subject=${subject}&body=${body}`;
window.location.href = url;
toast.success($_("sharing_popup.success.email"));
};
const shareToWhatsApp = () => {
const text = `${content.title}\n\n${content.description}\n\n${content.url}`;
const url = `https://wa.me/?text=${encodeURIComponent(text)}`;
window.open(url, "_blank");
toast.success($_("sharing_popup.success.whatsapp"));
};
const shareToWhatsApp = () => {
const text = `${content.title}\n\n${content.description}\n\n${content.url}`;
const url = `https://wa.me/?text=${encodeURIComponent(text)}`;
window.open(url, "_blank");
toast.success($_("sharing_popup.success.whatsapp"));
};
const shareToTelegram = () => {
const text = `${content.title}\n\n${content.description}`;
const url = `https://t.me/share/url?url=${encodeURIComponent(content.url)}&text=${encodeURIComponent(text)}`;
window.open(url, "_blank");
toast.success($_("sharing_popup.success.telegram"));
};
const shareToTelegram = () => {
const text = `${content.title}\n\n${content.description}`;
const url = `https://t.me/share/url?url=${encodeURIComponent(content.url)}&text=${encodeURIComponent(text)}`;
window.open(url, "_blank");
toast.success($_("sharing_popup.success.telegram"));
};
const copyLink = async () => {
try {
await navigator.clipboard.writeText(content.url);
toast.success($_("sharing_popup.success.copy"));
} catch {
// Fallback for older browsers
const textArea = document.createElement("textarea");
textArea.value = content.url;
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
toast.success($_("sharing_popup.success.copy"));
}
};
const copyLink = async () => {
try {
await navigator.clipboard.writeText(content.url);
toast.success($_("sharing_popup.success.copy"));
} catch {
// Fallback for older browsers
const textArea = document.createElement("textarea");
textArea.value = content.url;
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
toast.success($_("sharing_popup.success.copy"));
}
};
</script>
<div class="space-y-6">
<div class="text-center space-y-4">
<h4 class="text-sm font-medium text-muted-foreground">
{$_("sharing_popup.subtitle")}
</h4>
<div class="text-center space-y-4">
<h4 class="text-sm font-medium text-muted-foreground">
{$_("sharing_popup.subtitle")}
</h4>
<div class="flex justify-center gap-3 flex-wrap">
<ShareButton
onclick={shareToX}
icon="icon-[ri--twitter-x-line]"
label={$_("sharing_popup.share.x")}
/>
<div class="flex justify-center gap-3 flex-wrap">
<ShareButton
onclick={shareToX}
icon="icon-[ri--twitter-x-line]"
label={$_("sharing_popup.share.x")}
/>
<ShareButton
onclick={shareToFacebook}
icon="icon-[ri--facebook-line]"
label={$_("sharing_popup.share.facebook")}
/>
<ShareButton
onclick={shareToFacebook}
icon="icon-[ri--facebook-line]"
label={$_("sharing_popup.share.facebook")}
/>
<ShareButton
onclick={shareViaEmail}
icon="icon-[ri--mail-line]"
label={$_("sharing_popup.share.email")}
/>
<ShareButton
onclick={shareViaEmail}
icon="icon-[ri--mail-line]"
label={$_("sharing_popup.share.email")}
/>
<ShareButton
onclick={shareToWhatsApp}
icon="icon-[ri--whatsapp-line]"
label={$_("sharing_popup.share.whatsapp")}
/>
<ShareButton
onclick={shareToWhatsApp}
icon="icon-[ri--whatsapp-line]"
label={$_("sharing_popup.share.whatsapp")}
/>
<ShareButton
onclick={shareToTelegram}
icon="icon-[ri--telegram-2-line]"
label={$_("sharing_popup.share.telegram")}
/>
<ShareButton
onclick={shareToTelegram}
icon="icon-[ri--telegram-2-line]"
label={$_("sharing_popup.share.telegram")}
/>
<ShareButton
onclick={copyLink}
icon="icon-[ri--file-copy-line]"
label={$_("sharing_popup.share.copy")}
/>
</div>
<ShareButton
onclick={copyLink}
icon="icon-[ri--file-copy-line]"
label={$_("sharing_popup.share.copy")}
/>
</div>
</div>
</div>

View File

@@ -1,10 +1,10 @@
<script>
import { _ } from "svelte-i18n";
import SharingPopup from "./sharing-popup.svelte";
import Button from "../ui/button/button.svelte";
import { _ } from "svelte-i18n";
import SharingPopup from "./sharing-popup.svelte";
import Button from "../ui/button/button.svelte";
const { content } = $props();
let isPopupOpen = $state(false);
const { content } = $props();
let isPopupOpen = $state(false);
</script>
<Button
@@ -13,6 +13,6 @@ let isPopupOpen = $state(false);
class="flex items-center gap-2 border-primary/20 hover:bg-primary/10 cursor-pointer"
>
<span class="icon-[ri--share-2-line] w-4 h-4"></span>
{$_('sharing_popup_button.share')}
{$_("sharing_popup_button.share")}
</Button>
<SharingPopup bind:open={isPopupOpen} {content} />

View File

@@ -1,89 +1,89 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "$lib/components/ui/dialog";
import { Button } from "$lib/components/ui/button";
import { Separator } from "$lib/components/ui/separator";
import ShareServices from "./share-services.svelte";
import type { Snippet } from "svelte";
import { _ } from "svelte-i18n";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "$lib/components/ui/dialog";
import { Button } from "$lib/components/ui/button";
import { Separator } from "$lib/components/ui/separator";
import ShareServices from "./share-services.svelte";
import type { Snippet } from "svelte";
interface ShareContent {
title: string;
description: string;
url: string;
type: "video" | "model" | "article" | "link";
}
interface ShareContent {
title: string;
description: string;
url: string;
type: "video" | "model" | "article" | "link";
}
interface Props {
open: boolean;
content: ShareContent;
children?: Snippet;
}
interface Props {
open: boolean;
content: ShareContent;
children?: Snippet;
}
let { open = $bindable(), content }: Props = $props();
let { open = $bindable(), content }: Props = $props();
</script>
<Dialog bind:open>
<DialogContent class="sm:max-w-md">
<DialogHeader class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-10 h-10 rounded-full bg-gradient-to-br from-primary to-purple-600 flex items-center justify-center shrink-0 grow-0"
>
<span class="icon-[ri--share-2-line] text-primary-foreground"></span>
</div>
<div class="">
<DialogTitle class="text-left text-xl font-semibold text-primary-foreground"
>{$_("sharing_popup.title")}</DialogTitle
>
<DialogDescription class="text-left text-sm">
{$_("sharing_popup.description", {
values: { type: content.type },
})}
</DialogDescription>
</div>
</div>
</div>
<!-- Content Preview -->
<div class="text-left bg-muted/60 rounded-lg p-4 space-y-2">
<h4 class="font-medium text-sm text-primary-foreground">
{content.title}
</h4>
<p class="text-xs text-muted-foreground">{content.description}</p>
<div class="flex items-center gap-2 text-xs">
<span class="px-2 py-1 bg-primary/10 text-primary rounded-full capitalize">
{content.type}
</span>
<span class="text-muted-foreground text-clip">{content.url}</span>
</div>
</div>
</DialogHeader>
<Separator class="my-4" />
<!-- Share Services -->
<ShareServices {content} />
<Separator class="my-4" />
<!-- Close Button -->
<div class="flex justify-end">
<Button
variant="ghost"
size="sm"
onclick={() => (open = false)}
class="text-muted-foreground hover:text-foreground cursor-pointer"
<DialogContent class="sm:max-w-md">
<DialogHeader class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-10 h-10 rounded-full bg-gradient-to-br from-primary to-purple-600 flex items-center justify-center shrink-0 grow-0"
>
<span class="icon-[ri--share-2-line] text-primary-foreground"></span>
</div>
<div class="">
<DialogTitle class="text-left text-xl font-semibold text-primary-foreground"
>{$_("sharing_popup.title")}</DialogTitle
>
<span class="icon-[ri--close-large-line]"></span>
{$_("sharing_popup.close")}
</Button>
<DialogDescription class="text-left text-sm">
{$_("sharing_popup.description", {
values: { type: content.type },
})}
</DialogDescription>
</div>
</div>
</DialogContent>
</div>
<!-- Content Preview -->
<div class="text-left bg-muted/60 rounded-lg p-4 space-y-2">
<h4 class="font-medium text-sm text-primary-foreground">
{content.title}
</h4>
<p class="text-xs text-muted-foreground">{content.description}</p>
<div class="flex items-center gap-2 text-xs">
<span class="px-2 py-1 bg-primary/10 text-primary rounded-full capitalize">
{content.type}
</span>
<span class="text-muted-foreground text-clip">{content.url}</span>
</div>
</div>
</DialogHeader>
<Separator class="my-4" />
<!-- Share Services -->
<ShareServices {content} />
<Separator class="my-4" />
<!-- Close Button -->
<div class="flex justify-end">
<Button
variant="ghost"
size="sm"
onclick={() => (open = false)}
class="text-muted-foreground hover:text-foreground cursor-pointer"
>
<span class="icon-[ri--close-large-line]"></span>
{$_("sharing_popup.close")}
</Button>
</div>
</DialogContent>
</Dialog>

View File

@@ -1,23 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-description"
class={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className,
)}
{...restProps}
bind:this={ref}
data-slot="alert-description"
class={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className,
)}
{...restProps}
>
{@render children?.()}
{@render children?.()}
</div>

View File

@@ -1,20 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-title"
class={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
{...restProps}
bind:this={ref}
data-slot="alert-title"
class={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
{...restProps}
>
{@render children?.()}
{@render children?.()}
</div>

View File

@@ -1,44 +1,44 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
import { type VariantProps, tv } from "tailwind-variants";
export const alertVariants = tv({
base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
});
export const alertVariants = tv({
base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
});
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
variant?: AlertVariant;
} = $props();
let {
ref = $bindable(null),
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
variant?: AlertVariant;
} = $props();
</script>
<div
bind:this={ref}
data-slot="alert"
class={cn(alertVariants({ variant }), className)}
{...restProps}
role="alert"
bind:this={ref}
data-slot="alert"
class={cn(alertVariants({ variant }), className)}
{...restProps}
role="alert"
>
{@render children?.()}
{@render children?.()}
</div>

View File

@@ -4,11 +4,11 @@ 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,
Root,
Description,
Title,
//
Root as Alert,
Description as AlertDescription,
Title as AlertTitle,
};

View File

@@ -1,17 +1,17 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.FallbackProps = $props();
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.FallbackProps = $props();
</script>
<AvatarPrimitive.Fallback
bind:ref
data-slot="avatar-fallback"
class={cn("bg-muted flex size-full items-center justify-center rounded-full", className)}
{...restProps}
bind:ref
data-slot="avatar-fallback"
class={cn("bg-muted flex size-full items-center justify-center rounded-full", className)}
{...restProps}
/>

View File

@@ -1,17 +1,17 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.ImageProps = $props();
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.ImageProps = $props();
</script>
<AvatarPrimitive.Image
bind:ref
data-slot="avatar-image"
class={cn("aspect-square size-full", className)}
{...restProps}
bind:ref
data-slot="avatar-image"
class={cn("aspect-square size-full", className)}
{...restProps}
/>

View File

@@ -1,19 +1,19 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
loadingStatus = $bindable("loading"),
class: className,
...restProps
}: AvatarPrimitive.RootProps = $props();
let {
ref = $bindable(null),
loadingStatus = $bindable("loading"),
class: className,
...restProps
}: AvatarPrimitive.RootProps = $props();
</script>
<AvatarPrimitive.Root
bind:ref
bind:loadingStatus
data-slot="avatar"
class={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
{...restProps}
bind:ref
bind:loadingStatus
data-slot="avatar"
class={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
{...restProps}
/>

View File

@@ -3,11 +3,11 @@ import Image from "./avatar-image.svelte";
import Fallback from "./avatar-fallback.svelte";
export {
Root,
Image,
Fallback,
//
Root as Avatar,
Image as AvatarImage,
Fallback as AvatarFallback,
Root,
Image,
Fallback,
//
Root as Avatar,
Image as AvatarImage,
Fallback as AvatarFallback,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,19 +7,19 @@ import Title from "./card-title.svelte";
import Action from "./card-action.svelte";
export {
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,20 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.OverlayProps = $props();
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.OverlayProps = $props();
</script>
<DialogPrimitive.Overlay
bind:ref
data-slot="dialog-overlay"
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/50",
className,
)}
{...restProps}
bind:ref
data-slot="dialog-overlay"
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/50",
className,
)}
{...restProps}
/>

View File

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

View File

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

View File

@@ -13,25 +13,25 @@ const Root = DialogPrimitive.Root;
const Portal = DialogPrimitive.Portal;
export {
Root,
Title,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
Close,
//
Root as Dialog,
Title as DialogTitle,
Portal as DialogPortal,
Footer as DialogFooter,
Header as DialogHeader,
Trigger as DialogTrigger,
Overlay as DialogOverlay,
Content as DialogContent,
Description as DialogDescription,
Close as DialogClose,
Root,
Title,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
Close,
//
Root as Dialog,
Title as DialogTitle,
Portal as DialogPortal,
Footer as DialogFooter,
Header as DialogHeader,
Trigger as DialogTrigger,
Overlay as DialogOverlay,
Content as DialogContent,
Description as DialogDescription,
Close as DialogClose,
};

View File

@@ -3,183 +3,174 @@
-->
<script lang="ts">
import { cn } from "$lib/utils/utils";
import UploadIcon from "@lucide/svelte/icons/upload";
import { displaySize } from ".";
import { useId } from "bits-ui";
import type { FileDropZoneProps, FileRejectedReason } from "./types";
import { cn } from "$lib/utils/utils";
import UploadIcon from "@lucide/svelte/icons/upload";
import { displaySize } from ".";
import { useId } from "bits-ui";
import type { FileDropZoneProps, FileRejectedReason } from "./types";
let {
id = useId(),
children,
maxFiles,
maxFileSize,
fileCount,
disabled = false,
onUpload,
onFileRejected,
accept,
class: className,
...rest
}: FileDropZoneProps = $props();
let {
id = useId(),
children,
maxFiles,
maxFileSize,
fileCount,
disabled = false,
onUpload,
onFileRejected,
accept,
class: className,
...rest
}: FileDropZoneProps = $props();
if (maxFiles !== undefined && fileCount === undefined) {
console.warn(
"Make sure to provide FileDropZone with `fileCount` when using the `maxFiles` prompt",
);
}
if (maxFiles !== undefined && fileCount === undefined) {
console.warn(
"Make sure to provide FileDropZone with `fileCount` when using the `maxFiles` prompt",
);
}
let uploading = $state(false);
let uploading = $state(false);
const drop = async (
e: DragEvent & {
currentTarget: EventTarget & HTMLLabelElement;
},
) => {
if (disabled || !canUploadFiles) return;
const drop = async (
e: DragEvent & {
currentTarget: EventTarget & HTMLLabelElement;
},
) => {
if (disabled || !canUploadFiles) return;
e.preventDefault();
e.preventDefault();
const droppedFiles = Array.from(e.dataTransfer?.files ?? []);
const droppedFiles = Array.from(e.dataTransfer?.files ?? []);
await upload(droppedFiles);
};
await upload(droppedFiles);
};
const change = async (
e: Event & {
currentTarget: EventTarget & HTMLInputElement;
},
) => {
if (disabled) return;
const change = async (
e: Event & {
currentTarget: EventTarget & HTMLInputElement;
},
) => {
if (disabled) return;
const selectedFiles = e.currentTarget.files;
const selectedFiles = e.currentTarget.files;
if (!selectedFiles) return;
if (!selectedFiles) return;
await upload(Array.from(selectedFiles));
await upload(Array.from(selectedFiles));
// this if a file fails and we upload the same file again we still get feedback
(e.target as HTMLInputElement).value = "";
};
// this if a file fails and we upload the same file again we still get feedback
(e.target as HTMLInputElement).value = "";
};
const shouldAcceptFile = (
file: File,
fileNumber: number,
): FileRejectedReason | undefined => {
if (maxFileSize !== undefined && file.size > maxFileSize)
return "Maximum file size exceeded";
const shouldAcceptFile = (file: File, fileNumber: number): FileRejectedReason | undefined => {
if (maxFileSize !== undefined && file.size > maxFileSize) return "Maximum file size exceeded";
if (maxFiles !== undefined && fileNumber > maxFiles)
return "Maximum files uploaded";
if (maxFiles !== undefined && fileNumber > maxFiles) return "Maximum files uploaded";
if (!accept) return undefined;
if (!accept) return undefined;
const acceptedTypes = accept.split(",").map((a) => a.trim().toLowerCase());
const fileType = file.type.toLowerCase();
const fileName = file.name.toLowerCase();
const acceptedTypes = accept.split(",").map((a) => a.trim().toLowerCase());
const fileType = file.type.toLowerCase();
const fileName = file.name.toLowerCase();
const isAcceptable = acceptedTypes.some((pattern) => {
// check extension like .mp4
if (fileType.startsWith(".")) {
return fileName.endsWith(pattern);
}
const isAcceptable = acceptedTypes.some((pattern) => {
// check extension like .mp4
if (fileType.startsWith(".")) {
return fileName.endsWith(pattern);
}
// if pattern has wild card like video/*
if (pattern.endsWith("/*")) {
const baseType = pattern.slice(0, pattern.indexOf("/*"));
return fileType.startsWith(baseType + "/");
}
// if pattern has wild card like video/*
if (pattern.endsWith("/*")) {
const baseType = pattern.slice(0, pattern.indexOf("/*"));
return fileType.startsWith(baseType + "/");
}
// otherwise it must be a specific type like video/mp4
return fileType === pattern;
});
// otherwise it must be a specific type like video/mp4
return fileType === pattern;
});
if (!isAcceptable) return "File type not allowed";
if (!isAcceptable) return "File type not allowed";
return undefined;
};
return undefined;
};
const upload = async (uploadFiles: File[]) => {
uploading = true;
const upload = async (uploadFiles: File[]) => {
uploading = true;
const validFiles: File[] = [];
const validFiles: File[] = [];
for (let i = 0; i < uploadFiles.length; i++) {
const file = uploadFiles[i];
for (let i = 0; i < uploadFiles.length; i++) {
const file = uploadFiles[i];
const rejectedReason = shouldAcceptFile(file, (fileCount ?? 0) + i + 1);
const rejectedReason = shouldAcceptFile(file, (fileCount ?? 0) + i + 1);
if (rejectedReason) {
onFileRejected?.({ file, reason: rejectedReason });
continue;
}
if (rejectedReason) {
onFileRejected?.({ file, reason: rejectedReason });
continue;
}
validFiles.push(file);
}
validFiles.push(file);
}
await onUpload(validFiles);
await onUpload(validFiles);
uploading = false;
};
uploading = false;
};
const canUploadFiles = $derived(
!disabled &&
!uploading &&
!(
maxFiles !== undefined &&
fileCount !== undefined &&
fileCount >= maxFiles
),
);
const canUploadFiles = $derived(
!disabled &&
!uploading &&
!(maxFiles !== undefined && fileCount !== undefined && fileCount >= maxFiles),
);
</script>
<label
ondragover={(e) => e.preventDefault()}
ondrop={drop}
for={id}
aria-disabled={!canUploadFiles}
class={cn(
"border-border hover:bg-accent/25 flex h-48 w-full place-items-center justify-center rounded-lg border-2 border-dashed p-6 transition-all hover:cursor-pointer aria-disabled:opacity-50 aria-disabled:hover:cursor-not-allowed",
className,
)}
ondragover={(e) => e.preventDefault()}
ondrop={drop}
for={id}
aria-disabled={!canUploadFiles}
class={cn(
"border-border hover:bg-accent/25 flex h-48 w-full place-items-center justify-center rounded-lg border-2 border-dashed p-6 transition-all hover:cursor-pointer aria-disabled:opacity-50 aria-disabled:hover:cursor-not-allowed",
className,
)}
>
{#if children}
{@render children()}
{:else}
<div class="flex flex-col place-items-center justify-center gap-2">
<div
class="border-border text-muted-foreground flex size-14 place-items-center justify-center rounded-full border border-dashed"
>
<UploadIcon class="size-7" />
</div>
<div class="flex flex-col gap-0.5 text-center">
<span class="text-muted-foreground font-medium">
Drag 'n' drop files here, or click to select files
</span>
{#if maxFiles || maxFileSize}
<span class="text-muted-foreground/75 text-sm">
{#if maxFiles}
<span>You can upload {maxFiles} files</span>
{/if}
{#if maxFiles && maxFileSize}
<span>(up to {displaySize(maxFileSize)} each)</span>
{/if}
{#if maxFileSize && !maxFiles}
<span>Maximum size {displaySize(maxFileSize)}</span>
{/if}
</span>
{/if}
</div>
</div>
{/if}
<input
{...rest}
disabled={!canUploadFiles}
{id}
{accept}
multiple={maxFiles === undefined || maxFiles - (fileCount ?? 0) > 1}
type="file"
onchange={change}
class="hidden"
/>
{#if children}
{@render children()}
{:else}
<div class="flex flex-col place-items-center justify-center gap-2">
<div
class="border-border text-muted-foreground flex size-14 place-items-center justify-center rounded-full border border-dashed"
>
<UploadIcon class="size-7" />
</div>
<div class="flex flex-col gap-0.5 text-center">
<span class="text-muted-foreground font-medium">
Drag 'n' drop files here, or click to select files
</span>
{#if maxFiles || maxFileSize}
<span class="text-muted-foreground/75 text-sm">
{#if maxFiles}
<span>You can upload {maxFiles} files</span>
{/if}
{#if maxFiles && maxFileSize}
<span>(up to {displaySize(maxFileSize)} each)</span>
{/if}
{#if maxFileSize && !maxFiles}
<span>Maximum size {displaySize(maxFileSize)}</span>
{/if}
</span>
{/if}
</div>
</div>
{/if}
<input
{...rest}
disabled={!canUploadFiles}
{id}
{accept}
multiple={maxFiles === undefined || maxFiles - (fileCount ?? 0) > 1}
type="file"
onchange={change}
class="hidden"
/>
</label>

View File

@@ -6,13 +6,13 @@ import FileDropZone from "./file-drop-zone.svelte";
import { type FileRejectedReason, type FileDropZoneProps } from "./types";
export const displaySize = (bytes: number): string => {
if (bytes < KILOBYTE) return `${bytes.toFixed(0)} B`;
if (bytes < KILOBYTE) return `${bytes.toFixed(0)} B`;
if (bytes < MEGABYTE) return `${(bytes / KILOBYTE).toFixed(0)} KB`;
if (bytes < MEGABYTE) return `${(bytes / KILOBYTE).toFixed(0)} KB`;
if (bytes < GIGABYTE) return `${(bytes / MEGABYTE).toFixed(0)} MB`;
if (bytes < GIGABYTE) return `${(bytes / MEGABYTE).toFixed(0)} MB`;
return `${(bytes / GIGABYTE).toFixed(0)} GB`;
return `${(bytes / GIGABYTE).toFixed(0)} GB`;
};
// Utilities for working with file sizes

View File

@@ -6,46 +6,46 @@ import type { WithChildren } from "bits-ui";
import type { HTMLInputAttributes } from "svelte/elements";
export type FileRejectedReason =
| "Maximum file size exceeded"
| "File type not allowed"
| "Maximum files uploaded";
| "Maximum file size exceeded"
| "File type not allowed"
| "Maximum files uploaded";
export type FileDropZonePropsWithoutHTML = WithChildren<{
ref?: HTMLInputElement | null;
/** Called with the uploaded files when the user drops or clicks and selects their files.
*
* @param files
*/
onUpload: (files: File[]) => Promise<void>;
/** The maximum amount files allowed to be uploaded */
maxFiles?: number;
fileCount?: number;
/** The maximum size of a file in bytes */
maxFileSize?: number;
/** Called when a file does not meet the upload criteria (size, or type) */
onFileRejected?: (opts: { reason: FileRejectedReason; file: File }) => void;
ref?: HTMLInputElement | null;
/** Called with the uploaded files when the user drops or clicks and selects their files.
*
* @param files
*/
onUpload: (files: File[]) => Promise<void>;
/** The maximum amount files allowed to be uploaded */
maxFiles?: number;
fileCount?: number;
/** The maximum size of a file in bytes */
maxFileSize?: number;
/** Called when a file does not meet the upload criteria (size, or type) */
onFileRejected?: (opts: { reason: FileRejectedReason; file: File }) => void;
// just for extra documentation
/** Takes a comma separated list of one or more file types.
*
* [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept)
*
* ### Usage
* ```svelte
* <FileDropZone
* accept=".doc,.docx,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
* />
* ```
*
* ### Common Values
* ```svelte
* <FileDropZone accept="audio/*"/>
* <FileDropZone accept="image/*"/>
* <FileDropZone accept="video/*"/>
* ```
*/
accept?: string;
// just for extra documentation
/** Takes a comma separated list of one or more file types.
*
* [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept)
*
* ### Usage
* ```svelte
* <FileDropZone
* accept=".doc,.docx,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
* />
* ```
*
* ### Common Values
* ```svelte
* <FileDropZone accept="audio/*"/>
* <FileDropZone accept="image/*"/>
* <FileDropZone accept="video/*"/>
* ```
*/
accept?: string;
}>;
export type FileDropZoneProps = FileDropZonePropsWithoutHTML &
Omit<HTMLInputAttributes, "multiple" | "files">;
Omit<HTMLInputAttributes, "multiple" | "files">;

View File

@@ -1,7 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
Root,
//
Root as Input,
};

View File

@@ -1,57 +1,51 @@
<script lang="ts">
import type {
HTMLInputAttributes,
HTMLInputTypeAttribute,
} from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
(
| { type: "file"; files?: FileList }
| { type?: InputType; files?: undefined }
)
>;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
...restProps
}: Props = $props();
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
...restProps
}: Props = $props();
</script>
{#if type === "file"}
<input
bind:this={ref}
data-slot="input"
class={cn(
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
type="file"
bind:files
bind:value
{...restProps}
/>
<input
bind:this={ref}
data-slot="input"
class={cn(
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
bind:this={ref}
data-slot="input"
class={cn(
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{type}
bind:value
{...restProps}
/>
<input
bind:this={ref}
data-slot="input"
class={cn(
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{type}
bind:value
{...restProps}
/>
{/if}

View File

@@ -1,7 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
Root,
//
Root as Label,
};

View File

@@ -1,20 +1,20 @@
<script lang="ts">
import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: LabelPrimitive.RootProps = $props();
let {
ref = $bindable(null),
class: className,
...restProps
}: LabelPrimitive.RootProps = $props();
</script>
<LabelPrimitive.Root
bind:ref
data-slot="label"
class={cn(
"flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
className,
)}
{...restProps}
bind:ref
data-slot="label"
class={cn(
"flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
className,
)}
{...restProps}
/>

View File

@@ -13,25 +13,25 @@ import GroupHeading from "./select-group-heading.svelte";
const Root = SelectPrimitive.Root;
export {
Root,
Group,
Label,
Item,
Content,
Trigger,
Separator,
ScrollDownButton,
ScrollUpButton,
GroupHeading,
//
Root as Select,
Group as SelectGroup,
Label as SelectLabel,
Item as SelectItem,
Content as SelectContent,
Trigger as SelectTrigger,
Separator as SelectSeparator,
ScrollDownButton as SelectScrollDownButton,
ScrollUpButton as SelectScrollUpButton,
GroupHeading as SelectGroupHeading,
Root,
Group,
Label,
Item,
Content,
Trigger,
Separator,
ScrollDownButton,
ScrollUpButton,
GroupHeading,
//
Root as Select,
Group as SelectGroup,
Label as SelectLabel,
Item as SelectItem,
Content as SelectContent,
Trigger as SelectTrigger,
Separator as SelectSeparator,
ScrollDownButton as SelectScrollDownButton,
ScrollUpButton as SelectScrollUpButton,
GroupHeading as SelectGroupHeading,
};

View File

@@ -1,40 +1,40 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
import { cn, type WithoutChild } from "$lib/utils.js";
import { Select as SelectPrimitive } from "bits-ui";
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
portalProps,
children,
...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: SelectPrimitive.PortalProps;
} = $props();
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
portalProps,
children,
...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: SelectPrimitive.PortalProps;
} = $props();
</script>
<SelectPrimitive.Portal {...portalProps}>
<SelectPrimitive.Content
bind:ref
{sideOffset}
data-slot="select-content"
class={cn(
"bg-popover text-popover-foreground 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--bits-select-content-available-height) origin-(--bits-select-content-transform-origin) relative z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
{...restProps}
<SelectPrimitive.Content
bind:ref
{sideOffset}
data-slot="select-content"
class={cn(
"bg-popover text-popover-foreground 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--bits-select-content-available-height) origin-(--bits-select-content-transform-origin) relative z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
{...restProps}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
class={cn(
"h-(--bits-select-anchor-height) min-w-(--bits-select-anchor-width) w-full scroll-my-1 p-1",
)}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
class={cn(
"h-(--bits-select-anchor-height) min-w-(--bits-select-anchor-width) w-full scroll-my-1 p-1",
)}
>
{@render children?.()}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
{@render children?.()}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>

View File

@@ -1,21 +1,21 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
</script>
<SelectPrimitive.GroupHeading
bind:ref
data-slot="select-group-heading"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
bind:ref
data-slot="select-group-heading"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
>
{@render children?.()}
{@render children?.()}
</SelectPrimitive.GroupHeading>

View File

@@ -1,9 +1,8 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { Select as SelectPrimitive } from "bits-ui";
// eslint-disable-next-line no-useless-assignment
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps =
$props();
// eslint-disable-next-line no-useless-assignment
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
</script>
<SelectPrimitive.Group data-slot="select-group" {...restProps} />

View File

@@ -1,38 +1,38 @@
<script lang="ts">
import CheckIcon from "@lucide/svelte/icons/check";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
import CheckIcon from "@lucide/svelte/icons/check";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
value,
label,
children: childrenProp,
...restProps
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
let {
ref = $bindable(null),
class: className,
value,
label,
children: childrenProp,
...restProps
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
</script>
<SelectPrimitive.Item
bind:ref
{value}
data-slot="select-item"
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...restProps}
bind:ref
{value}
data-slot="select-item"
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...restProps}
>
{#snippet children({ selected, highlighted })}
<span class="absolute right-2 flex size-3.5 items-center justify-center">
{#if selected}
<CheckIcon class="size-4" />
{/if}
</span>
{#if childrenProp}
{@render childrenProp({ selected, highlighted })}
{:else}
{label || value}
{/if}
{/snippet}
{#snippet children({ selected, highlighted })}
<span class="absolute right-2 flex size-3.5 items-center justify-center">
{#if selected}
<CheckIcon class="size-4" />
{/if}
</span>
{#if childrenProp}
{@render childrenProp({ selected, highlighted })}
{:else}
{label || value}
{/if}
{/snippet}
</SelectPrimitive.Item>

View File

@@ -1,20 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
</script>
<div
bind:this={ref}
data-slot="select-label"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
bind:this={ref}
data-slot="select-label"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
>
{@render children?.()}
{@render children?.()}
</div>

View File

@@ -1,20 +1,20 @@
<script lang="ts">
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
</script>
<SelectPrimitive.ScrollDownButton
bind:ref
data-slot="select-scroll-down-button"
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
bind:ref
data-slot="select-scroll-down-button"
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronDownIcon class="size-4" />
<ChevronDownIcon class="size-4" />
</SelectPrimitive.ScrollDownButton>

View File

@@ -1,20 +1,20 @@
<script lang="ts">
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
</script>
<SelectPrimitive.ScrollUpButton
bind:ref
data-slot="select-scroll-up-button"
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
bind:ref
data-slot="select-scroll-up-button"
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronUpIcon class="size-4" />
<ChevronUpIcon class="size-4" />
</SelectPrimitive.ScrollUpButton>

View File

@@ -1,18 +1,18 @@
<script lang="ts">
import type { Separator as SeparatorPrimitive } from "bits-ui";
import { Separator } from "$lib/components/ui/separator/index.js";
import { cn } from "$lib/utils.js";
import type { Separator as SeparatorPrimitive } from "bits-ui";
import { Separator } from "$lib/components/ui/separator/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<Separator
bind:ref
data-slot="select-separator"
class={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...restProps}
bind:ref
data-slot="select-separator"
class={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...restProps}
/>

View File

@@ -1,29 +1,29 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { cn, type WithoutChild } from "$lib/utils.js";
import { Select as SelectPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
size = "default",
...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: "sm" | "default";
} = $props();
let {
ref = $bindable(null),
class: className,
children,
size = "default",
...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: "sm" | "default";
} = $props();
</script>
<SelectPrimitive.Trigger
bind:ref
data-slot="select-trigger"
data-size={size}
class={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 shadow-xs flex w-fit select-none items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...restProps}
bind:ref
data-slot="select-trigger"
data-size={size}
class={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 shadow-xs flex w-fit select-none items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon class="size-4 opacity-50" />
{@render children?.()}
<ChevronDownIcon class="size-4 opacity-50" />
</SelectPrimitive.Trigger>

View File

@@ -1,7 +1,7 @@
import Root from "./separator.svelte";
export {
Root,
//
Root as Separator,
Root,
//
Root as Separator,
};

View File

@@ -1,20 +1,20 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Separator as SeparatorPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<SeparatorPrimitive.Root
bind:ref
data-slot="separator"
class={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px",
className,
)}
{...restProps}
bind:ref
data-slot="separator"
class={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px",
className,
)}
{...restProps}
/>

View File

@@ -1,7 +1,7 @@
import Root from "./slider.svelte";
export {
Root,
//
Root as Slider,
Root,
//
Root as Slider,
};

View File

@@ -1,14 +1,14 @@
<script lang="ts">
import { Slider as SliderPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import { Slider as SliderPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
value = $bindable(),
orientation = "horizontal",
class: className,
...restProps
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> = $props();
let {
ref = $bindable(null),
value = $bindable(),
orientation = "horizontal",
class: className,
...restProps
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> = $props();
</script>
<!--
@@ -16,37 +16,37 @@ Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<SliderPrimitive.Root
bind:ref
bind:value={value as never}
data-slot="slider"
{orientation}
class={cn(
"relative flex w-full touch-none select-none items-center data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col data-[disabled]:opacity-50",
className,
)}
{...restProps}
bind:ref
bind:value={value as never}
data-slot="slider"
{orientation}
class={cn(
"relative flex w-full touch-none select-none items-center data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col data-[disabled]:opacity-50",
className,
)}
{...restProps}
>
{#snippet children({ thumbs })}
<span
data-orientation={orientation}
data-slot="slider-track"
class={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-1.5",
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
class={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
)}
/>
</span>
{#each thumbs as thumb (thumb)}
<SliderPrimitive.Thumb
data-slot="slider-thumb"
index={thumb}
class="border-primary bg-background ring-ring/50 focus-visible:outline-hidden block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 disabled:pointer-events-none disabled:opacity-50"
/>
{/each}
{/snippet}
{#snippet children({ thumbs })}
<span
data-orientation={orientation}
data-slot="slider-track"
class={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-1.5",
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
class={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
)}
/>
</span>
{#each thumbs as thumb (thumb)}
<SliderPrimitive.Thumb
data-slot="slider-thumb"
index={thumb}
class="border-primary bg-background ring-ring/50 focus-visible:outline-hidden block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 disabled:pointer-events-none disabled:opacity-50"
/>
{/each}
{/snippet}
</SliderPrimitive.Root>

View File

@@ -1,16 +1,13 @@
<script lang="ts">
import {
Toaster as Sonner,
type ToasterProps as SonnerProps,
} from "svelte-sonner";
import { mode } from "mode-watcher";
import { Toaster as Sonner, type ToasterProps as SonnerProps } from "svelte-sonner";
import { mode } from "mode-watcher";
let { ...restProps }: SonnerProps = $props();
let { ...restProps }: SonnerProps = $props();
</script>
<Sonner
theme={mode.current}
class="toaster group"
style="--normal-bg: var(--color-popover); --normal-text: var(--color-popover-foreground); --normal-border: var(--color-border);"
{...restProps}
theme={mode.current}
class="toaster group"
style="--normal-bg: var(--color-popover); --normal-text: var(--color-popover-foreground); --normal-border: var(--color-border);"
{...restProps}
/>

View File

@@ -4,13 +4,13 @@ import List from "./tabs-list.svelte";
import Trigger from "./tabs-trigger.svelte";
export {
Root,
Content,
List,
Trigger,
//
Root as Tabs,
Content as TabsContent,
List as TabsList,
Trigger as TabsTrigger,
Root,
Content,
List,
Trigger,
//
Root as Tabs,
Content as TabsContent,
List as TabsList,
Trigger as TabsTrigger,
};

View File

@@ -1,17 +1,17 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.ContentProps = $props();
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.ContentProps = $props();
</script>
<TabsPrimitive.Content
bind:ref
data-slot="tabs-content"
class={cn("flex-1 outline-none", className)}
{...restProps}
bind:ref
data-slot="tabs-content"
class={cn("flex-1 outline-none", className)}
{...restProps}
/>

View File

@@ -1,20 +1,16 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.ListProps = $props();
let { ref = $bindable(null), class: className, ...restProps }: TabsPrimitive.ListProps = $props();
</script>
<TabsPrimitive.List
bind:ref
data-slot="tabs-list"
class={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className,
)}
{...restProps}
bind:ref
data-slot="tabs-list"
class={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className,
)}
{...restProps}
/>

View File

@@ -1,20 +1,20 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.TriggerProps = $props();
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.TriggerProps = $props();
</script>
<TabsPrimitive.Trigger
bind:ref
data-slot="tabs-trigger"
class={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 text-sm font-medium transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...restProps}
bind:ref
data-slot="tabs-trigger"
class={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 text-sm font-medium transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...restProps}
/>

View File

@@ -1,19 +1,19 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
value = $bindable(""),
class: className,
...restProps
}: TabsPrimitive.RootProps = $props();
let {
ref = $bindable(null),
value = $bindable(""),
class: className,
...restProps
}: TabsPrimitive.RootProps = $props();
</script>
<TabsPrimitive.Root
bind:ref
bind:value
data-slot="tabs"
class={cn("flex flex-col gap-2", className)}
{...restProps}
bind:ref
bind:value
data-slot="tabs"
class={cn("flex flex-col gap-2", className)}
{...restProps}
/>

View File

@@ -3,26 +3,26 @@
-->
<script lang="ts">
import XIcon from "@lucide/svelte/icons/x";
import XIcon from "@lucide/svelte/icons/x";
type Props = {
value: string;
disabled: boolean | null;
active: boolean;
onDelete: (value: string) => void;
};
type Props = {
value: string;
disabled: boolean | null;
active: boolean;
onDelete: (value: string) => void;
};
let { value, disabled, onDelete, active }: Props = $props();
let { value, disabled, onDelete, active }: Props = $props();
</script>
<div
class="bg-secondary ring-offset-background hover:bg-secondary/90 aria-selected:bg-secondary/90 aria-selected:ring-ring flex place-items-center gap-2 rounded-md px-2 py-0.5 transition-all hover:cursor-default aria-selected:ring-2 aria-selected:ring-offset-2"
aria-selected={active}
class="bg-secondary ring-offset-background hover:bg-secondary/90 aria-selected:bg-secondary/90 aria-selected:ring-ring flex place-items-center gap-2 rounded-md px-2 py-0.5 transition-all hover:cursor-default aria-selected:ring-2 aria-selected:ring-offset-2"
aria-selected={active}
>
<span>
{value}
</span>
<button type="button" {disabled} onclick={() => onDelete(value)}>
<XIcon class="size-4" />
</button>
<span>
{value}
</span>
<button type="button" {disabled} onclick={() => onDelete(value)}>
<XIcon class="size-4" />
</button>
</div>

View File

@@ -3,209 +3,208 @@
-->
<script lang="ts">
import { cn } from "$lib/utils/utils";
import type { TagsInputProps } from "./types";
import TagsInputTag from "./tags-input-tag.svelte";
import { untrack } from "svelte";
import { cn } from "$lib/utils/utils";
import type { TagsInputProps } from "./types";
import TagsInputTag from "./tags-input-tag.svelte";
import { untrack } from "svelte";
const defaultValidate: TagsInputProps["validate"] = (val, tags) => {
const transformed = val.trim();
const defaultValidate: TagsInputProps["validate"] = (val, tags) => {
const transformed = val.trim();
// disallow empties
if (transformed.length === 0) return undefined;
// disallow empties
if (transformed.length === 0) return undefined;
// disallow duplicates
if (tags.find((t) => transformed === t)) return undefined;
// disallow duplicates
if (tags.find((t) => transformed === t)) return undefined;
return transformed;
};
return transformed;
};
let {
value = $bindable([]),
placeholder,
class: className,
disabled = false,
validate = defaultValidate,
...rest
}: TagsInputProps = $props();
let {
value = $bindable([]),
placeholder,
class: className,
disabled = false,
validate = defaultValidate,
...rest
}: TagsInputProps = $props();
let inputValue = $state("");
let tagIndex = $state<number>();
let invalid = $state(false);
let isComposing = $state(false);
let inputValue = $state("");
let tagIndex = $state<number>();
let invalid = $state(false);
let isComposing = $state(false);
$effect(() => {
// whenever input value changes reset invalid
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
inputValue;
$effect(() => {
// whenever input value changes reset invalid
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
inputValue;
untrack(() => {
invalid = false;
});
});
untrack(() => {
invalid = false;
});
});
const enter = () => {
if (isComposing) return;
const enter = () => {
if (isComposing) return;
const validated = validate(inputValue, value);
const validated = validate(inputValue, value);
if (!validated) {
invalid = true;
return;
}
if (!validated) {
invalid = true;
return;
}
value = [...value, validated];
inputValue = "";
};
value = [...value, validated];
inputValue = "";
};
const compositionStart = () => {
isComposing = true;
};
const compositionStart = () => {
isComposing = true;
};
const compositionEnd = () => {
isComposing = false;
};
const compositionEnd = () => {
isComposing = false;
};
const keydown = (e: KeyboardEvent) => {
const target = e.target as HTMLInputElement;
const keydown = (e: KeyboardEvent) => {
const target = e.target as HTMLInputElement;
if (e.key === "Enter") {
// prevent form submit
e.preventDefault();
if (e.key === "Enter") {
// prevent form submit
e.preventDefault();
if (isComposing) return;
if (isComposing) return;
enter();
return;
}
enter();
return;
}
const isAtBeginning =
target.selectionStart === 0 && target.selectionEnd === 0;
const isAtBeginning = target.selectionStart === 0 && target.selectionEnd === 0;
let shouldResetIndex = true;
let shouldResetIndex = true;
if (e.key === "Backspace") {
if (isAtBeginning) {
e.preventDefault();
if (e.key === "Backspace") {
if (isAtBeginning) {
e.preventDefault();
if (tagIndex !== undefined) {
deleteIndex(tagIndex);
if (tagIndex !== undefined) {
deleteIndex(tagIndex);
// focus previous
const prev = tagIndex - 1;
// focus previous
const prev = tagIndex - 1;
if (prev < 0) {
tagIndex = undefined;
} else {
tagIndex = prev;
}
} else {
tagIndex = value.length - 1;
}
if (prev < 0) {
tagIndex = undefined;
} else {
tagIndex = prev;
}
} else {
tagIndex = value.length - 1;
}
shouldResetIndex = false;
}
}
shouldResetIndex = false;
}
}
if (e.key === "Delete") {
if (isAtBeginning) {
if (inputValue.length === 0) {
if (tagIndex !== undefined) {
e.preventDefault();
if (e.key === "Delete") {
if (isAtBeginning) {
if (inputValue.length === 0) {
if (tagIndex !== undefined) {
e.preventDefault();
deleteIndex(tagIndex);
deleteIndex(tagIndex);
// stay focused on the same index unless value.length === 0
if (value.length === 0) tagIndex = undefined;
// stay focused on the same index unless value.length === 0
if (value.length === 0) tagIndex = undefined;
shouldResetIndex = false;
}
}
}
}
shouldResetIndex = false;
}
}
}
}
// controls for tag selection
if (isAtBeginning) {
// left
if (e.key === "ArrowLeft") {
if (tagIndex !== undefined) {
const prev = tagIndex - 1;
// controls for tag selection
if (isAtBeginning) {
// left
if (e.key === "ArrowLeft") {
if (tagIndex !== undefined) {
const prev = tagIndex - 1;
if (prev < 0) {
tagIndex = 0;
} else {
tagIndex = prev;
}
} else {
// set initial index
tagIndex = value.length - 1;
}
if (prev < 0) {
tagIndex = 0;
} else {
tagIndex = prev;
}
} else {
// set initial index
tagIndex = value.length - 1;
}
shouldResetIndex = false;
}
shouldResetIndex = false;
}
// right
// we can only move right if the value is empty
if (inputValue.length === 0) {
if (e.key === "ArrowRight") {
if (tagIndex !== undefined) {
const next = tagIndex + 1;
// right
// we can only move right if the value is empty
if (inputValue.length === 0) {
if (e.key === "ArrowRight") {
if (tagIndex !== undefined) {
const next = tagIndex + 1;
if (next > value.length - 1) {
tagIndex = undefined;
} else {
tagIndex = next;
}
if (next > value.length - 1) {
tagIndex = undefined;
} else {
tagIndex = next;
}
shouldResetIndex = false;
}
}
}
}
shouldResetIndex = false;
}
}
}
}
// reset the tag index to undefined
if (shouldResetIndex) {
tagIndex = undefined;
}
};
// reset the tag index to undefined
if (shouldResetIndex) {
tagIndex = undefined;
}
};
const deleteValue = (val: string) => {
const index = value.findIndex((v) => val === v);
const deleteValue = (val: string) => {
const index = value.findIndex((v) => val === v);
if (index === -1) return;
if (index === -1) return;
deleteIndex(index);
};
deleteIndex(index);
};
const deleteIndex = (index: number) => {
value = [...value.slice(0, index), ...value.slice(index + 1)];
};
const deleteIndex = (index: number) => {
value = [...value.slice(0, index), ...value.slice(index + 1)];
};
const blur = () => {
tagIndex = undefined;
};
const blur = () => {
tagIndex = undefined;
};
</script>
<div
class={cn(
"border-input bg-background selection:bg-primary dark:bg-input/30 flex min-h-[36px] w-full flex-wrap place-items-center gap-1 rounded-md border py-0.5 pr-1 pl-1 disabled:opacity-50 aria-disabled:cursor-not-allowed",
className,
)}
aria-disabled={disabled}
class={cn(
"border-input bg-background selection:bg-primary dark:bg-input/30 flex min-h-[36px] w-full flex-wrap place-items-center gap-1 rounded-md border py-0.5 pr-1 pl-1 disabled:opacity-50 aria-disabled:cursor-not-allowed",
className,
)}
aria-disabled={disabled}
>
{#each value as tag, i (tag)}
<TagsInputTag value={tag} {disabled} onDelete={deleteValue} active={i === tagIndex} />
{/each}
<input
{...rest}
bind:value={inputValue}
onblur={blur}
oncompositionstart={compositionStart}
oncompositionend={compositionEnd}
{disabled}
{placeholder}
data-invalid={invalid}
onkeydown={keydown}
class="placeholder:text-muted-foreground min-w-16 shrink grow basis-0 border-none bg-transparent px-2 outline-hidden focus:outline-hidden disabled:cursor-not-allowed data-[invalid=true]:text-red-500 md:text-sm"
/>
{#each value as tag, i (tag)}
<TagsInputTag value={tag} {disabled} onDelete={deleteValue} active={i === tagIndex} />
{/each}
<input
{...rest}
bind:value={inputValue}
onblur={blur}
oncompositionstart={compositionStart}
oncompositionend={compositionEnd}
{disabled}
{placeholder}
data-invalid={invalid}
onkeydown={keydown}
class="placeholder:text-muted-foreground min-w-16 shrink grow basis-0 border-none bg-transparent px-2 outline-hidden focus:outline-hidden disabled:cursor-not-allowed data-[invalid=true]:text-red-500 md:text-sm"
/>
</div>

View File

@@ -5,9 +5,8 @@
import type { HTMLInputAttributes } from "svelte/elements";
export type TagsInputPropsWithoutHTML = {
value?: string[];
validate?: (val: string, tags: string[]) => string | undefined;
value?: string[];
validate?: (val: string, tags: string[]) => string | undefined;
};
export type TagsInputProps = TagsInputPropsWithoutHTML &
Omit<HTMLInputAttributes, "value">;
export type TagsInputProps = TagsInputPropsWithoutHTML & Omit<HTMLInputAttributes, "value">;

View File

@@ -1,7 +1,7 @@
import Root from "./textarea.svelte";
export {
Root,
//
Root as Textarea,
Root,
//
Root as Textarea,
};

View File

@@ -1,22 +1,22 @@
<script lang="ts">
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
import type { HTMLTextareaAttributes } from "svelte/elements";
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
import type { HTMLTextareaAttributes } from "svelte/elements";
let {
ref = $bindable(null),
value = $bindable(),
class: className,
...restProps
}: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props();
let {
ref = $bindable(null),
value = $bindable(),
class: className,
...restProps
}: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props();
</script>
<textarea
bind:this={ref}
data-slot="textarea"
class={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 field-sizing-content shadow-xs flex min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
bind:value
{...restProps}
bind:this={ref}
data-slot="textarea"
class={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 field-sizing-content shadow-xs flex min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
bind:value
{...restProps}
></textarea>

View File

@@ -1,3 +1,8 @@
// Re-export from api.ts for backwards compatibility
// All components that import from $lib/directus continue to work
export { apiUrl as directusApiUrl, getAssetUrl, isModel, getGraphQLClient as getDirectusInstance } from "./api.js";
export {
apiUrl as directusApiUrl,
getAssetUrl,
isModel,
getGraphQLClient as getDirectusInstance,
} from "./api.js";

View File

@@ -6,6 +6,6 @@ const defaultLocale = "en";
addMessages("en", en);
init({
fallbackLocale: defaultLocale,
initialLocale: defaultLocale,
fallbackLocale: defaultLocale,
initialLocale: defaultLocale,
});

File diff suppressed because it is too large Load Diff

View File

@@ -3,140 +3,137 @@
* Provides structured logging with context and request tracing
*/
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export type LogLevel = "debug" | "info" | "warn" | "error";
interface LogContext {
timestamp: string;
level: LogLevel;
message: string;
context?: Record<string, unknown>;
requestId?: string;
userId?: string;
path?: string;
method?: string;
duration?: number;
error?: Error;
timestamp: string;
level: LogLevel;
message: string;
context?: Record<string, unknown>;
requestId?: string;
userId?: string;
path?: string;
method?: string;
duration?: number;
error?: Error;
}
class Logger {
private isDev = process.env.NODE_ENV === 'development';
private serviceName = 'sexy.pivoine.art';
private isDev = process.env.NODE_ENV === "development";
private serviceName = "sexy.pivoine.art";
private formatLog(ctx: LogContext): string {
const { timestamp, level, message, context, requestId, userId, path, method, duration, error } = ctx;
private formatLog(ctx: LogContext): string {
const { timestamp, level, message, context, requestId, userId, path, method, duration, error } =
ctx;
const parts = [
`[${timestamp}]`,
`[${level.toUpperCase()}]`,
requestId ? `[${requestId}]` : null,
method && path ? `${method} ${path}` : null,
message,
userId ? `user=${userId}` : null,
duration !== undefined ? `${duration}ms` : null,
].filter(Boolean);
const parts = [
`[${timestamp}]`,
`[${level.toUpperCase()}]`,
requestId ? `[${requestId}]` : null,
method && path ? `${method} ${path}` : null,
message,
userId ? `user=${userId}` : null,
duration !== undefined ? `${duration}ms` : null,
].filter(Boolean);
let logString = parts.join(' ');
let logString = parts.join(" ");
if (context && Object.keys(context).length > 0) {
logString += ' ' + JSON.stringify(context);
}
if (context && Object.keys(context).length > 0) {
logString += " " + JSON.stringify(context);
}
if (error) {
logString += `\n Error: ${error.message}\n Stack: ${error.stack}`;
}
if (error) {
logString += `\n Error: ${error.message}\n Stack: ${error.stack}`;
}
return logString;
}
return logString;
}
private log(level: LogLevel, message: string, meta: Partial<LogContext> = {}) {
const timestamp = new Date().toISOString();
const logContext: LogContext = {
timestamp,
level,
message,
...meta,
};
private log(level: LogLevel, message: string, meta: Partial<LogContext> = {}) {
const timestamp = new Date().toISOString();
const logContext: LogContext = {
timestamp,
level,
message,
...meta,
};
const formattedLog = this.formatLog(logContext);
const formattedLog = this.formatLog(logContext);
switch (level) {
case 'debug':
if (this.isDev) console.debug(formattedLog);
break;
case 'info':
console.info(formattedLog);
break;
case 'warn':
console.warn(formattedLog);
break;
case 'error':
console.error(formattedLog);
break;
}
}
switch (level) {
case "debug":
if (this.isDev) console.debug(formattedLog);
break;
case "info":
console.info(formattedLog);
break;
case "warn":
console.warn(formattedLog);
break;
case "error":
console.error(formattedLog);
break;
}
}
debug(message: string, meta?: Partial<LogContext>) {
this.log('debug', message, meta);
}
debug(message: string, meta?: Partial<LogContext>) {
this.log("debug", message, meta);
}
info(message: string, meta?: Partial<LogContext>) {
this.log('info', message, meta);
}
info(message: string, meta?: Partial<LogContext>) {
this.log("info", message, meta);
}
warn(message: string, meta?: Partial<LogContext>) {
this.log('warn', message, meta);
}
warn(message: string, meta?: Partial<LogContext>) {
this.log("warn", message, meta);
}
error(message: string, meta?: Partial<LogContext>) {
this.log('error', message, meta);
}
error(message: string, meta?: Partial<LogContext>) {
this.log("error", message, meta);
}
// Request logging helper
request(
method: string,
path: string,
meta: Partial<LogContext> = {}
) {
this.info('→ Request received', { method, path, ...meta });
}
// Request logging helper
request(method: string, path: string, meta: Partial<LogContext> = {}) {
this.info("→ Request received", { method, path, ...meta });
}
response(
method: string,
path: string,
status: number,
duration: number,
meta: Partial<LogContext> = {}
) {
const level = status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info';
this.log(level, `← Response ${status}`, { method, path, duration, ...meta });
}
response(
method: string,
path: string,
status: number,
duration: number,
meta: Partial<LogContext> = {},
) {
const level = status >= 500 ? "error" : status >= 400 ? "warn" : "info";
this.log(level, `← Response ${status}`, { method, path, duration, ...meta });
}
// Authentication logging
auth(action: string, success: boolean, meta: Partial<LogContext> = {}) {
this.info(`🔐 Auth: ${action} ${success ? 'success' : 'failed'}`, meta);
}
// Authentication logging
auth(action: string, success: boolean, meta: Partial<LogContext> = {}) {
this.info(`🔐 Auth: ${action} ${success ? "success" : "failed"}`, meta);
}
// Startup logging
startup() {
const env = {
NODE_ENV: process.env.NODE_ENV,
PUBLIC_API_URL: process.env.PUBLIC_API_URL,
PUBLIC_URL: process.env.PUBLIC_URL,
PUBLIC_UMAMI_ID: process.env.PUBLIC_UMAMI_ID ? '***set***' : 'not set',
PUBLIC_UMAMI_SCRIPT: process.env.PUBLIC_UMAMI_SCRIPT || 'not set',
PORT: process.env.PORT || '3000',
HOST: process.env.HOST || '0.0.0.0',
};
// Startup logging
startup() {
const env = {
NODE_ENV: process.env.NODE_ENV,
PUBLIC_API_URL: process.env.PUBLIC_API_URL,
PUBLIC_URL: process.env.PUBLIC_URL,
PUBLIC_UMAMI_ID: process.env.PUBLIC_UMAMI_ID ? "***set***" : "not set",
PUBLIC_UMAMI_SCRIPT: process.env.PUBLIC_UMAMI_SCRIPT || "not set",
PORT: process.env.PORT || "3000",
HOST: process.env.HOST || "0.0.0.0",
};
console.log('\n' + '='.repeat(60));
console.log('🍑 sexy.pivoine.art - Server Starting 💜');
console.log('='.repeat(60));
console.log('\n📋 Environment Configuration:');
Object.entries(env).forEach(([key, value]) => {
console.log(` ${key}: ${value}`);
});
console.log('\n' + '='.repeat(60) + '\n');
}
console.log("\n" + "=".repeat(60));
console.log("🍑 sexy.pivoine.art - Server Starting 💜");
console.log("=".repeat(60));
console.log("\n📋 Environment Configuration:");
Object.entries(env).forEach(([key, value]) => {
console.log(` ${key}: ${value}`);
});
console.log("\n" + "=".repeat(60) + "\n");
}
}
// Singleton instance
@@ -144,5 +141,5 @@ export const logger = new Logger();
// Generate request ID
export function generateRequestId(): string {
return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,205 +1,205 @@
import { type ButtplugClientDevice } from "@sexy.pivoine.art/buttplug";
export interface User {
id: string;
first_name: string;
last_name: string;
artist_name: string;
slug: string;
email: string;
description: string;
tags: string[];
avatar: string | File;
password: string;
directus_users_id?: User;
id: string;
first_name: string;
last_name: string;
artist_name: string;
slug: string;
email: string;
description: string;
tags: string[];
avatar: string | File;
password: string;
directus_users_id?: User;
}
export interface CurrentUser extends User {
avatar: File;
role: "model" | "viewer" | "admin";
policies: string[];
avatar: File;
role: "model" | "viewer" | "admin";
policies: string[];
}
export interface AuthStatus {
authenticated: boolean;
user?: CurrentUser;
data?: {
refresh_token: string | null;
};
authenticated: boolean;
user?: CurrentUser;
data?: {
refresh_token: string | null;
};
}
export interface File {
id: string;
filesize: number;
title: string;
description: string;
duration: number;
directus_files_id?: File;
id: string;
filesize: number;
title: string;
description: string;
duration: number;
directus_files_id?: File;
}
export interface Article {
id: string;
slug: string;
title: string;
excerpt: string;
content: string;
image: string;
tags: string[];
publish_date: Date;
author: {
first_name: string;
last_name: string;
avatar: string;
description?: string;
website?: string;
};
category: string;
featured?: boolean;
id: string;
slug: string;
title: string;
excerpt: string;
content: string;
image: string;
tags: string[];
publish_date: Date;
author: {
first_name: string;
last_name: string;
avatar: string;
description?: string;
website?: string;
};
category: string;
featured?: boolean;
}
export interface Model {
id: string;
slug: string;
artist_name: string;
description: string;
avatar: string;
category: string;
tags: string[];
join_date: Date;
featured?: boolean;
photos: File[];
banner?: File;
id: string;
slug: string;
artist_name: string;
description: string;
avatar: string;
category: string;
tags: string[];
join_date: Date;
featured?: boolean;
photos: File[];
banner?: File;
}
export interface Video {
id: string;
slug: string;
title: string;
description: string;
image: string;
movie: File;
models: User[];
tags: string[];
upload_date: Date;
premium?: boolean;
featured?: boolean;
likes_count?: number;
plays_count?: number;
views_count?: number;
id: string;
slug: string;
title: string;
description: string;
image: string;
movie: File;
models: User[];
tags: string[];
upload_date: Date;
premium?: boolean;
featured?: boolean;
likes_count?: number;
plays_count?: number;
views_count?: number;
}
export interface Comment {
id: string;
comment: string;
item: string;
user_created: User;
date_created: Date;
id: string;
comment: string;
item: string;
user_created: User;
date_created: Date;
}
export interface Stats {
videos_count: number;
models_count: number;
viewers_count: number;
videos_count: number;
models_count: number;
viewers_count: number;
}
export interface DeviceActuator {
featureIndex: number;
outputType: string;
maxSteps: number;
descriptor: string;
value: number;
featureIndex: number;
outputType: string;
maxSteps: number;
descriptor: string;
value: number;
}
export interface BluetoothDevice {
id: string;
name: string;
actuators: DeviceActuator[];
batteryLevel: number;
hasBattery: boolean;
isConnected: boolean;
lastSeen: Date;
info: ButtplugClientDevice;
id: string;
name: string;
actuators: DeviceActuator[];
batteryLevel: number;
hasBattery: boolean;
isConnected: boolean;
lastSeen: Date;
info: ButtplugClientDevice;
}
export interface ShareContent {
title: string;
description: string;
url: string;
type: "video" | "model" | "article" | "link";
title: string;
description: string;
url: string;
type: "video" | "model" | "article" | "link";
}
export interface RecordedEvent {
timestamp: number;
deviceIndex: number;
deviceName: string;
actuatorIndex: number;
actuatorType: string;
value: number;
timestamp: number;
deviceIndex: number;
deviceName: string;
actuatorIndex: number;
actuatorType: string;
value: number;
}
export interface DeviceInfo {
name: string;
index: number;
capabilities: string[];
name: string;
index: number;
capabilities: string[];
}
export interface Recording {
id: string;
title: string;
description?: string;
slug: string;
duration: number;
events: RecordedEvent[];
device_info: DeviceInfo[];
user_created: string | User;
date_created: Date;
date_updated?: Date;
status: "draft" | "published" | "archived";
tags?: string[];
linked_video?: string | Video;
featured?: boolean;
public?: boolean;
id: string;
title: string;
description?: string;
slug: string;
duration: number;
events: RecordedEvent[];
device_info: DeviceInfo[];
user_created: string | User;
date_created: Date;
date_updated?: Date;
status: "draft" | "published" | "archived";
tags?: string[];
linked_video?: string | Video;
featured?: boolean;
public?: boolean;
}
export interface VideoLikeStatus {
liked: boolean;
liked: boolean;
}
export interface VideoPlayRecord {
id: string;
video_id: string;
duration_watched?: number;
completed: boolean;
id: string;
video_id: string;
duration_watched?: number;
completed: boolean;
}
export interface VideoLikeResponse {
liked: boolean;
likes_count: number;
liked: boolean;
likes_count: number;
}
export interface VideoPlayResponse {
success: boolean;
play_id: string;
plays_count: number;
success: boolean;
play_id: string;
plays_count: number;
}
export interface VideoAnalytics {
id: string;
title: string;
slug: string;
upload_date: Date;
likes: number;
plays: number;
completed_plays: number;
completion_rate: number;
avg_watch_time: number;
id: string;
title: string;
slug: string;
upload_date: Date;
likes: number;
plays: number;
completed_plays: number;
completion_rate: number;
avg_watch_time: number;
}
export interface Analytics {
total_videos: number;
total_likes: number;
total_plays: number;
plays_by_date: Record<string, number>;
likes_by_date: Record<string, number>;
videos: VideoAnalytics[];
total_videos: number;
total_likes: number;
total_plays: number;
plays_by_date: Record<string, number>;
likes_by_date: Record<string, number>;
videos: VideoAnalytics[];
}

View File

@@ -2,43 +2,41 @@ import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
return twMerge(clsx(inputs));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any }
? Omit<T, "children">
: T;
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & {
ref?: U | null;
ref?: U | null;
};
export const calcReadingTime = (text: string) => {
const wordsPerMinute = 200; // Average case.
const textLength = text.split(" ").length; // Split by words
if (textLength > 0) {
return Math.ceil(textLength / wordsPerMinute);
}
return 0;
const wordsPerMinute = 200; // Average case.
const textLength = text.split(" ").length; // Split by words
if (textLength > 0) {
return Math.ceil(textLength / wordsPerMinute);
}
return 0;
};
export const getUserInitials = (name: string) => {
if (!name) return "??";
return name
.split(" ")
.map((word) => word.charAt(0))
.join("")
.toUpperCase()
.slice(0, 2);
if (!name) return "??";
return name
.split(" ")
.map((word) => word.charAt(0))
.join("")
.toUpperCase()
.slice(0, 2);
};
export const formatVideoDuration = (duration: number) => {
const hours = Math.floor(duration / 3600);
const minutes = Math.floor((duration - hours * 3600) / 60);
const seconds = duration - hours * 3600 - minutes * 60;
const hours = Math.floor(duration / 3600);
const minutes = Math.floor((duration - hours * 3600) / 60);
const seconds = duration - hours * 3600 - minutes * 60;
return `${hours < 10 ? "0" + hours : hours}:${minutes < 10 ? "0" + minutes : minutes}:${seconds < 10 ? "0" + seconds : seconds}`;
return `${hours < 10 ? "0" + hours : hours}:${minutes < 10 ? "0" + minutes : minutes}:${seconds < 10 ? "0" + seconds : seconds}`;
};

View File

@@ -6,16 +6,14 @@ import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
return twMerge(clsx(inputs));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any }
? Omit<T, "children">
: T;
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & {
ref?: U | null;
ref?: U | null;
};

View File

@@ -1,123 +1,119 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import { page } from "$app/state";
import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card";
import PeonyIcon from "$lib/components/icon/peony-icon.svelte";
import PeonyBackground from "$lib/components/background/peony-background.svelte";
import Meta from "$lib/components/meta/meta.svelte";
import { _ } from "svelte-i18n";
import { page } from "$app/state";
import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card";
import PeonyIcon from "$lib/components/icon/peony-icon.svelte";
import PeonyBackground from "$lib/components/background/peony-background.svelte";
import Meta from "$lib/components/meta/meta.svelte";
</script>
<Meta
title={page.status === 404 ? $_("error.not_found") : $_("error.common")}
description={$_("error.description")}
title={page.status === 404 ? $_("error.not_found") : $_("error.common")}
description={$_("error.description")}
/>
<div
class="relative min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
class="relative min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
>
<PeonyBackground />
<PeonyBackground />
<!-- Content -->
<div class="relative z-10 container mx-auto px-4 text-center">
<div class="max-w-2xl mx-auto">
<!-- Premium Glassmorphism Card -->
<Card
class="bg-gradient-to-br from-card/25 via-card/30 to-card/20 backdrop-blur-2xl shadow-2xl shadow-primary/20"
<!-- Content -->
<div class="relative z-10 container mx-auto px-4 text-center">
<div class="max-w-2xl mx-auto">
<!-- Premium Glassmorphism Card -->
<Card
class="bg-gradient-to-br from-card/25 via-card/30 to-card/20 backdrop-blur-2xl shadow-2xl shadow-primary/20"
>
<CardContent class="p-12">
<!-- 404 Animation -->
<div class="mb-8">
<div
class="text-8xl md:text-9xl font-bold bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent animate-pulse"
>
<CardContent class="p-12">
<!-- 404 Animation -->
<div class="mb-8">
<div
class="text-8xl md:text-9xl font-bold bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent animate-pulse"
>
{page.status}
</div>
<div class="flex justify-center mt-4">
<PeonyIcon class="w-16 h-16 text-primary/60 animate-bounce" />
</div>
</div>
{page.status}
</div>
<div class="flex justify-center mt-4">
<PeonyIcon class="w-16 h-16 text-primary/60 animate-bounce" />
</div>
</div>
<!-- Error Message -->
<div class="space-y-6 mb-10">
<h1 class="text-4xl md:text-5xl font-bold text-foreground">
{page.status === 404 ? $_("error.not_found") : $_("error.common")}
</h1>
<p class="text-xl text-muted-foreground leading-relaxed max-w-2xl mx-auto">
{$_("error.description")}
</p>
</div>
<!-- Error Message -->
<div class="space-y-6 mb-10">
<h1 class="text-4xl md:text-5xl font-bold text-foreground">
{page.status === 404 ? $_("error.not_found") : $_("error.common")}
</h1>
<p class="text-xl text-muted-foreground leading-relaxed max-w-2xl mx-auto">
{$_("error.description")}
</p>
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
<Button
size="lg"
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 text-lg px-8 py-6"
href="/"
>
<span class="icon-[ri--home-2-line]"></span>
{$_("error.go_home")}
</Button>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
<Button
size="lg"
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 text-lg px-8 py-6"
href="/"
>
<span class="icon-[ri--home-2-line]"></span>
{$_("error.go_home")}
</Button>
<Button
variant="outline"
size="lg"
class="text-lg px-8 py-6 border-primary/50 hover:bg-primary/10"
href="/videos"
>
<span class="icon-[ri--search-line]"></span>
{$_("error.explore_videos")}
</Button>
</div>
<Button
variant="outline"
size="lg"
class="text-lg px-8 py-6 border-primary/50 hover:bg-primary/10"
href="/videos"
>
<span class="icon-[ri--search-line]"></span>
{$_("error.explore_videos")}
</Button>
</div>
<!-- Quick Links -->
<div class="mt-8 pt-8 border-t border-border/50">
<p class="text-sm text-muted-foreground mb-4">
{$_("error.quick_links")}
</p>
<div class="flex flex-wrap justify-center gap-3">
<button
class="text-sm text-primary hover:text-accent transition-colors hover:underline"
><a href="/models">{$_("error.featured_models")}</a></button
>
<span class="text-muted-foreground"></span>
<button
class="text-sm text-primary hover:text-accent transition-colors hover:underline"
><a href="/magazine">{$_("error.magazine")}</a></button
>
<span class="text-muted-foreground"></span>
<button
class="text-sm text-primary hover:text-accent transition-colors hover:underline"
><a href="/about">{$_("error.about_us")}</a></button
>
</div>
</div>
</CardContent>
</Card>
</div>
<!-- Quick Links -->
<div class="mt-8 pt-8 border-t border-border/50">
<p class="text-sm text-muted-foreground mb-4">
{$_("error.quick_links")}
</p>
<div class="flex flex-wrap justify-center gap-3">
<button
class="text-sm text-primary hover:text-accent transition-colors hover:underline"
><a href="/models">{$_("error.featured_models")}</a></button
>
<span class="text-muted-foreground"></span>
<button
class="text-sm text-primary hover:text-accent transition-colors hover:underline"
><a href="/magazine">{$_("error.magazine")}</a></button
>
<span class="text-muted-foreground"></span>
<button
class="text-sm text-primary hover:text-accent transition-colors hover:underline"
><a href="/about">{$_("error.about_us")}</a></button
>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
<!-- Floating Hearts Animation -->
<div class="absolute inset-0 pointer-events-none">
<div class="absolute top-1/4 left-1/4 opacity-20">
<span
class="icon-[ri--heart-3-line] w-6 h-6 text-primary animate-float animation-delay-1000"
></span>
</div>
<div class="absolute top-1/3 right-1/3 opacity-15">
<span
class="icon-[ri--heart-3-line] w-4 h-4 text-accent animate-float animation-delay-3000"
></span>
</div>
<div class="absolute bottom-1/4 left-1/3 opacity-25">
<span
class="icon-[ri--heart-3-line] w-5 h-5 text-primary animate-float animation-delay-5000"
></span>
</div>
<div class="absolute bottom-1/3 right-1/4 opacity-20">
<span
class="icon-[ri--heart-3-line] w-3 h-3 text-accent animate-float animation-delay-7000"
></span>
</div>
<!-- Floating Hearts Animation -->
<div class="absolute inset-0 pointer-events-none">
<div class="absolute top-1/4 left-1/4 opacity-20">
<span class="icon-[ri--heart-3-line] w-6 h-6 text-primary animate-float animation-delay-1000"
></span>
</div>
<div class="absolute top-1/3 right-1/3 opacity-15">
<span class="icon-[ri--heart-3-line] w-4 h-4 text-accent animate-float animation-delay-3000"
></span>
</div>
<div class="absolute bottom-1/4 left-1/3 opacity-25">
<span class="icon-[ri--heart-3-line] w-5 h-5 text-primary animate-float animation-delay-5000"
></span>
</div>
<div class="absolute bottom-1/3 right-1/4 opacity-20">
<span class="icon-[ri--heart-3-line] w-3 h-3 text-accent animate-float animation-delay-7000"
></span>
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
export async function load({ locals }) {
return {
authStatus: locals.authStatus,
};
return {
authStatus: locals.authStatus,
};
}

View File

@@ -1,29 +1,25 @@
<script lang="ts">
import "../app.css";
import { onMount } from "svelte";
import { waitLocale } from "svelte-i18n";
import "$lib/i18n";
import Footer from "$lib/components/footer/footer.svelte";
import { Toaster } from "$lib/components/ui/sonner";
import Header from "$lib/components/header/header.svelte";
import AgeVerificationDialog from "$lib/components/age-verification-dialog/age-verification-dialog.svelte";
import { env } from "$env/dynamic/public";
import "../app.css";
import { onMount } from "svelte";
import { waitLocale } from "svelte-i18n";
import "$lib/i18n";
import Footer from "$lib/components/footer/footer.svelte";
import { Toaster } from "$lib/components/ui/sonner";
import Header from "$lib/components/header/header.svelte";
import AgeVerificationDialog from "$lib/components/age-verification-dialog/age-verification-dialog.svelte";
import { env } from "$env/dynamic/public";
onMount(async () => {
await waitLocale();
});
onMount(async () => {
await waitLocale();
});
let { children, data } = $props();
let { children, data } = $props();
</script>
<svelte:head>
{#if import.meta.env.PROD && env.PUBLIC_UMAMI_ID && env.PUBLIC_UMAMI_SCRIPT}
<script
defer
src={env.PUBLIC_UMAMI_SCRIPT}
data-website-id={env.PUBLIC_UMAMI_ID}
></script>
{/if}
{#if import.meta.env.PROD && env.PUBLIC_UMAMI_ID && env.PUBLIC_UMAMI_SCRIPT}
<script defer src={env.PUBLIC_UMAMI_SCRIPT} data-website-id={env.PUBLIC_UMAMI_ID}></script>
{/if}
</svelte:head>
<AgeVerificationDialog />
@@ -31,48 +27,48 @@ let { children, data } = $props();
<Toaster />
<div class="bg-background text-foreground min-h-screen">
<!-- Advanced Global Plasma Background -->
<div class="fixed inset-0 pointer-events-none overflow-hidden">
<!-- Large primary blobs -->
<div
class="absolute -top-40 -left-40 w-80 h-80 bg-gradient-to-r from-primary/12 via-accent/18 to-primary/8 rounded-full blur-3xl animate-blob-ultra-slow"
></div>
<div
class="absolute -bottom-40 -right-40 w-96 h-96 bg-gradient-to-r from-accent/12 via-primary/18 to-accent/8 rounded-full blur-3xl animate-blob-ultra-slow animation-delay-5000"
></div>
<!-- Advanced Global Plasma Background -->
<div class="fixed inset-0 pointer-events-none overflow-hidden">
<!-- Large primary blobs -->
<div
class="absolute -top-40 -left-40 w-80 h-80 bg-gradient-to-r from-primary/12 via-accent/18 to-primary/8 rounded-full blur-3xl animate-blob-ultra-slow"
></div>
<div
class="absolute -bottom-40 -right-40 w-96 h-96 bg-gradient-to-r from-accent/12 via-primary/18 to-accent/8 rounded-full blur-3xl animate-blob-ultra-slow animation-delay-5000"
></div>
<!-- Medium floating elements -->
<div
class="absolute top-1/2 -left-20 w-64 h-64 bg-gradient-to-r from-primary/10 via-accent/15 to-primary/6 rounded-full blur-2xl animate-blob-ultra-slow animation-delay-8000"
></div>
<div
class="absolute top-1/4 -right-20 w-72 h-72 bg-gradient-to-r from-accent/10 via-primary/15 to-accent/6 rounded-full blur-2xl animate-blob-ultra-slow animation-delay-10000"
></div>
<!-- Medium floating elements -->
<div
class="absolute top-1/2 -left-20 w-64 h-64 bg-gradient-to-r from-primary/10 via-accent/15 to-primary/6 rounded-full blur-2xl animate-blob-ultra-slow animation-delay-8000"
></div>
<div
class="absolute top-1/4 -right-20 w-72 h-72 bg-gradient-to-r from-accent/10 via-primary/15 to-accent/6 rounded-full blur-2xl animate-blob-ultra-slow animation-delay-10000"
></div>
<!-- Small particle-like elements -->
<div
class="absolute top-1/3 left-1/4 w-32 h-32 bg-gradient-to-r from-primary/15 to-accent/12 rounded-full blur-xl animate-float animation-delay-2000"
></div>
<div
class="absolute bottom-1/3 right-1/3 w-40 h-40 bg-gradient-to-r from-accent/15 to-primary/12 rounded-full blur-xl animate-float animation-delay-6000"
></div>
<div
class="absolute top-2/3 left-1/2 w-24 h-24 bg-gradient-to-r from-primary/20 to-accent/15 rounded-full blur-lg animate-pulse-slow animation-delay-4000"
></div>
<!-- Small particle-like elements -->
<div
class="absolute top-1/3 left-1/4 w-32 h-32 bg-gradient-to-r from-primary/15 to-accent/12 rounded-full blur-xl animate-float animation-delay-2000"
></div>
<div
class="absolute bottom-1/3 right-1/3 w-40 h-40 bg-gradient-to-r from-accent/15 to-primary/12 rounded-full blur-xl animate-float animation-delay-6000"
></div>
<div
class="absolute top-2/3 left-1/2 w-24 h-24 bg-gradient-to-r from-primary/20 to-accent/15 rounded-full blur-lg animate-pulse-slow animation-delay-4000"
></div>
<!-- Glassmorphism overlay -->
<div
class="absolute inset-0 bg-gradient-to-br from-primary/2 via-transparent to-accent/2 backdrop-blur-[0.5px]"
></div>
</div>
<!-- Header -->
<Header authStatus={data.authStatus} />
<!-- Glassmorphism overlay -->
<div
class="absolute inset-0 bg-gradient-to-br from-primary/2 via-transparent to-accent/2 backdrop-blur-[0.5px]"
></div>
</div>
<!-- Header -->
<Header authStatus={data.authStatus} />
<!-- Main Content -->
<main class="min-h-screen">
{@render children()}
</main>
<!-- Main Content -->
<main class="min-h-screen">
{@render children()}
</main>
<!-- Footer -->
<Footer />
<!-- Footer -->
<Footer />
</div>

View File

@@ -1,7 +1,7 @@
import { getFeaturedModels, getFeaturedVideos } from "$lib/services";
export async function load({ fetch }) {
return {
models: await getFeaturedModels(3, fetch),
videos: await getFeaturedVideos(3, fetch),
};
return {
models: await getFeaturedModels(3, fetch),
videos: await getFeaturedVideos(3, fetch),
};
}

View File

@@ -1,24 +1,20 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card";
import { getAssetUrl } from "$lib/directus";
import Meta from "$lib/components/meta/meta.svelte";
import { formatVideoDuration } from "$lib/utils.js";
import { _ } from "svelte-i18n";
import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card";
import { getAssetUrl } from "$lib/directus";
import Meta from "$lib/components/meta/meta.svelte";
import { formatVideoDuration } from "$lib/utils.js";
const { data } = $props();
const { data } = $props();
</script>
<Meta title={$_('home.hero.title')} description={$_('home.hero.description')} />
<Meta title={$_("home.hero.title")} description={$_("home.hero.description")} />
<!-- Hero Section -->
<section
class="relative min-h-screen flex items-center justify-center overflow-hidden"
>
<section class="relative min-h-screen flex items-center justify-center overflow-hidden">
<!-- Background Gradient -->
<div
class="absolute inset-0 bg-gradient-to-br from-primary/20 via-accent/10 to-background"
></div>
<div class="absolute inset-0 bg-gradient-to-br from-primary/20 via-accent/10 to-background"></div>
<!-- Content -->
<div class="relative z-10 container mx-auto px-4 text-center">
@@ -26,13 +22,11 @@ const { data } = $props();
<h1
class="text-6xl md:text-8xl font-bold bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent leading-tight mb-8"
>
{$_('home.hero.title')}
{$_("home.hero.title")}
</h1>
<p
class="text-xl md:text-2xl text-muted-foreground max-w-3xl mx-auto leading-relaxed"
>
{$_('home.hero.description')}
<p class="text-xl md:text-2xl text-muted-foreground max-w-3xl mx-auto leading-relaxed">
{$_("home.hero.description")}
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
@@ -42,13 +36,13 @@ const { data } = $props();
href="/videos"
>
<span class="icon-[ri--play-large-fill]"></span>
{$_('home.hero.cta_videos')}
{$_("home.hero.cta_videos")}
</Button>
<Button
variant="outline"
size="lg"
class="text-lg px-8 py-6 border-primary/50 hover:bg-primary/10"
href="/models">{$_('home.hero.cta_models')}</Button
href="/models">{$_("home.hero.cta_models")}</Button
>
</div>
</div>
@@ -68,10 +62,10 @@ const { data } = $props();
<div class="container mx-auto px-4">
<div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-bold mb-4">
{$_('home.featured_models.title')}
{$_("home.featured_models.title")}
</h2>
<p class="text-muted-foreground text-lg">
{$_('home.featured_models.description')}
{$_("home.featured_models.description")}
</p>
</div>
@@ -83,7 +77,7 @@ const { data } = $props();
<CardContent class="p-6 text-center">
<div class="relative mb-4">
<img
src={getAssetUrl(model.avatar, 'mini')}
src={getAssetUrl(model.avatar, "mini")}
alt={model.artist_name}
class="w-24 h-24 rounded-full mx-auto object-cover ring-4 ring-primary/20 group-hover:ring-primary/40 transition-all"
/>
@@ -107,8 +101,7 @@ const { data } = $props();
variant="ghost"
size="sm"
class="mt-4 w-full group-hover:bg-primary/10"
href="/models/{model.slug}"
>{$_('home.featured_models.view_profile')}</Button
href="/models/{model.slug}">{$_("home.featured_models.view_profile")}</Button
>
</CardContent>
</Card>
@@ -122,7 +115,7 @@ const { data } = $props();
<div class="container mx-auto px-4">
<div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-bold mb-4">
{$_('home.trending.title')}
{$_("home.trending.title")}
</h2>
<!-- <p class="text-muted-foreground text-lg">Most watched romantic content</p> -->
</div>
@@ -134,16 +127,14 @@ const { data } = $props();
>
<div class="relative">
<img
src={getAssetUrl(video.image, 'preview')}
src={getAssetUrl(video.image, "preview")}
alt={video.title}
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300"
/>
<div
class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent group-hover:scale-105 transition-transform duration-300"
></div>
<div
class="absolute bottom-2 left-2 text-white text-sm font-medium"
>
<div class="absolute bottom-2 left-2 text-white text-sm font-medium">
{#if video.movie_file?.duration}{formatVideoDuration(video.movie_file.duration)}{/if}
</div>
<!-- <div
@@ -160,21 +151,18 @@ const { data } = $props();
href="/videos/{video.slug}"
aria-label={video.title}
>
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white"
></span>
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white"></span>
</a>
</div>
</div>
<CardContent class="px-4 pb-4 pt-0">
<h3
class="font-semibold mb-2 group-hover:text-primary transition-colors"
>
<h3 class="font-semibold mb-2 group-hover:text-primary transition-colors">
{video.title}
</h3>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<span class="icon-[ri--fire-line] w-4 h-4"></span>
{$_('home.trending.trending')}
{$_("home.trending.trending")}
</div>
</CardContent>
</Card>
@@ -184,29 +172,27 @@ const { data } = $props();
</section>
<!-- CTA Section -->
<section
class="py-20 bg-gradient-to-r from-primary/10 via-accent/10 to-primary/10"
>
<section class="py-20 bg-gradient-to-r from-primary/10 via-accent/10 to-primary/10">
<div class="container mx-auto px-4 text-center">
<div class="max-w-3xl mx-auto space-y-8">
<h2 class="text-3xl md:text-4xl font-bold">
{$_('home.featured_models.join_community')}
{$_("home.featured_models.join_community")}
</h2>
<p class="text-lg text-muted-foreground">
{$_('home.featured_models.join_community_description')}
{$_("home.featured_models.join_community_description")}
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<Button
href="/signup"
size="lg"
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 text-lg px-8 py-6"
>{$_('home.community.cta_join')}</Button
>{$_("home.community.cta_join")}</Button
>
<Button
variant="outline"
size="lg"
class="text-lg px-8 py-6 border-primary/50 hover:bg-primary/10"
href="/magazine">{$_('home.community.cta_magazine')}</Button
href="/magazine">{$_("home.community.cta_magazine")}</Button
>
</div>
</div>

View File

@@ -1,6 +1,6 @@
import { getStats } from "$lib/services";
export async function load({ fetch }) {
return {
stats: await getStats(fetch),
};
return {
stats: await getStats(fetch),
};
}

View File

@@ -1,312 +1,310 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card";
import Meta from "$lib/components/meta/meta.svelte";
import PeonyBackground from "$lib/components/background/peony-background.svelte";
import { _ } from "svelte-i18n";
import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card";
import Meta from "$lib/components/meta/meta.svelte";
import PeonyBackground from "$lib/components/background/peony-background.svelte";
const { data } = $props();
const { data } = $props();
const stats = [
{
icon: "icon-[ri--user-heart-line]",
value: data.stats.viewers_count,
label: $_("about.stats.members"),
},
{
icon: "icon-[ri--video-on-line]",
value: data.stats.videos_count,
label: $_("about.stats.videos"),
},
{
icon: "icon-[ri--star-line]",
value: data.stats.models_count,
label: $_("about.stats.models"),
},
{
icon: "icon-[ri--award-line]",
value: $_("about.stats.yearsFormatted", { values: { years: 5 } }),
label: $_("about.stats.experience"),
},
];
const stats = [
{
icon: "icon-[ri--user-heart-line]",
value: data.stats.viewers_count,
label: $_("about.stats.members"),
},
{
icon: "icon-[ri--video-on-line]",
value: data.stats.videos_count,
label: $_("about.stats.videos"),
},
{
icon: "icon-[ri--star-line]",
value: data.stats.models_count,
label: $_("about.stats.models"),
},
{
icon: "icon-[ri--award-line]",
value: $_("about.stats.yearsFormatted", { values: { years: 5 } }),
label: $_("about.stats.experience"),
},
];
const team = [
{
name: $_("about.team.sebastian.name"),
role: $_("about.team.sebastian.role"),
image: $_("about.team.sebastian.image"),
bio: $_("about.team.sebastian.bio"),
},
{
name: $_("about.team.valknar.name"),
role: $_("about.team.valknar.role"),
image: $_("about.team.valknar.image"),
bio: $_("about.team.valknar.bio"),
},
];
const team = [
{
name: $_("about.team.sebastian.name"),
role: $_("about.team.sebastian.role"),
image: $_("about.team.sebastian.image"),
bio: $_("about.team.sebastian.bio"),
},
{
name: $_("about.team.valknar.name"),
role: $_("about.team.valknar.role"),
image: $_("about.team.valknar.image"),
bio: $_("about.team.valknar.bio"),
},
];
const values = [
{
icon: "icon-[ri--heart-line]",
title: $_("about.values.authentic_expression.title"),
description: $_("about.values.authentic_expression.description"),
},
{
icon: "icon-[ri--shield-line]",
title: $_("about.values.safety_respect.title"),
description: $_("about.values.safety_respect.description"),
},
{
icon: "icon-[ri--star-line]",
title: $_("about.values.artistic_excellence.title"),
description: $_("about.values.artistic_excellence.description"),
},
{
icon: "icon-[ri--user-heart-line]",
title: $_("about.values.community_first.title"),
description: $_("about.values.community_first.description"),
},
];
const values = [
{
icon: "icon-[ri--heart-line]",
title: $_("about.values.authentic_expression.title"),
description: $_("about.values.authentic_expression.description"),
},
{
icon: "icon-[ri--shield-line]",
title: $_("about.values.safety_respect.title"),
description: $_("about.values.safety_respect.description"),
},
{
icon: "icon-[ri--star-line]",
title: $_("about.values.artistic_excellence.title"),
description: $_("about.values.artistic_excellence.description"),
},
{
icon: "icon-[ri--user-heart-line]",
title: $_("about.values.community_first.title"),
description: $_("about.values.community_first.description"),
},
];
</script>
<Meta title={$_("about.title")} description={$_("about.subtitle")} />
<div
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
>
<PeonyBackground />
<PeonyBackground />
<!-- Hero Section -->
<section class="relative py-20 overflow-hidden">
<div
class="absolute inset-0 bg-gradient-to-br from-primary/10 via-accent/5 to-background"
></div>
<div class="relative container mx-auto px-4 text-center">
<div class="max-w-5xl mx-auto">
<h1
class="text-5xl md:text-7xl font-bold mb-8 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
<!-- Hero Section -->
<section class="relative py-20 overflow-hidden">
<div
class="absolute inset-0 bg-gradient-to-br from-primary/10 via-accent/5 to-background"
></div>
<div class="relative container mx-auto px-4 text-center">
<div class="max-w-5xl mx-auto">
<h1
class="text-5xl md:text-7xl font-bold mb-8 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
>
{$_("about.title")}
</h1>
<p
class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto"
>
{$_("about.subtitle")}
</p>
<div class="flex justify-center">
<Button
size="lg"
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
href="/signup">{$_("about.join_community")}</Button
>
</div>
</div>
</div>
</section>
<!-- Stats Section -->
<section class="py-16 bg-card/30">
<div class="container mx-auto px-4">
<div class="grid grid-cols-2 md:grid-cols-4 gap-8">
{#each stats as stat (stat.icon)}
<div class="text-center">
<div
class="w-16 h-16 bg-gradient-to-br from-primary/20 to-accent/20 rounded-full flex items-center justify-center mx-auto mb-4"
>
<span class={stat.icon + " w-8 h-8 text-primary"}></span>
</div>
<div class="text-3xl font-bold text-primary mb-2">{stat.value}</div>
<div class="text-muted-foreground">{stat.label}</div>
</div>
{/each}
</div>
</div>
</section>
<!-- Our Story Section -->
<section class="py-20">
<div class="container mx-auto px-4">
<div class="max-w-4xl mx-auto">
<div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-bold mb-4">
{$_("about.story.title")}
</h2>
<p class="text-lg text-muted-foreground">
{$_("about.story.subtitle")}
</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
<div class="space-y-6">
<p class="text-muted-foreground leading-relaxed text-lg">
{$_("about.story.description_part1")}
</p>
<p class="text-muted-foreground leading-relaxed text-lg">
{$_("about.story.description_part2")}
</p>
<p class="text-muted-foreground leading-relaxed text-lg">
{$_("about.story.description_part3")}
</p>
</div>
<div class="relative">
<img
src="/img/babes.jpg"
alt="Our story"
class="w-full object-cover rounded-2xl shadow-2xl"
/>
<div
class="absolute inset-0 bg-gradient-to-t from-primary/20 to-transparent rounded-2xl"
></div>
</div>
</div>
</div>
</div>
</section>
<!-- Values Section -->
<section class="py-20 bg-card/30">
<div class="container mx-auto px-4">
<div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-bold mb-4">
{$_("about.values.title")}
</h2>
<p class="text-lg text-muted-foreground max-w-2xl mx-auto">
{$_("about.values.subtitle")}
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-4xl mx-auto">
{#each values as value (value.title)}
<Card
class="bg-gradient-to-br from-card to-card/50 border-primary/20 hover:shadow-xl hover:shadow-primary/10 transition-all duration-300"
>
<CardContent class="p-6">
<div class="flex items-start gap-4">
<div
class="w-12 h-12 bg-gradient-to-br from-primary/20 to-accent/20 rounded-full flex items-center justify-center flex-shrink-0"
>
{$_("about.title")}
</h1>
<p
class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto"
>
{$_("about.subtitle")}
</p>
<div class="flex justify-center">
<Button
size="lg"
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
href="/signup">{$_("about.join_community")}</Button
>
<span class={value.icon + " w-6 h-6 text-primary"}></span>
</div>
</div>
</div>
</section>
<!-- Stats Section -->
<section class="py-16 bg-card/30">
<div class="container mx-auto px-4">
<div class="grid grid-cols-2 md:grid-cols-4 gap-8">
{#each stats as stat (stat.icon)}
<div class="text-center">
<div
class="w-16 h-16 bg-gradient-to-br from-primary/20 to-accent/20 rounded-full flex items-center justify-center mx-auto mb-4"
>
<span class={stat.icon + " w-8 h-8 text-primary"}></span>
</div>
<div class="text-3xl font-bold text-primary mb-2">{stat.value}</div>
<div class="text-muted-foreground">{stat.label}</div>
</div>
{/each}
</div>
</div>
</section>
<!-- Our Story Section -->
<section class="py-20">
<div class="container mx-auto px-4">
<div class="max-w-4xl mx-auto">
<div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-bold mb-4">
{$_("about.story.title")}
</h2>
<p class="text-lg text-muted-foreground">
{$_("about.story.subtitle")}
</p>
<div>
<h3 class="font-semibold text-lg mb-2">{value.title}</h3>
<p class="text-muted-foreground leading-relaxed">
{value.description}
</p>
</div>
</div>
</CardContent>
</Card>
{/each}
</div>
</div>
</section>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
<div class="space-y-6">
<p class="text-muted-foreground leading-relaxed text-lg">
{$_("about.story.description_part1")}
</p>
<p class="text-muted-foreground leading-relaxed text-lg">
{$_("about.story.description_part2")}
</p>
<p class="text-muted-foreground leading-relaxed text-lg">
{$_("about.story.description_part3")}
</p>
</div>
<div class="relative">
<img
src="/img/babes.jpg"
alt="Our story"
class="w-full object-cover rounded-2xl shadow-2xl"
/>
<div
class="absolute inset-0 bg-gradient-to-t from-primary/20 to-transparent rounded-2xl"
></div>
</div>
</div>
</div>
<!-- Team Section -->
<section class="py-20">
<div class="container mx-auto px-4 max-w-xl">
<div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-bold mb-4">
{$_("about.team.title")}
</h2>
<p class="text-lg text-muted-foreground max-w-2xl mx-auto">
{$_("about.team.subtitle")}
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
{#each team as member (member.name)}
<Card
class="bg-gradient-to-br from-card to-card/50 border-primary/20 hover:shadow-xl hover:shadow-primary/10 transition-all duration-300 hover:-translate-y-2"
>
<CardContent class="p-6 text-center">
<img
src={member.image}
alt={member.name}
class="w-24 h-24 rounded-full mx-auto mb-4 object-cover ring-4 ring-primary/20"
/>
<h3 class="font-semibold text-lg mb-1">{member.name}</h3>
<p class="text-primary text-sm mb-3">{member.role}</p>
<p class="text-muted-foreground text-sm leading-relaxed">
{member.bio}
</p>
</CardContent>
</Card>
{/each}
</div>
</div>
</section>
<!-- Mission Section -->
<section class="py-20 bg-gradient-to-r from-primary/10 via-accent/10 to-primary/10">
<div class="container mx-auto px-4 text-center">
<div class="max-w-4xl mx-auto">
<h2 class="text-3xl md:text-4xl font-bold mb-6">
{$_("about.mission.title")}
</h2>
<p class="text-xl text-muted-foreground mb-8 leading-relaxed">
{$_("about.mission.description")}
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<Button
size="lg"
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
href="/signup">{$_("about.mission.cta_creator")}</Button
>
<Button
variant="outline"
size="lg"
class="border-primary/50 hover:bg-primary/10"
href="/signup">{$_("about.mission.cta_community")}</Button
>
</div>
</section>
</div>
</div>
</section>
<!-- Values Section -->
<section class="py-20 bg-card/30">
<div class="container mx-auto px-4">
<div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-bold mb-4">
{$_("about.values.title")}
</h2>
<p class="text-lg text-muted-foreground max-w-2xl mx-auto">
{$_("about.values.subtitle")}
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-4xl mx-auto">
{#each values as value (value.title)}
<Card
class="bg-gradient-to-br from-card to-card/50 border-primary/20 hover:shadow-xl hover:shadow-primary/10 transition-all duration-300"
>
<CardContent class="p-6">
<div class="flex items-start gap-4">
<div
class="w-12 h-12 bg-gradient-to-br from-primary/20 to-accent/20 rounded-full flex items-center justify-center flex-shrink-0"
>
<span class={value.icon + " w-6 h-6 text-primary"}></span>
</div>
<div>
<h3 class="font-semibold text-lg mb-2">{value.title}</h3>
<p class="text-muted-foreground leading-relaxed">
{value.description}
</p>
</div>
</div>
</CardContent>
</Card>
{/each}
</div>
<!-- Contact Section -->
<section class="py-20">
<div class="container mx-auto px-4">
<div class="max-w-2xl mx-auto text-center">
<h2 class="text-3xl md:text-4xl font-bold mb-6">
{$_("about.contact.title")}
</h2>
<p class="text-lg text-muted-foreground mb-8">
{$_("about.contact.description")}
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card class="bg-gradient-to-br from-card to-card/50 border-primary/20">
<CardContent class="p-6 text-center">
<h3 class="font-semibold mb-2">
{$_("about.contact.general.title")}
</h3>
<p class="text-muted-foreground text-sm mb-4">
{$_("about.contact.general.description")}
</p>
<a
href="mailto:{$_('about.contact.general.mailto')}"
class="text-primary hover:underline">{$_("about.contact.general.mailto")}</a
>
</CardContent>
</Card>
<Card class="bg-gradient-to-br from-card to-card/50 border-primary/20">
<CardContent class="p-6 text-center">
<h3 class="font-semibold mb-2">
{$_("about.contact.creators.title")}
</h3>
<p class="text-muted-foreground text-sm mb-4">
{$_("about.contact.creators.description")}
</p>
<a
href="mailto:{$_('about.contact.creators.mailto')}"
class="text-primary hover:underline">{$_("about.contact.creators.mailto")}</a
>
</CardContent>
</Card>
</div>
</section>
<!-- Team Section -->
<section class="py-20">
<div class="container mx-auto px-4 max-w-xl">
<div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-bold mb-4">
{$_("about.team.title")}
</h2>
<p class="text-lg text-muted-foreground max-w-2xl mx-auto">
{$_("about.team.subtitle")}
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
{#each team as member (member.name)}
<Card
class="bg-gradient-to-br from-card to-card/50 border-primary/20 hover:shadow-xl hover:shadow-primary/10 transition-all duration-300 hover:-translate-y-2"
>
<CardContent class="p-6 text-center">
<img
src={member.image}
alt={member.name}
class="w-24 h-24 rounded-full mx-auto mb-4 object-cover ring-4 ring-primary/20"
/>
<h3 class="font-semibold text-lg mb-1">{member.name}</h3>
<p class="text-primary text-sm mb-3">{member.role}</p>
<p class="text-muted-foreground text-sm leading-relaxed">
{member.bio}
</p>
</CardContent>
</Card>
{/each}
</div>
</div>
</section>
<!-- Mission Section -->
<section class="py-20 bg-gradient-to-r from-primary/10 via-accent/10 to-primary/10">
<div class="container mx-auto px-4 text-center">
<div class="max-w-4xl mx-auto">
<h2 class="text-3xl md:text-4xl font-bold mb-6">
{$_("about.mission.title")}
</h2>
<p class="text-xl text-muted-foreground mb-8 leading-relaxed">
{$_("about.mission.description")}
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<Button
size="lg"
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
href="/signup">{$_("about.mission.cta_creator")}</Button
>
<Button
variant="outline"
size="lg"
class="border-primary/50 hover:bg-primary/10"
href="/signup">{$_("about.mission.cta_community")}</Button
>
</div>
</div>
</div>
</section>
<!-- Contact Section -->
<section class="py-20">
<div class="container mx-auto px-4">
<div class="max-w-2xl mx-auto text-center">
<h2 class="text-3xl md:text-4xl font-bold mb-6">
{$_("about.contact.title")}
</h2>
<p class="text-lg text-muted-foreground mb-8">
{$_("about.contact.description")}
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card class="bg-gradient-to-br from-card to-card/50 border-primary/20">
<CardContent class="p-6 text-center">
<h3 class="font-semibold mb-2">
{$_("about.contact.general.title")}
</h3>
<p class="text-muted-foreground text-sm mb-4">
{$_("about.contact.general.description")}
</p>
<a
href="mailto:{$_('about.contact.general.mailto')}"
class="text-primary hover:underline"
>{$_("about.contact.general.mailto")}</a
>
</CardContent>
</Card>
<Card class="bg-gradient-to-br from-card to-card/50 border-primary/20">
<CardContent class="p-6 text-center">
<h3 class="font-semibold mb-2">
{$_("about.contact.creators.title")}
</h3>
<p class="text-muted-foreground text-sm mb-4">
{$_("about.contact.creators.description")}
</p>
<a
href="mailto:{$_('about.contact.creators.mailto')}"
class="text-primary hover:underline"
>{$_("about.contact.creators.mailto")}</a
>
</CardContent>
</Card>
</div>
</div>
</div>
</section>
</div>
</div>
</section>
</div>

View File

@@ -1,358 +1,346 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import { SvelteSet } from "svelte/reactivity";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "$lib/components/ui/card";
import { Input } from "$lib/components/ui/input";
import { Button } from "$lib/components/ui/button";
import PeonyBackground from "$lib/components/background/peony-background.svelte";
import Meta from "$lib/components/meta/meta.svelte";
import { _ } from "svelte-i18n";
import { SvelteSet } from "svelte/reactivity";
import { Card, CardContent, CardHeader, CardTitle } from "$lib/components/ui/card";
import { Input } from "$lib/components/ui/input";
import { Button } from "$lib/components/ui/button";
import PeonyBackground from "$lib/components/background/peony-background.svelte";
import Meta from "$lib/components/meta/meta.svelte";
let searchQuery = $state("");
let expandedItems = new SvelteSet<number>();
let searchQuery = $state("");
let expandedItems = new SvelteSet<number>();
const faqCategories = [
{
id: 1,
title: $_("faq.getting_started.title"),
icon: "icon-[ri--home-heart-line]",
questions: [
{
id: 1,
question: $_("faq.getting_started.questions.0.question"),
answer: $_("faq.getting_started.questions.0.answer"),
},
{
id: 2,
question: $_("faq.getting_started.questions.1.question"),
answer: $_("faq.getting_started.questions.1.answer"),
},
{
id: 3,
question: $_("faq.getting_started.questions.2.question"),
answer: $_("faq.getting_started.questions.2.answer"),
},
{
id: 4,
question: $_("faq.getting_started.questions.3.question"),
answer: $_("faq.getting_started.questions.3.answer"),
},
],
},
{
id: 2,
title: $_("faq.creators.title"),
icon: "icon-[ri--user-heart-line]",
questions: [
{
id: 5,
question: $_("faq.creators.questions.0.question"),
answer: $_("faq.creators.questions.0.answer"),
},
{
id: 6,
question: $_("faq.creators.questions.1.question"),
answer: $_("faq.creators.questions.1.answer"),
},
{
id: 7,
question: $_("faq.creators.questions.2.question"),
answer: $_("faq.creators.questions.2.answer"),
},
{
id: 8,
question: $_("faq.creators.questions.3.question"),
answer: $_("faq.creators.questions.3.answer"),
},
],
},
// {
// id: 3,
// title: $_("faq.categories.payments"),
// icon: CreditCardIcon,
// questions: [
// {
// id: 9,
// question: "What payment methods do you accept?",
// answer:
// "We accept all major credit cards (Visa, MasterCard, American Express), PayPal, and various digital payment methods. All transactions are processed securely through encrypted payment gateways.",
// },
// {
// id: 10,
// question: "How does billing work?",
// answer:
// "Subscriptions are billed monthly or annually depending on your chosen plan. You'll be charged on the same date each billing cycle. We send email notifications before each billing date, and you can cancel anytime from your account settings.",
// },
// {
// id: 11,
// question: "Can I get a refund?",
// answer:
// "We offer refunds within 7 days of purchase if you haven't accessed premium content. For technical issues or billing errors, contact our support team. Refunds are processed within 5-10 business days to your original payment method.",
// },
// {
// id: 12,
// question: "How do creator payouts work?",
// answer:
// "Creators are paid weekly via direct deposit, PayPal, or wire transfer. Minimum payout is $50. Earnings are calculated after a 7-day processing period to account for potential chargebacks or refunds.",
// },
// ],
// },
{
id: 4,
title: $_("faq.privacy.title"),
icon: "icon-[ri--git-repository-private-line]",
questions: [
{
id: 13,
question: $_("faq.privacy.questions.0.question"),
answer: $_("faq.privacy.questions.0.answer"),
},
{
id: 14,
question: $_("faq.privacy.questions.1.question"),
answer: $_("faq.privacy.questions.1.answer"),
},
{
id: 15,
question: $_("faq.privacy.questions.2.question"),
answer: $_("faq.privacy.questions.2.answer"),
},
{
id: 16,
question: $_("faq.privacy.questions.3.question"),
answer: $_("faq.privacy.questions.3.answer"),
},
],
},
{
id: 5,
title: $_("faq.technical.title"),
icon: "icon-[ri--settings-3-line]",
questions: [
{
id: 17,
question: $_("faq.technical.questions.0.question"),
answer: $_("faq.technical.questions.0.answer"),
},
{
id: 18,
question: $_("faq.technical.questions.1.question"),
answer: $_("faq.technical.questions.1.answer"),
},
{
id: 19,
question: $_("faq.technical.questions.2.question"),
answer: $_("faq.technical.questions.2.answer"),
},
{
id: 20,
question: $_("faq.technical.questions.3.question"),
answer: $_("faq.technical.questions.3.answer"),
},
],
},
];
const faqCategories = [
{
id: 1,
title: $_("faq.getting_started.title"),
icon: "icon-[ri--home-heart-line]",
questions: [
{
id: 1,
question: $_("faq.getting_started.questions.0.question"),
answer: $_("faq.getting_started.questions.0.answer"),
},
{
id: 2,
question: $_("faq.getting_started.questions.1.question"),
answer: $_("faq.getting_started.questions.1.answer"),
},
{
id: 3,
question: $_("faq.getting_started.questions.2.question"),
answer: $_("faq.getting_started.questions.2.answer"),
},
{
id: 4,
question: $_("faq.getting_started.questions.3.question"),
answer: $_("faq.getting_started.questions.3.answer"),
},
],
},
{
id: 2,
title: $_("faq.creators.title"),
icon: "icon-[ri--user-heart-line]",
questions: [
{
id: 5,
question: $_("faq.creators.questions.0.question"),
answer: $_("faq.creators.questions.0.answer"),
},
{
id: 6,
question: $_("faq.creators.questions.1.question"),
answer: $_("faq.creators.questions.1.answer"),
},
{
id: 7,
question: $_("faq.creators.questions.2.question"),
answer: $_("faq.creators.questions.2.answer"),
},
{
id: 8,
question: $_("faq.creators.questions.3.question"),
answer: $_("faq.creators.questions.3.answer"),
},
],
},
// {
// id: 3,
// title: $_("faq.categories.payments"),
// icon: CreditCardIcon,
// questions: [
// {
// id: 9,
// question: "What payment methods do you accept?",
// answer:
// "We accept all major credit cards (Visa, MasterCard, American Express), PayPal, and various digital payment methods. All transactions are processed securely through encrypted payment gateways.",
// },
// {
// id: 10,
// question: "How does billing work?",
// answer:
// "Subscriptions are billed monthly or annually depending on your chosen plan. You'll be charged on the same date each billing cycle. We send email notifications before each billing date, and you can cancel anytime from your account settings.",
// },
// {
// id: 11,
// question: "Can I get a refund?",
// answer:
// "We offer refunds within 7 days of purchase if you haven't accessed premium content. For technical issues or billing errors, contact our support team. Refunds are processed within 5-10 business days to your original payment method.",
// },
// {
// id: 12,
// question: "How do creator payouts work?",
// answer:
// "Creators are paid weekly via direct deposit, PayPal, or wire transfer. Minimum payout is $50. Earnings are calculated after a 7-day processing period to account for potential chargebacks or refunds.",
// },
// ],
// },
{
id: 4,
title: $_("faq.privacy.title"),
icon: "icon-[ri--git-repository-private-line]",
questions: [
{
id: 13,
question: $_("faq.privacy.questions.0.question"),
answer: $_("faq.privacy.questions.0.answer"),
},
{
id: 14,
question: $_("faq.privacy.questions.1.question"),
answer: $_("faq.privacy.questions.1.answer"),
},
{
id: 15,
question: $_("faq.privacy.questions.2.question"),
answer: $_("faq.privacy.questions.2.answer"),
},
{
id: 16,
question: $_("faq.privacy.questions.3.question"),
answer: $_("faq.privacy.questions.3.answer"),
},
],
},
{
id: 5,
title: $_("faq.technical.title"),
icon: "icon-[ri--settings-3-line]",
questions: [
{
id: 17,
question: $_("faq.technical.questions.0.question"),
answer: $_("faq.technical.questions.0.answer"),
},
{
id: 18,
question: $_("faq.technical.questions.1.question"),
answer: $_("faq.technical.questions.1.answer"),
},
{
id: 19,
question: $_("faq.technical.questions.2.question"),
answer: $_("faq.technical.questions.2.answer"),
},
{
id: 20,
question: $_("faq.technical.questions.3.question"),
answer: $_("faq.technical.questions.3.answer"),
},
],
},
];
const allQuestions = faqCategories.flatMap((category) =>
category.questions.map((q) => ({ ...q, categoryTitle: category.title })),
);
const allQuestions = faqCategories.flatMap((category) =>
category.questions.map((q) => ({ ...q, categoryTitle: category.title })),
);
const filteredQuestions = $derived(() => {
if (!searchQuery.trim()) return allQuestions;
return allQuestions.filter(
(q) =>
q.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
q.answer.toLowerCase().includes(searchQuery.toLowerCase()) ||
q.categoryTitle.toLowerCase().includes(searchQuery.toLowerCase()),
);
});
const filteredQuestions = $derived(() => {
if (!searchQuery.trim()) return allQuestions;
return allQuestions.filter(
(q) =>
q.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
q.answer.toLowerCase().includes(searchQuery.toLowerCase()) ||
q.categoryTitle.toLowerCase().includes(searchQuery.toLowerCase()),
);
});
function toggleExpanded(id: number) {
const newExpanded = new SvelteSet(expandedItems);
if (newExpanded.has(id)) {
newExpanded.delete(id);
} else {
newExpanded.add(id);
}
expandedItems = newExpanded;
}
function toggleExpanded(id: number) {
const newExpanded = new SvelteSet(expandedItems);
if (newExpanded.has(id)) {
newExpanded.delete(id);
} else {
newExpanded.add(id);
}
expandedItems = newExpanded;
}
</script>
<Meta title={$_("faq.title")} description={$_("faq.description")} />
<div
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
>
<PeonyBackground />
<PeonyBackground />
<div class="container mx-auto py-20 relative px-4">
<!-- Header -->
<div class="text-center mb-16">
<h1
class="text-5xl md:text-6xl font-bold mb-8 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
<div class="container mx-auto py-20 relative px-4">
<!-- Header -->
<div class="text-center mb-16">
<h1
class="text-5xl md:text-6xl font-bold mb-8 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
>
{$_("faq.title")}
</h1>
<p class="text-xl text-muted-foreground max-w-3xl mx-auto leading-relaxed">
{$_("faq.description")}
</p>
</div>
<!-- Search -->
<div class="max-w-2xl mx-auto mb-12">
<div class="relative">
<span class="icon-[ri--search-line] absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5"
></span>
<Input
placeholder={$_("faq.search_placeholder")}
bind:value={searchQuery}
class="pl-12 h-14 text-lg bg-card/50 border-primary/20 focus:border-primary"
/>
</div>
</div>
{#if searchQuery.trim()}
<!-- Search Results -->
<div class="max-w-4xl mx-auto">
<h2 class="text-2xl font-bold mb-6">
{$_("faq.search_results", {
values: { count: filteredQuestions().length },
})}
</h2>
<div class="space-y-4">
{#each filteredQuestions() as question (question.id)}
<Card
class="bg-gradient-to-br from-card/90 via-card/95 to-card/85 backdrop-blur-xl shadow-lg shadow-primary/10"
>
{$_("faq.title")}
</h1>
<p class="text-xl text-muted-foreground max-w-3xl mx-auto leading-relaxed">
{$_("faq.description")}
</p>
</div>
<!-- Search -->
<div class="max-w-2xl mx-auto mb-12">
<div class="relative">
<span
class="icon-[ri--search-line] absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5"
></span>
<Input
placeholder={$_("faq.search_placeholder")}
bind:value={searchQuery}
class="pl-12 h-14 text-lg bg-card/50 border-primary/20 focus:border-primary"
/>
</div>
</div>
{#if searchQuery.trim()}
<!-- Search Results -->
<div class="max-w-4xl mx-auto">
<h2 class="text-2xl font-bold mb-6">
{$_("faq.search_results", {
values: { count: filteredQuestions().length },
})}
</h2>
<div class="space-y-4">
{#each filteredQuestions() as question (question.id)}
<Card
class="bg-gradient-to-br from-card/90 via-card/95 to-card/85 backdrop-blur-xl shadow-lg shadow-primary/10"
>
<CardContent class="p-0">
<button
onclick={() => toggleExpanded(question.id)}
class="w-full p-6 text-left hover:bg-primary/5 transition-colors flex items-center justify-between"
>
<div class="flex-1">
<div class="text-sm text-primary font-medium mb-1">
{question.categoryTitle}
</div>
<h3 class="font-semibold text-lg">{question.question}</h3>
</div>
{#if expandedItems.has(question.id)}
<span
class="icon-[ri--arrow-drop-up-line] w-7 h-7 text-muted-foreground flex-shrink-0 ml-4"
></span>
{:else}
<span
class="icon-[ri--arrow-drop-down-line] w-7 h-7 text-muted-foreground flex-shrink-0 ml-4"
></span>
{/if}
</button>
{#if expandedItems.has(question.id)}
<div class="p-6">
<p class="text-muted-foreground leading-relaxed">
{question.answer}
</p>
</div>
{/if}
</CardContent>
</Card>
{/each}
</div>
{#if filteredQuestions.length === 0}
<div class="text-center py-12">
<p class="text-muted-foreground text-lg">{$_("faq.no_results")}</p>
<Button variant="outline" onclick={() => (searchQuery = "")} class="mt-4"
>{$_("faq.clear_search")}</Button
>
<CardContent class="p-0">
<button
onclick={() => toggleExpanded(question.id)}
class="w-full p-6 text-left hover:bg-primary/5 transition-colors flex items-center justify-between"
>
<div class="flex-1">
<div class="text-sm text-primary font-medium mb-1">
{question.categoryTitle}
</div>
{/if}
</div>
{:else}
<!-- Category View -->
<div class="max-w-6xl mx-auto">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
{#each faqCategories as category (category.id)}
<Card
class="bg-gradient-to-br from-card/90 via-card/95 to-card/85 backdrop-blur-xl shadow-lg shadow-primary/10"
>
<CardHeader class="pb-4">
<CardTitle class="flex items-center gap-3 text-xl">
<div
class="w-10 h-10 bg-gradient-to-br from-primary/20 to-accent/20 rounded-full flex items-center justify-center"
>
<span class={category.icon + " w-5 h-5 text-primary"}
></span>
</div>
{category.title}
</CardTitle>
</CardHeader>
<CardContent class="pt-0">
<div class="space-y-3">
{#each category.questions as question (question.id)}
<div
class="border border-border/50 rounded-lg overflow-hidden"
>
<button
onclick={() => toggleExpanded(question.id)}
class="w-full p-4 text-left hover:bg-primary/5 transition-colors flex items-center justify-between"
>
<h4 class="font-medium text-sm pr-4">
{question.question}
</h4>
{#if expandedItems.has(question.id)}
<span
class="icon-[ri--arrow-drop-up-line] w-6 h-6 text-muted-foreground flex-shrink-0"
></span>
{:else}
<span
class="icon-[ri--arrow-drop-down-line] w-6 h-6 text-muted-foreground flex-shrink-0"
></span>
{/if}
</button>
{#if expandedItems.has(question.id)}
<div class="p-4 border-t border-border/50">
<p
class="text-muted-foreground text-sm leading-relaxed"
>
{question.answer}
</p>
</div>
{/if}
</div>
{/each}
</div>
</CardContent>
</Card>
{/each}
</div>
</div>
{/if}
<!-- Contact Support -->
<div class="max-w-2xl mx-auto mt-16">
<Card class="bg-gradient-to-br from-primary/10 to-accent/10 border-primary/20">
<CardContent class="p-8 text-center">
<h3 class="text-2xl font-bold mb-4">{$_("faq.support.title")}</h3>
<p class="text-muted-foreground mb-6 leading-relaxed">
{$_("faq.support.description")}
<h3 class="font-semibold text-lg">{question.question}</h3>
</div>
{#if expandedItems.has(question.id)}
<span
class="icon-[ri--arrow-drop-up-line] w-7 h-7 text-muted-foreground flex-shrink-0 ml-4"
></span>
{:else}
<span
class="icon-[ri--arrow-drop-down-line] w-7 h-7 text-muted-foreground flex-shrink-0 ml-4"
></span>
{/if}
</button>
{#if expandedItems.has(question.id)}
<div class="p-6">
<p class="text-muted-foreground leading-relaxed">
{question.answer}
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<Button
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
href="mailto:{$_('faq.support.contact_email')}"
>{$_("faq.support.contact")}</Button
>
<!-- <Button
</div>
{/if}
</CardContent>
</Card>
{/each}
</div>
{#if filteredQuestions.length === 0}
<div class="text-center py-12">
<p class="text-muted-foreground text-lg">{$_("faq.no_results")}</p>
<Button variant="outline" onclick={() => (searchQuery = "")} class="mt-4"
>{$_("faq.clear_search")}</Button
>
</div>
{/if}
</div>
{:else}
<!-- Category View -->
<div class="max-w-6xl mx-auto">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
{#each faqCategories as category (category.id)}
<Card
class="bg-gradient-to-br from-card/90 via-card/95 to-card/85 backdrop-blur-xl shadow-lg shadow-primary/10"
>
<CardHeader class="pb-4">
<CardTitle class="flex items-center gap-3 text-xl">
<div
class="w-10 h-10 bg-gradient-to-br from-primary/20 to-accent/20 rounded-full flex items-center justify-center"
>
<span class={category.icon + " w-5 h-5 text-primary"}></span>
</div>
{category.title}
</CardTitle>
</CardHeader>
<CardContent class="pt-0">
<div class="space-y-3">
{#each category.questions as question (question.id)}
<div class="border border-border/50 rounded-lg overflow-hidden">
<button
onclick={() => toggleExpanded(question.id)}
class="w-full p-4 text-left hover:bg-primary/5 transition-colors flex items-center justify-between"
>
<h4 class="font-medium text-sm pr-4">
{question.question}
</h4>
{#if expandedItems.has(question.id)}
<span
class="icon-[ri--arrow-drop-up-line] w-6 h-6 text-muted-foreground flex-shrink-0"
></span>
{:else}
<span
class="icon-[ri--arrow-drop-down-line] w-6 h-6 text-muted-foreground flex-shrink-0"
></span>
{/if}
</button>
{#if expandedItems.has(question.id)}
<div class="p-4 border-t border-border/50">
<p class="text-muted-foreground text-sm leading-relaxed">
{question.answer}
</p>
</div>
{/if}
</div>
{/each}
</div>
</CardContent>
</Card>
{/each}
</div>
</div>
{/if}
<!-- Contact Support -->
<div class="max-w-2xl mx-auto mt-16">
<Card class="bg-gradient-to-br from-primary/10 to-accent/10 border-primary/20">
<CardContent class="p-8 text-center">
<h3 class="text-2xl font-bold mb-4">{$_("faq.support.title")}</h3>
<p class="text-muted-foreground mb-6 leading-relaxed">
{$_("faq.support.description")}
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<Button
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
href="mailto:{$_('faq.support.contact_email')}">{$_("faq.support.contact")}</Button
>
<!-- <Button
variant="outline"
class="border-primary/50 hover:bg-primary/10"
>{$_("faq.support.live_chat")}</Button
> -->
</div>
</CardContent>
</Card>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>

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