A new start
This commit is contained in:
17
packages/frontend/.dockerignore
Normal file
17
packages/frontend/.dockerignore
Normal file
@@ -0,0 +1,17 @@
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
README.md
|
||||
.npmrc
|
||||
.prettierrc
|
||||
.eslintrc.cjs
|
||||
.graphqlrc
|
||||
.editorconfig
|
||||
.svelte-kit
|
||||
.vscode
|
||||
node_modules
|
||||
build
|
||||
package
|
||||
**/.env
|
||||
6
packages/frontend/.env
Normal file
6
packages/frontend/.env
Normal file
@@ -0,0 +1,6 @@
|
||||
PUBLIC_API_URL=
|
||||
PUBLIC_URL=
|
||||
PUBLIC_UMAMI_ID=
|
||||
LETTERSPACE_API_URL=
|
||||
LETTERSPACE_API_KEY=
|
||||
LETTERSPACE_LIST_ID=
|
||||
3
packages/frontend/.gitignore
vendored
Normal file
3
packages/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.svelte-kit/
|
||||
build/
|
||||
16
packages/frontend/components.json
Normal file
16
packages/frontend/components.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"tailwind": {
|
||||
"css": "src/app.css",
|
||||
"baseColor": "slate"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://shadcn-svelte.com/registry"
|
||||
}
|
||||
16
packages/frontend/jsrepo.json
Normal file
16
packages/frontend/jsrepo.json
Normal file
@@ -0,0 +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"
|
||||
}
|
||||
}
|
||||
50
packages/frontend/package.json
Normal file
50
packages/frontend/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@sexy.pivoine.art/frontend",
|
||||
"version": "1.0.0",
|
||||
"author": "valknarogg",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "node ./build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/ri": "^1.2.5",
|
||||
"@iconify/tailwind4": "^1.0.6",
|
||||
"@internationalized/date": "^3.8.2",
|
||||
"@lucide/svelte": "^0.544.0",
|
||||
"@sveltejs/adapter-node": "^5.3.1",
|
||||
"@sveltejs/adapter-static": "^3.0.9",
|
||||
"@sveltejs/kit": "^2.37.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.1.4",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@tsconfig/svelte": "^5.0.5",
|
||||
"bits-ui": "2.11.0",
|
||||
"clsx": "^2.1.1",
|
||||
"glob": "^11.0.3",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"super-sitemap": "^1.0.5",
|
||||
"svelte": "^5.38.6",
|
||||
"svelte-sonner": "^1.0.5",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwind-variants": "^1.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tw-animate-css": "^1.3.8",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@directus/sdk": "^20.0.3",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.64.6",
|
||||
"@sexy.pivoine.art/buttplug": "workspace:*",
|
||||
"javascript-time-ago": "^2.5.11",
|
||||
"media-chrome": "^4.13.1",
|
||||
"svelte-i18n": "^4.0.1"
|
||||
}
|
||||
}
|
||||
226
packages/frontend/src/app.css
Normal file
226
packages/frontend/src/app.css
Normal file
@@ -0,0 +1,226 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@plugin "@iconify/tailwind4";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@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;
|
||||
|
||||
@keyframes vibrate {
|
||||
0% {
|
||||
transform: translate(0);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: translate(-2px, 2px);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: translate(-2px, -2px);
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: translate(2px, 2px);
|
||||
}
|
||||
|
||||
80% {
|
||||
transform: translate(2px, -2px);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(30px) scale(0.95);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes zoomIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentColor` in Tailwind CSS v4,
|
||||
so weve added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--border);
|
||||
outline-color: var(--ring);
|
||||
}
|
||||
|
||||
.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 p {
|
||||
@apply mb-4 leading-relaxed;
|
||||
}
|
||||
|
||||
.prose ul {
|
||||
@apply mb-4 pl-6;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@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);
|
||||
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
--font-serif: var(--font-serif);
|
||||
}
|
||||
24
packages/frontend/src/app.d.ts
vendored
Normal file
24
packages/frontend/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
|
||||
import type { AuthStatus } from "$lib/types";
|
||||
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
interface Locals {
|
||||
authStatus: AuthStatus;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
interface Window {
|
||||
sidebar: {
|
||||
addPanel: () => void;
|
||||
};
|
||||
opera: object;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
24
packages/frontend/src/app.html
Normal file
24
packages/frontend/src/app.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<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="manifest" href="/site.webmanifest" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
<body data-sveltekit-preload-data="hover" class="dark">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
27
packages/frontend/src/hooks.server.ts
Normal file
27
packages/frontend/src/hooks.server.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { isAuthenticated } from "$lib/services";
|
||||
|
||||
export async function handle({ event, resolve }) {
|
||||
const { cookies, locals } = event;
|
||||
|
||||
const token = cookies.get("directus_session_token");
|
||||
|
||||
if (token) {
|
||||
locals.authStatus = await isAuthenticated(token);
|
||||
// if (locals.authStatus.authenticated) {
|
||||
// cookies.set('directus_refresh_token', locals.authStatus.data!.refresh_token!, {
|
||||
// httpOnly: true,
|
||||
// secure: true,
|
||||
// domain: '.pivoine.art',
|
||||
// path: '/'
|
||||
// })
|
||||
// }
|
||||
} else {
|
||||
locals.authStatus = { authenticated: false };
|
||||
}
|
||||
|
||||
return await resolve(event, {
|
||||
filterSerializedResponseHeaders: (key) => {
|
||||
return key.toLowerCase() === "content-type";
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<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";
|
||||
|
||||
const AGE_VERIFICATION_KEY = "age-verified";
|
||||
|
||||
let isOpen = true;
|
||||
|
||||
function handleAgeConfirmation() {
|
||||
localStorage.setItem(AGE_VERIFICATION_KEY, "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"
|
||||
>
|
||||
<span class="icon-[ri--check-line]"></span>
|
||||
{$_("age_verification_dialog.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -0,0 +1,61 @@
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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
|
||||
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
|
||||
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
|
||||
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>
|
||||
|
||||
<!-- 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
|
||||
class="absolute bottom-32 left-32 w-88 h-88 bg-gradient-to-r from-primary/18 via-accent/22 to-primary/12 rounded-full blur-3xl animate-blob-reverse animation-delay-3000"
|
||||
></div>
|
||||
<div
|
||||
class="absolute top-2/3 right-1/4 w-64 h-64 bg-gradient-to-r from-accent/15 via-primary/18 to-accent/10 rounded-full blur-2xl animate-float animation-delay-1500"
|
||||
></div> -->
|
||||
</div>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
const { isMobileMenuOpen = $bindable(), label, onclick } = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="block rounded-full cursor-pointer"
|
||||
onclick={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"
|
||||
>
|
||||
<div
|
||||
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' : ''}`}
|
||||
></div>
|
||||
<div
|
||||
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' : ''}`}
|
||||
></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' : ''}`}
|
||||
>
|
||||
<div
|
||||
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' : ''}`}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -0,0 +1,165 @@
|
||||
<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 { ActuatorType } from "@sexy.pivoine.art/buttplug";
|
||||
|
||||
interface Props {
|
||||
device: BluetoothDevice;
|
||||
onChange: (scalarIndex: number, val: number) => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
let { device, onChange, onStop }: Props = $props();
|
||||
|
||||
function getBatteryColor(level: number) {
|
||||
if (!device.info.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.info.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() {
|
||||
const cmds: [{ ActuatorType: typeof ActuatorType }] =
|
||||
device.info.messageAttributes.ScalarCmd;
|
||||
return cmds
|
||||
.filter((_, i: number) => !!device.actuatorValues[i])
|
||||
.map(({ ActuatorType }) => `animate-${ActuatorType.toLowerCase()}`);
|
||||
}
|
||||
|
||||
function isActive() {
|
||||
const cmds: [{ ActuatorType: typeof ActuatorType }] =
|
||||
device.info.messageAttributes.ScalarCmd;
|
||||
return cmds.some((_, i: number) => !!device.actuatorValues[i]);
|
||||
}
|
||||
</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"
|
||||
>
|
||||
<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>
|
||||
|
||||
<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"
|
||||
>{$_("device_card.current_value")}</span
|
||||
>
|
||||
<span class="font-medium text-card-foreground">{device.currentValue}</span
|
||||
>
|
||||
</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.info.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
|
||||
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.info.messageAttributes.ScalarCmd as scalarCmd}
|
||||
<div class="space-y-2">
|
||||
<Label for={`device-${device.info.index}-${scalarCmd.Index}`}
|
||||
>{$_(
|
||||
`device_card.actuator_types.${scalarCmd.ActuatorType.toLowerCase()}`,
|
||||
)}</Label
|
||||
>
|
||||
<Slider
|
||||
id={`device-${device.info.index}-${scalarCmd.Index}`}
|
||||
type="single"
|
||||
value={device.actuatorValues[scalarCmd.Index]}
|
||||
onValueChange={(val) => onChange(scalarCmd.Index, val)}
|
||||
max={scalarCmd.StepCount}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</CardContent>
|
||||
</Card>
|
||||
121
packages/frontend/src/lib/components/footer/footer.svelte
Normal file
121
packages/frontend/src/lib/components/footer/footer.svelte
Normal file
@@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import PeonyIcon from "$lib/components/icon/peony-icon.svelte";
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
120
packages/frontend/src/lib/components/girls/girls.svelte
Normal file
120
packages/frontend/src/lib/components/girls/girls.svelte
Normal file
@@ -0,0 +1,120 @@
|
||||
<div class="w-full h-auto">
|
||||
<svg
|
||||
version="1.0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1280.000000 904.000000"
|
||||
stroke-width="5"
|
||||
stroke="#ce47eb"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<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
|
||||
-19 -69 -66 -96 -104 -116 -164 -130 -314 -59 -664 32 -164 36 -217 18 -256
|
||||
-13 -30 -14 -30 -140 -52 -75 -12 -105 -13 -129 -5 -18 6 -59 11 -93 11 -123
|
||||
-1 -213 -66 -379 -275 -245 -308 -501 -567 -686 -693 l-92 -64 -82 7 c-53 5
|
||||
-88 13 -100 23 -21 18 -66 20 -167 7 -73 -9 -124 -31 -159 -69 -22 -23 -23
|
||||
-31 -18 -94 6 -58 4 -71 -11 -84 -44 -40 -203 -119 -295 -149 -56 -18 -144
|
||||
-50 -195 -71 -50 -21 -138 -51 -195 -67 -232 -65 -369 -131 -595 -284 -182
|
||||
-124 -172 -123 -208 -27 -23 60 -39 81 -189 245 -279 305 -319 354 -368 458
|
||||
-46 94 -47 98 -32 127 8 16 15 36 15 43 0 8 14 41 30 72 17 31 30 63 30 70 0
|
||||
7 7 18 15 25 8 7 15 26 15 42 0 42 15 65 49 71 17 4 37 17 46 30 14 23 14 30
|
||||
-9 101 -28 88 -21 130 22 141 20 5 23 10 18 31 -4 13 -1 34 5 46 13 25 33 239
|
||||
31 336 0 42 -8 78 -23 108 -31 65 -121 158 -209 217 -41 28 -77 55 -79 60 -2
|
||||
5 -17 24 -33 43 -23 26 -48 39 -111 58 -183 55 -239 61 -361 36 -156 -33 -333
|
||||
-185 -425 -368 -72 -143 -93 -280 -96 -622 -2 -240 -5 -288 -24 -379 -12 -57
|
||||
-30 -120 -40 -140 -11 -20 -61 -84 -113 -142 -52 -58 -105 -121 -118 -140 -13
|
||||
-19 -45 -58 -72 -88 -93 -106 -127 -193 -237 -616 -33 -127 -67 -251 -76 -275
|
||||
-9 -25 -48 -153 -86 -285 -78 -264 -163 -502 -334 -935 -135 -340 -194 -526
|
||||
-290 -910 -20 -80 -47 -180 -61 -223 -13 -43 -24 -92 -24 -109 0 -42 -43 -79
|
||||
-132 -112 -56 -20 -108 -52 -213 -132 -77 -58 -162 -117 -190 -131 -85 -43
|
||||
-107 -75 -62 -89 12 -3 30 -15 40 -25 10 -11 30 -19 45 -19 29 0 146 52 175
|
||||
77 9 9 19 14 22 12 2 -3 -21 -24 -51 -47 -55 -43 -63 -59 -42 -80 30 -30 130
|
||||
5 198 69 54 52 127 109 139 109 20 0 11 -27 -25 -80 -38 -56 -38 -74 0 -91 33
|
||||
-16 67 7 135 89 31 37 70 71 95 84 l42 20 82 -21 c45 -11 95 -21 111 -21 17 0
|
||||
50 -11 75 -25 58 -32 136 -35 166 -5 35 35 26 57 -40 90 -59 30 -156 132 -186
|
||||
195 -30 63 -31 124 -3 258 43 213 95 336 279 657 126 219 231 423 267 520 14
|
||||
36 40 128 58 205 19 77 50 185 69 240 55 159 182 450 195 447 7 -1 9 7 5 23
|
||||
-10 38 0 30 37 -30 42 -69 60 -53 28 27 -36 92 -39 98 -34 98 3 0 14 -18 25
|
||||
-41 14 -26 26 -39 35 -35 9 3 28 -22 59 -81 65 -121 162 -266 237 -353 35 -41
|
||||
174 -196 309 -345 359 -394 379 -421 409 -549 25 -103 90 -214 169 -287 74
|
||||
-67 203 -135 332 -173 110 -33 472 -112 575 -125 325 -44 688 -30 1453 54 172
|
||||
19 352 35 400 35 112 1 156 11 272 66 139 66 171 103 171 197 0 64 -11 95 -52
|
||||
141 -17 20 -30 38 -28 39 2 1 13 7 24 13 11 6 21 23 23 38 2 14 12 31 23 36
|
||||
12 7 19 21 19 38 0 19 7 30 23 37 14 6 23 21 25 39 2 16 10 36 18 44 10 9 13
|
||||
24 9 41 -4 20 -1 28 16 36 58 26 47 86 -21 106 -38 12 -40 14 -40 51 0 51 -18
|
||||
82 -82 145 -73 70 -132 105 -358 213 -547 260 -919 419 -1210 517 -13 5 -13 6
|
||||
0 10 8 3 22 13 30 22 23 26 363 124 434 125 l60 1 21 -85 c29 -118 59 -175
|
||||
129 -245 118 -117 234 -156 461 -158 171 -1 271 17 445 80 268 96 361 157 602
|
||||
396 93 92 171 159 246 209 155 105 513 381 595 458 131 122 189 224 277 485
|
||||
109 325 149 342 163 70 9 -163 30 -242 143 -531 53 -137 98 -258 101 -270 3
|
||||
-14 -5 -28 -29 -46 -18 -14 -94 -80 -168 -147 -137 -123 -261 -216 -306 -227
|
||||
-17 -4 -46 4 -92 27 -60 29 -80 34 -192 41 -69 4 -144 11 -166 14 -103 15
|
||||
-115 -61 -15 -95 19 -6 46 -11 61 -11 44 0 91 -20 88 -38 -2 -8 -15 -24 -30
|
||||
-35 -22 -17 -30 -18 -42 -7 -21 16 -46 6 -46 -19 0 -25 -29 -35 -110 -35 -57
|
||||
-1 -65 -3 -68 -21 -4 -29 44 -54 120 -62 35 -3 66 -12 71 -19 4 -7 31 -25 59
|
||||
-39 41 -21 60 -24 93 -19 25 3 45 2 49 -4 3 -5 34 -9 69 -7 52 1 72 7 108 32
|
||||
58 40 97 59 135 66 32 6 462 230 516 269 18 12 33 17 35 12 2 -6 30 -62 62
|
||||
-126 l58 -116 -3 -112 c-2 -61 -6 -115 -9 -119 -2 -5 -100 -8 -217 -8 -221 0
|
||||
-452 -23 -868 -88 -85 -13 -225 -33 -310 -45 -189 -26 -314 -52 -440 -92 -203
|
||||
-65 -284 -132 -304 -254 -15 -90 30 -173 137 -251 28 -20 113 -85 187 -142 74
|
||||
-58 171 -129 215 -158 105 -71 324 -181 563 -283 106 -45 194 -86 197 -90 9
|
||||
-14 -260 -265 -361 -337 -100 -71 -130 -102 -188 -193 -16 -24 -53 -73 -82
|
||||
-107 -30 -35 -67 -89 -83 -121 -20 -41 -63 -92 -135 -163 -86 -87 -106 -112
|
||||
-112 -144 -4 -22 -15 -53 -26 -70 -23 -38 -23 -73 -1 -105 39 -56 94 -81 132
|
||||
-60 18 9 21 8 21 -9 0 -33 11 -51 41 -67 20 -10 35 -12 46 -5 13 7 21 3 36
|
||||
-15 11 -14 29 -24 44 -24 15 0 34 -7 44 -16 9 -8 27 -16 40 -16 13 -1 33 -8
|
||||
44 -15 11 -7 29 -13 40 -13 50 0 129 132 140 232 21 203 78 389 136 444 17 16
|
||||
51 56 74 89 89 124 200 212 433 343 l142 81 14 -27 c16 -32 36 -151 36 -220 0
|
||||
-35 6 -54 21 -71 43 -46 143 -68 168 -37 6 8 14 37 18 65 5 46 11 56 47 85 23
|
||||
18 61 44 86 58 91 53 151 145 153 234 0 38 -5 50 -33 79 -19 19 -53 42 -77 51
|
||||
-24 9 -43 19 -43 23 0 3 28 24 62 46 81 52 213 178 298 284 63 79 75 89 148
|
||||
122 l80 37 32 -49 c79 -122 233 -192 370 -170 222 37 395 196 428 396 18 107
|
||||
35 427 30 560 -9 217 -63 344 -223 514 -52 56 -95 106 -95 111 0 5 4 12 10 15
|
||||
55 34 235 523 290 785 10 52 28 118 39 145 10 28 29 103 41 169 27 142 24 271
|
||||
-7 352 -28 72 -115 215 -185 303 -65 82 -118 184 -125 241 -11 82 59 182 93
|
||||
135 9 -12 17 -14 31 -7 10 6 25 7 33 2 8 -4 27 -6 41 -3 28 5 44 45 33 80 -5
|
||||
15 -4 15 4 4 12 -17 17 -6 76 144 39 99 43 100 22 10 -8 -33 -13 -62 -10 -64
|
||||
10 -10 65 154 83 249 6 30 16 80 22 110 19 85 16 216 -5 278 -11 32 -22 50
|
||||
-29 45 -7 -4 -8 0 -3 13 4 10 4 15 0 12 -6 -7 -89 109 -89 124 0 4 -6 13 -14
|
||||
20 -10 10 -12 10 -7 1 14 -24 -10 -13 -40 19 -16 17 -23 27 -15 23 9 -5 12 -4
|
||||
8 2 -11 18 -131 71 -188 82 -50 11 -127 14 -259 12 -25 -1 -57 -7 -72 -15 -17
|
||||
-9 -28 -11 -28 -4 0 6 -9 8 -22 3 -13 -4 -31 -7 -41 -6 -9 0 -15 -4 -12 -9 3
|
||||
-6 0 -7 -8 -4 -20 7 -127 -84 -176 -149 -43 -57 -111 -185 -111 -208 0 -19
|
||||
-55 -135 -69 -143 -6 -4 -11 -12 -11 -18 0 -19 29 13 66 73 19 33 37 59 40 59
|
||||
10 0 -65 -126 -103 -173 -30 -36 -39 -53 -30 -59 9 -6 9 -8 0 -8 -9 0 -10 -7
|
||||
-2 -27 6 -16 10 -29 10 -30 -1 -11 23 -63 29 -63 4 0 20 10 36 22 30 24 26 14
|
||||
-13 -39 -13 -18 -20 -33 -14 -33 19 0 74 65 97 115 13 27 24 43 24 34 0 -25
|
||||
-21 -81 -42 -111 -23 -34 -23 -46 0 -25 18 16 19 14 21 -70 3 -183 25 -289 76
|
||||
-381 26 -46 33 -96 15 -107 -6 -3 -86 -17 -178 -30 -240 -35 -301 -61 -360
|
||||
-152 -62 -96 -73 -147 -83 -378 -9 -214 -20 -312 -32 -285 -20 45 -77 356 -91
|
||||
492 -18 174 -34 243 -72 325 -58 121 -120 163 -243 163 -63 0 -80 3 -85 16
|
||||
-11 29 -6 103 13 196 43 209 51 282 51 479 -1 301 -22 464 -76 571 -32 64
|
||||
-132 168 -191 200 -79 43 -224 72 -303 61z m2438 -421 c18 -14 38 -35 44 -46
|
||||
9 -16 -39 22 -102 82 -11 11 27 -13 58 -36z m142 -188 c17 -52 7 -51 -11 1 -9
|
||||
25 -13 42 -8 40 4 -3 13 -21 19 -41z m-1000 -42 c0 -5 -7 -17 -15 -28 -14 -18
|
||||
-14 -17 -4 9 12 27 19 34 19 19z m1037 -14 c-3 -8 -6 -5 -6 6 -1 11 2 17 5 13
|
||||
3 -3 4 -12 1 -19z m10 -40 c-3 -8 -6 -5 -6 6 -1 11 2 17 5 13 3 -3 4 -12 1
|
||||
-19z m-53 -327 c-4 -23 -9 -40 -11 -37 -3 3 -2 23 2 46 4 23 9 39 11 37 3 -2
|
||||
2 -23 -2 -46z m-17 -73 c-3 -8 -6 -5 -6 6 -1 11 2 17 5 13 3 -3 4 -12 1 -19z
|
||||
m-3487 -790 c-17 -35 -55 -110 -84 -168 -29 -58 -72 -163 -96 -235 -45 -134
|
||||
-64 -175 -84 -175 -6 1 -23 18 -38 40 -31 44 -71 60 -155 60 -29 0 -53 3 -52
|
||||
8 0 4 63 59 141 122 182 149 293 258 347 343 24 37 45 67 47 67 3 0 -10 -28
|
||||
-26 -62z m-4768 -415 c-37 -46 -160 -176 -140 -148 21 29 160 185 165 185 3 0
|
||||
-9 -17 -25 -37z m38 -52 c-11 -21 -30 -37 -30 -25 0 8 30 44 37 44 2 0 -1 -9
|
||||
-7 -19z m1692 -588 c22 -30 39 -56 36 -58 -5 -5 -107 115 -122 143 -15 28 42
|
||||
-29 86 -85z m-100 -108 c6 -11 -13 3 -42 30 -28 28 -56 59 -62 70 -6 11 13 -2
|
||||
42 -30 28 -27 56 -59 62 -70z m1587 -1 c29 -6 22 -10 -71 -40 -57 -19 -128
|
||||
-41 -158 -49 -58 -15 -288 -41 -296 -33 -2 3 23 19 56 37 45 24 98 40 208 61
|
||||
153 29 208 34 261 24z m-860 -1488 c150 -59 299 -94 495 -114 l68 -7 -42 -27
|
||||
-42 -28 -111 20 c-62 11 -196 28 -300 38 -103 10 -189 21 -192 23 -2 3 -1 21
|
||||
4 40 5 19 12 46 15 62 4 15 9 27 13 27 3 0 45 -15 92 -34z m3893 -371 l37 -6
|
||||
-55 -72 c-31 -40 -59 -72 -62 -73 -4 -1 -51 44 -104 100 l-97 101 122 -22 c67
|
||||
-13 139 -25 159 -28z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
394
packages/frontend/src/lib/components/header/header.svelte
Normal file
394
packages/frontend/src/lib/components/header/header.svelte
Normal file
@@ -0,0 +1,394 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import { page } from "$app/state";
|
||||
import PeonyIcon from "$lib/components/icon/peony-icon.svelte";
|
||||
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, isModel } 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;
|
||||
}
|
||||
|
||||
let { authStatus }: Props = $props();
|
||||
|
||||
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" },
|
||||
];
|
||||
|
||||
async function handleLogout() {
|
||||
closeMenu();
|
||||
await logout();
|
||||
goto("/login", { invalidateAll: true });
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
isMobileMenuOpen = false;
|
||||
}
|
||||
|
||||
function isActiveLink(link: any) {
|
||||
return (
|
||||
(page.url.pathname === "/" && link === navLinks[0]) ||
|
||||
(page.url.pathname.startsWith(link.href) && link !== navLinks[0])
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="sticky top-0 z-50 w-full bg-gradient-to-br from-card/85 via-card/90 to-card/80 backdrop-blur-xl shadow-2xl shadow-primary/20"
|
||||
>
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex items-center justify-evenly h-16">
|
||||
<!-- Logo -->
|
||||
<a
|
||||
href="/"
|
||||
class="flex w-full items-center gap-3 hover:scale-105 transition-all duration-300"
|
||||
>
|
||||
<Logo hideName={true} />
|
||||
</a>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<nav class="hidden w-full lg:flex items-center justify-center gap-8">
|
||||
{#each navLinks as link}
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
{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'}`}
|
||||
></span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- Desktop Login Button -->
|
||||
{#if authStatus.authenticated}
|
||||
<div class="w-full flex items-center justify-end">
|
||||
<div class="flex items-center gap-2 rounded-full bg-muted/30 p-1">
|
||||
<!-- Notifications -->
|
||||
<!-- <Button variant="ghost" size="sm" class="relative h-9 w-9 rounded-full p-0 hover:bg-background/80">
|
||||
<BellIcon class="h-4 w-4" />
|
||||
<Badge class="absolute -right-1 -top-1 h-5 w-5 rounded-full bg-gradient-to-r from-primary to-accent p-0 text-xs text-primary-foreground">3</Badge>
|
||||
<span class="sr-only">Notifications</span>
|
||||
</Button> -->
|
||||
|
||||
<!-- <Separator orientation="vertical" class="mx-1 h-6 bg-border/50" /> -->
|
||||
|
||||
<!-- User Actions -->
|
||||
<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'}`}
|
||||
href="/me"
|
||||
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'}`}
|
||||
></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'}`}
|
||||
href="/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'}`}
|
||||
></span>
|
||||
<span class="sr-only">{$_('header.play')}</span>
|
||||
</Button>
|
||||
|
||||
<Separator orientation="vertical" class="hidden md:flex mx-1 h-6 bg-border/50" />
|
||||
|
||||
<!-- Slide Logout Button -->
|
||||
|
||||
<LogoutButton
|
||||
user={{
|
||||
name: authStatus.user!.artist_name,
|
||||
avatar: getAssetUrl(authStatus.user!.avatar?.id, 'mini')!,
|
||||
email: authStatus.user!.email
|
||||
}}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
</div>
|
||||
</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
|
||||
href="/signup"
|
||||
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 font-medium"
|
||||
>{$_('header.signup')}</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
<BurgerMenuButton
|
||||
label={$_('header.navigation')}
|
||||
bind:isMobileMenuOpen
|
||||
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
|
||||
/>
|
||||
</div>
|
||||
</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'}`}
|
||||
>
|
||||
{#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 ">
|
||||
<!-- 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="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')}
|
||||
alt={authStatus.user!.artist_name}
|
||||
/>
|
||||
<AvatarFallback
|
||||
class="bg-gradient-to-br from-primary to-accent text-primary-foreground font-semibold"
|
||||
>
|
||||
{getUserInitials(authStatus.user!.artist_name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<p class="text-base font-semibold text-foreground">
|
||||
{authStatus.user!.artist_name}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{authStatus.user!.email}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<div class="h-2 w-2 rounded-full bg-green-500"></div>
|
||||
<span class="text-xs text-muted-foreground">Online</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Notifications Badge -->
|
||||
<!-- <Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="relative h-10 w-10 rounded-full p-0"
|
||||
>
|
||||
<BellIcon class="h-4 w-4" />
|
||||
<Badge
|
||||
class="absolute -right-1 -top-1 h-5 w-5 rounded-full bg-gradient-to-r from-primary to-accent p-0 text-xs text-primary-foreground"
|
||||
>3</Badge
|
||||
>
|
||||
</Button> -->
|
||||
</div>
|
||||
</div>
|
||||
{/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>
|
||||
<div class="grid gap-2">
|
||||
{#each navLinks as link}
|
||||
<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
|
||||
)
|
||||
? 'border-primary/30 bg-primary/5'
|
||||
: ''}"
|
||||
onclick={() => (isMobileMenuOpen = false)}
|
||||
>
|
||||
<span class="font-medium text-foreground">{link.name}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- {#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>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Actions -->
|
||||
<div class="space-y-3">
|
||||
<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' : ''}`}
|
||||
href="/me"
|
||||
onclick={closeMenu}
|
||||
>
|
||||
<div
|
||||
class={`flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-muted to-muted/50 transition-all group-hover:bg-card group-hover:from-primary/10 group-hover:to-accent/10`}
|
||||
>
|
||||
<span
|
||||
class="icon-[ri--dashboard-2-line] h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors"
|
||||
></span>
|
||||
</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
|
||||
>
|
||||
</div>
|
||||
<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"
|
||||
></span>
|
||||
</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' : ''}`}
|
||||
href="/play"
|
||||
onclick={closeMenu}
|
||||
>
|
||||
<div
|
||||
class={`flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-muted to-muted/50 transition-all group-hover:bg-card group-hover:from-primary/10 group-hover:to-accent/10`}
|
||||
>
|
||||
<span
|
||||
class="icon-[ri--rocket-line] h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors"
|
||||
></span>
|
||||
</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
|
||||
>
|
||||
</div>
|
||||
<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"
|
||||
></span>
|
||||
</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' : ''}`}
|
||||
href="/login"
|
||||
onclick={closeMenu}
|
||||
>
|
||||
<div
|
||||
class={`flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-muted to-muted/50 transition-all group-hover:bg-card group-hover:from-primary/10 group-hover:to-accent/10`}
|
||||
>
|
||||
<span
|
||||
class="icon-[ri--login-circle-line] h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors"
|
||||
></span>
|
||||
</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
|
||||
>
|
||||
</div>
|
||||
<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"
|
||||
></span>
|
||||
</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' : ''}`}
|
||||
href="/signup"
|
||||
onclick={closeMenu}
|
||||
>
|
||||
<div
|
||||
class={`flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-muted to-muted/50 transition-all group-hover:bg-card group-hover:from-primary/10 group-hover:to-accent/10`}
|
||||
>
|
||||
<span
|
||||
class="icon-[ri--heart-add-2-line] h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors"
|
||||
></span>
|
||||
</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
|
||||
>
|
||||
</div>
|
||||
<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"
|
||||
></span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if authStatus.authenticated}
|
||||
<!-- Logout Button -->
|
||||
<button
|
||||
class="cursor-pointer flex w-full items-center gap-4 rounded-xl border border-destructive/20 bg-destructive/5 p-4 text-left backdrop-blur-sm transition-all hover:bg-destructive/10 hover:border-destructive/30 group"
|
||||
onclick={handleLogout}
|
||||
>
|
||||
<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>
|
||||
</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
|
||||
>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
25
packages/frontend/src/lib/components/icon/peony-icon.svelte
Normal file
25
packages/frontend/src/lib/components/icon/peony-icon.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
class?: string;
|
||||
size?: string | number;
|
||||
}
|
||||
|
||||
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"
|
||||
>
|
||||
<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
|
||||
>
|
||||
@@ -0,0 +1,280 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import { browser } from "$app/environment";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import Button from "../ui/button/button.svelte";
|
||||
|
||||
const { images = [] } = $props();
|
||||
|
||||
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);
|
||||
|
||||
function openViewer(index) {
|
||||
currentImageIndex = index;
|
||||
isViewerOpen = true;
|
||||
imageLoading = true;
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
function closeViewer() {
|
||||
isViewerOpen = false;
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
|
||||
function navigatePrev() {
|
||||
if (canGoPrev) {
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
});
|
||||
|
||||
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"
|
||||
>
|
||||
{#each images as image, index}
|
||||
<button
|
||||
onclick={() => openViewer(index)}
|
||||
class="group relative aspect-square overflow-hidden rounded-xl bg-zinc-900 border border-zinc-800 transition-all duration-300 hover:scale-[1.03] hover:border-primary/50 hover:shadow-2xl hover:shadow-primary/20 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-zinc-950"
|
||||
>
|
||||
<!-- Thumbnail Image -->
|
||||
<img
|
||||
src={image.thumbnail}
|
||||
alt={image.title}
|
||||
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
<!-- Gradient Overlay -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
||||
></div>
|
||||
|
||||
<!-- Hover Glow Effect -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-primary/20 to-accent/20 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
|
||||
></div>
|
||||
|
||||
<!-- Image Info Overlay -->
|
||||
<div
|
||||
class="absolute bottom-0 left-0 right-0 p-4 transform translate-y-full group-hover:translate-y-0 transition-transform duration-300"
|
||||
>
|
||||
<h3 class="text-foreground font-semibold text-sm mb-1">
|
||||
{image.title}
|
||||
</h3>
|
||||
<p class="text-zinc-400 text-xs">
|
||||
{index + 1} / {images.length}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Viewer Modal -->
|
||||
{#if isViewerOpen}
|
||||
<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>
|
||||
|
||||
<!-- Viewer Content -->
|
||||
<div class="relative w-[90vw] h-[90vh] flex flex-col animate-slide-up">
|
||||
<!-- Header -->
|
||||
<div class="absolute top-0 left-0 right-0 z-20 p-6 rounded-2xl">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h2 class="text-3xl font-bold text-foreground mb-2 drop-shadow-lg">
|
||||
{currentImage.title}
|
||||
</h2>
|
||||
<div class="text-primary font-medium mb-3">
|
||||
{$_("image_viewer.index", {
|
||||
values: {
|
||||
index: currentImageIndex + 1,
|
||||
size: images.length
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
<p class="text-zinc-400 max-w-2xl">
|
||||
{currentImage.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Control Buttons -->
|
||||
<div class="flex gap-3 ml-8">
|
||||
<Button
|
||||
onclick={downloadImage}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="w-11 h-11 rounded-lg bg-foreground/10 backdrop-blur border border-foreground/10 text-foreground flex items-center justify-center transition-all hover:bg-primary hover:border-primary hover:scale-105 hover:shadow-lg active:scale-95"
|
||||
>
|
||||
<span class="icon-[ri--download-fill] w-4 h-4"></span>
|
||||
</Button>
|
||||
<Button
|
||||
onclick={closeViewer}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="w-11 h-11 rounded-lg bg-foreground/10 backdrop-blur border border-foreground/10 text-foreground flex items-center justify-center transition-all hover:bg-destructive hover:border-destructive hover:scale-105 hover:shadow-lg active:scale-95"
|
||||
>
|
||||
<span class="icon-[ri--close-fill] w-4 h-4"></span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Container -->
|
||||
<div class="flex-1 flex items-center justify-center relative px-20">
|
||||
<!-- Previous Button -->
|
||||
<Button
|
||||
onclick={navigatePrev}
|
||||
disabled={!canGoPrev}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="absolute left-8 top-1/2 -translate-y-1/2 w-14 h-14 rounded-full bg-foreground/10 backdrop-blur border border-foreground/10 text-foreground flex items-center justify-center transition-all hover:bg-accent hover:border-accent hover:scale-110 hover:shadow-xl active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-foreground/10 disabled:hover:border-foreground/10 disabled:hover:scale-100 disabled:hover:shadow-none z-10"
|
||||
>
|
||||
<span class="icon-[ri--arrow-left-s-line] w-5 h-5"></span>
|
||||
</Button>
|
||||
|
||||
<!-- Main Image -->
|
||||
<div class="relative max-w-full max-h-full">
|
||||
{#if imageLoading}
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
class="w-12 h-12 border-4 border-primary/30 border-t-primary rounded-full animate-spin"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
<img
|
||||
src={currentImage.url}
|
||||
alt={currentImage.title}
|
||||
onload={handleImageLoad}
|
||||
class="max-w-full max-h-[calc(90vh-8rem)] object-contain rounded-lg shadow-2xl {imageLoading
|
||||
? 'opacity-0'
|
||||
: 'opacity-100 animate-zoom-in'} transition-opacity duration-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Next Button -->
|
||||
<Button
|
||||
onclick={navigateNext}
|
||||
disabled={!canGoNext}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="absolute right-8 top-1/2 -translate-y-1/2 w-14 h-14 rounded-full bg-foreground/10 backdrop-blur border border-foreground/10 text-foreground flex items-center justify-center transition-all hover:bg-accent hover:border-accent hover:scale-110 hover:shadow-xl active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-foreground/10 disabled:hover:border-foreground/10 disabled:hover:scale-100 disabled:hover:shadow-none z-10"
|
||||
>
|
||||
<span class="icon-[ri--arrow-right-s-line] w-5 h-5"></span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Keyboard Hints -->
|
||||
<div
|
||||
class="hidden md:flex absolute bottom-6 left-1/2 -translate-x-1/2 gap-4 px-6 py-3 bg-zinc-900/95 backdrop-blur-sm rounded-lg border border-zinc-800 text-zinc-400 text-sm"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<kbd
|
||||
class="px-2 py-1 bg-zinc-800 border border-zinc-700 rounded text-foreground font-mono text-xs"
|
||||
>←</kbd
|
||||
>
|
||||
{$_("image_viewer.previous")}
|
||||
</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<kbd
|
||||
class="px-2 py-1 bg-zinc-800 border border-zinc-700 rounded text-foreground font-mono text-xs"
|
||||
>→</kbd
|
||||
>
|
||||
{$_("image_viewer.next")}
|
||||
</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<kbd
|
||||
class="px-2 py-1 bg-zinc-800 border border-zinc-700 rounded text-foreground font-mono text-xs"
|
||||
>Esc</kbd
|
||||
>
|
||||
{$_("image_viewer.close")}
|
||||
</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<kbd
|
||||
class="px-2 py-1 bg-zinc-800 border border-zinc-700 rounded text-foreground font-mono text-xs"
|
||||
>D</kbd
|
||||
>
|
||||
{$_("image_viewer.download")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
21
packages/frontend/src/lib/components/logo/logo.svelte
Normal file
21
packages/frontend/src/lib/components/logo/logo.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import PeonyIcon from "../icon/peony-icon.svelte";
|
||||
|
||||
const { hideName = false } = $props();
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<PeonyIcon class="w-13 h-13 text-black" />
|
||||
</div>
|
||||
<span
|
||||
class={`logo text-3xl text-foreground opacity-90 tracking-wide font-extrabold drop-shadow-x ${hideName ? "hidden sm:inline-block" : ""}`}
|
||||
>
|
||||
{$_('brand.name')}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.logo {
|
||||
font-family: 'Dancing Script', cursive;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
|
||||
import { getUserInitials } from "$lib/utils";
|
||||
|
||||
interface User {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// 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 handleMove = (clientX: number) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
currentX = clientX;
|
||||
const deltaX = currentX - startX;
|
||||
slidePosition = Math.max(0, Math.min(deltaX, maxSlide));
|
||||
};
|
||||
|
||||
const handleEnd = () => {
|
||||
if (!isDragging) return;
|
||||
|
||||
isDragging = false;
|
||||
|
||||
if (slideProgress >= threshold) {
|
||||
// Trigger logout
|
||||
slidePosition = maxSlide;
|
||||
onLogout();
|
||||
} else {
|
||||
// Snap back
|
||||
slidePosition = 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Mouse events
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
handleStart(e.clientX);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
handleMove(e.clientX);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
handleEnd();
|
||||
};
|
||||
|
||||
// Touch events
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
handleStart(e.touches[0].clientX);
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
e.preventDefault();
|
||||
handleMove(e.touches[0].clientX);
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
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,
|
||||
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,
|
||||
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>
|
||||
|
||||
<!-- 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} />
|
||||
<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)}
|
||||
</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.split(" ")[0]}</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>
|
||||
|
||||
|
||||
<!-- 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>
|
||||
24
packages/frontend/src/lib/components/meta/meta.svelte
Normal file
24
packages/frontend/src/lib/components/meta/meta.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import { PUBLIC_URL || http://localhost:3000 } from "$env/static/public";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
description,
|
||||
image = `${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} />
|
||||
</svelte:head>
|
||||
@@ -0,0 +1,119 @@
|
||||
<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 type { Snippet } from "svelte";
|
||||
import Label from "../ui/label/label.svelte";
|
||||
import Input from "../ui/input/input.svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
email: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let isLoading = $state(false);
|
||||
|
||||
async function handleSubscription(e: Event) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
isLoading = true;
|
||||
await fetch("/newsletter", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
toast.success(
|
||||
$_("newsletter_signup.toast_subscribe", { values: { email } }),
|
||||
);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
|
||||
let { open = $bindable(), email = $bindable() }: 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--newspaper-line]"></span>
|
||||
</div>
|
||||
<div class="">
|
||||
<DialogTitle
|
||||
class="text-left text-xl font-semibold text-primary-foreground"
|
||||
>{$_('newsletter_signup.title')}</DialogTitle
|
||||
>
|
||||
<DialogDescription class="text-left text-sm">
|
||||
{$_('newsletter_signup.description')}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<Separator class="my-4" />
|
||||
|
||||
<form onsubmit={handleSubscription}>
|
||||
<!-- Email -->
|
||||
<div class="space-y-2 flex gap-4 items-center">
|
||||
<Label for="email" class="m-0">{$_('newsletter_signup.email')}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder={$_('newsletter_signup.email_placeholder')}
|
||||
bind:value={email}
|
||||
required
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<Separator class="my-8" />
|
||||
|
||||
<!-- Close Button -->
|
||||
<div class="flex justify-end gap-4">
|
||||
<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>
|
||||
{$_('newsletter_signup.close')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
type="submit"
|
||||
class="cursor-pointer"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{#if isLoading}
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
|
||||
></div>
|
||||
{$_('newsletter_signup.subscribing')}
|
||||
{:else}
|
||||
<span class="icon-[ri--check-line]"></span>
|
||||
{$_('newsletter_signup.subscribe')}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script>
|
||||
import { _ } from "svelte-i18n";
|
||||
import { Button } from "../ui/button";
|
||||
import { Card, CardContent } from "../ui/card";
|
||||
import NewsletterSignupPopup from "./newsletter-signup-popup.svelte";
|
||||
let isPopupOpen = $state(false);
|
||||
|
||||
let { email = "" } = $props();
|
||||
</script>
|
||||
|
||||
<!-- Newsletter Signup -->
|
||||
<Card class="p-0 not-last:bg-gradient-to-br from-primary/10 to-accent/10">
|
||||
<CardContent class="p-6 text-center">
|
||||
<h3 class="font-semibold mb-2">{$_('newsletter_signup.title')}</h3>
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
{$_('newsletter_signup.description')}
|
||||
</p>
|
||||
<Button
|
||||
onclick={() => (isPopupOpen = true)}
|
||||
target="_blank"
|
||||
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
>{$_('newsletter_signup.cta')}</Button
|
||||
>
|
||||
<NewsletterSignupPopup bind:open={isPopupOpen} {email} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
|
||||
interface Props {
|
||||
onclick: () => void;
|
||||
icon: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
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"
|
||||
>
|
||||
<span class={icon + " w-4 h-4 text-primary"}></span>
|
||||
</button>
|
||||
@@ -0,0 +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";
|
||||
|
||||
interface Props {
|
||||
content: ShareContent;
|
||||
}
|
||||
|
||||
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"));
|
||||
};
|
||||
|
||||
const shareToFacebook = () => {
|
||||
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(content.url)}"e=${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 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 copyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content.url);
|
||||
toast.success($_("sharing_popup.success.copy"));
|
||||
} catch (err) {
|
||||
// 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="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={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={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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script>
|
||||
import { _ } from "svelte-i18n";
|
||||
import SharingPopup from "./sharing-popup.svelte";
|
||||
import Button from "../ui/button/button.svelte";
|
||||
|
||||
const { content } = $props();
|
||||
let isPopupOpen = $state(false);
|
||||
</script>
|
||||
|
||||
<Button
|
||||
onclick={() => (isPopupOpen = true)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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')}
|
||||
</Button>
|
||||
<SharingPopup bind:open={isPopupOpen} {content} />
|
||||
@@ -0,0 +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";
|
||||
|
||||
interface ShareContent {
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
type: "video" | "model" | "article" | "link";
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
content: ShareContent;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
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"
|
||||
>
|
||||
<span class="icon-[ri--close-large-line]"></span>
|
||||
{$_("sharing_popup.close")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="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?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert-title"
|
||||
class={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
44
packages/frontend/src/lib/components/ui/alert/alert.svelte
Normal file
44
packages/frontend/src/lib/components/ui/alert/alert.svelte
Normal file
@@ -0,0 +1,44 @@
|
||||
<script lang="ts" module>
|
||||
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 type AlertVariant = VariantProps<typeof alertVariants>["variant"];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
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();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert"
|
||||
class={cn(alertVariants({ variant }), className)}
|
||||
{...restProps}
|
||||
role="alert"
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
14
packages/frontend/src/lib/components/ui/alert/index.ts
Normal file
14
packages/frontend/src/lib/components/ui/alert/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import Root from "./alert.svelte";
|
||||
import Description from "./alert-description.svelte";
|
||||
import Title from "./alert-title.svelte";
|
||||
export { alertVariants, type AlertVariant } from "./alert.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Description,
|
||||
Title,
|
||||
//
|
||||
Root as Alert,
|
||||
Description as AlertDescription,
|
||||
Title as AlertTitle,
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
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}
|
||||
/>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
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}
|
||||
/>
|
||||
19
packages/frontend/src/lib/components/ui/avatar/avatar.svelte
Normal file
19
packages/frontend/src/lib/components/ui/avatar/avatar.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
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();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Root
|
||||
bind:ref
|
||||
bind:loadingStatus
|
||||
data-slot="avatar"
|
||||
class={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
13
packages/frontend/src/lib/components/ui/avatar/index.ts
Normal file
13
packages/frontend/src/lib/components/ui/avatar/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import Root from "./avatar.svelte";
|
||||
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,
|
||||
};
|
||||
86
packages/frontend/src/lib/components/ui/button/button.svelte
Normal file
86
packages/frontend/src/lib/components/ui/button/button.svelte
Normal file
@@ -0,0 +1,86 @@
|
||||
<script lang="ts" module>
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type {
|
||||
HTMLAnchorAttributes,
|
||||
HTMLButtonAttributes,
|
||||
} from "svelte/elements";
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
|
||||
export const buttonVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
|
||||
outline:
|
||||
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
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 ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||
WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let {
|
||||
class: className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
ref = $bindable(null),
|
||||
href = undefined,
|
||||
type = "button",
|
||||
disabled,
|
||||
children,
|
||||
...restProps
|
||||
}: ButtonProps = $props();
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
href={disabled ? undefined : href}
|
||||
aria-disabled={disabled}
|
||||
role={disabled ? "link" : undefined}
|
||||
tabindex={disabled ? -1 : undefined}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
{type}
|
||||
{disabled}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
17
packages/frontend/src/lib/components/ui/button/index.ts
Normal file
17
packages/frontend/src/lib/components/ui/button/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import Root, {
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
buttonVariants,
|
||||
} from "./button.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
type ButtonProps as Props,
|
||||
//
|
||||
Root as Button,
|
||||
buttonVariants,
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-action"
|
||||
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
|
||||
</script>
|
||||
|
||||
<p
|
||||
bind:this={ref}
|
||||
data-slot="card-description"
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</p>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-footer"
|
||||
class={cn("[.border-t]:pt-6 flex items-center px-6", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-header"
|
||||
class={cn(
|
||||
"@container/card-header 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?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-title"
|
||||
class={cn("font-semibold leading-none", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
23
packages/frontend/src/lib/components/ui/card/card.svelte
Normal file
23
packages/frontend/src/lib/components/ui/card/card.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card"
|
||||
class={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
25
packages/frontend/src/lib/components/ui/card/index.ts
Normal file
25
packages/frontend/src/lib/components/ui/card/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import Root from "./card.svelte";
|
||||
import Content from "./card-content.svelte";
|
||||
import Description from "./card-description.svelte";
|
||||
import Footer from "./card-footer.svelte";
|
||||
import Header from "./card-header.svelte";
|
||||
import Title from "./card-title.svelte";
|
||||
import Action from "./card-action.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Description,
|
||||
Footer,
|
||||
Header,
|
||||
Title,
|
||||
Action,
|
||||
//
|
||||
Root as Card,
|
||||
Content as CardContent,
|
||||
Description as CardDescription,
|
||||
Footer as CardFooter,
|
||||
Header as CardHeader,
|
||||
Title as CardTitle,
|
||||
Action as CardAction,
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { Checkbox as CheckboxPrimitive } from "bits-ui";
|
||||
import CheckIcon from "@lucide/svelte/icons/check";
|
||||
import MinusIcon from "@lucide/svelte/icons/minus";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
checked = $bindable(false),
|
||||
indeterminate = $bindable(false),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
|
||||
</script>
|
||||
|
||||
<CheckboxPrimitive.Root
|
||||
bind:ref
|
||||
data-slot="checkbox"
|
||||
class={cn(
|
||||
"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}
|
||||
</CheckboxPrimitive.Root>
|
||||
@@ -0,0 +1,6 @@
|
||||
import Root from "./checkbox.svelte";
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Checkbox,
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import XIcon from "@lucide/svelte/icons/x";
|
||||
import type { Snippet } from "svelte";
|
||||
import * as Dialog from "./index.js";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
portalProps,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
||||
portalProps?: DialogPrimitive.PortalProps;
|
||||
children: Snippet;
|
||||
showCloseButton?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Dialog.Portal {...portalProps}>
|
||||
<Dialog.Overlay />
|
||||
<DialogPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="dialog-content"
|
||||
class={cn(
|
||||
"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>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DialogPrimitive.DescriptionProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Description
|
||||
bind:ref
|
||||
data-slot="dialog-description"
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="dialog-footer"
|
||||
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="dialog-header"
|
||||
class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DialogPrimitive.OverlayProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Overlay
|
||||
bind:ref
|
||||
data-slot="dialog-overlay"
|
||||
class={cn(
|
||||
"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}
|
||||
/>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DialogPrimitive.TitleProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Title
|
||||
bind:ref
|
||||
data-slot="dialog-title"
|
||||
class={cn("text-lg font-semibold leading-none", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />
|
||||
37
packages/frontend/src/lib/components/ui/dialog/index.ts
Normal file
37
packages/frontend/src/lib/components/ui/dialog/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
|
||||
import Title from "./dialog-title.svelte";
|
||||
import Footer from "./dialog-footer.svelte";
|
||||
import Header from "./dialog-header.svelte";
|
||||
import Overlay from "./dialog-overlay.svelte";
|
||||
import Content from "./dialog-content.svelte";
|
||||
import Description from "./dialog-description.svelte";
|
||||
import Trigger from "./dialog-trigger.svelte";
|
||||
import Close from "./dialog-close.svelte";
|
||||
|
||||
const Root = DialogPrimitive.Root;
|
||||
const Portal = DialogPrimitive.Portal;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Title,
|
||||
Portal,
|
||||
Footer,
|
||||
Header,
|
||||
Trigger,
|
||||
Overlay,
|
||||
Content,
|
||||
Description,
|
||||
Close,
|
||||
//
|
||||
Root as Dialog,
|
||||
Title as DialogTitle,
|
||||
Portal as DialogPortal,
|
||||
Footer as DialogFooter,
|
||||
Header as DialogHeader,
|
||||
Trigger as DialogTrigger,
|
||||
Overlay as DialogOverlay,
|
||||
Content as DialogContent,
|
||||
Description as DialogDescription,
|
||||
Close as DialogClose,
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
<!--
|
||||
Installed from @ieedan/shadcn-svelte-extras
|
||||
-->
|
||||
|
||||
<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";
|
||||
|
||||
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",
|
||||
);
|
||||
}
|
||||
|
||||
let uploading = $state(false);
|
||||
|
||||
const drop = async (
|
||||
e: DragEvent & {
|
||||
currentTarget: EventTarget & HTMLLabelElement;
|
||||
},
|
||||
) => {
|
||||
if (disabled || !canUploadFiles) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const droppedFiles = Array.from(e.dataTransfer?.files ?? []);
|
||||
|
||||
await upload(droppedFiles);
|
||||
};
|
||||
|
||||
const change = async (
|
||||
e: Event & {
|
||||
currentTarget: EventTarget & HTMLInputElement;
|
||||
},
|
||||
) => {
|
||||
if (disabled) return;
|
||||
|
||||
const selectedFiles = e.currentTarget.files;
|
||||
|
||||
if (!selectedFiles) return;
|
||||
|
||||
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 = "";
|
||||
};
|
||||
|
||||
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 (!accept) return undefined;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// 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;
|
||||
});
|
||||
|
||||
if (!isAcceptable) return "File type not allowed";
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const upload = async (uploadFiles: File[]) => {
|
||||
uploading = true;
|
||||
|
||||
const validFiles: File[] = [];
|
||||
|
||||
for (let i = 0; i < uploadFiles.length; i++) {
|
||||
const file = uploadFiles[i];
|
||||
|
||||
const rejectedReason = shouldAcceptFile(file, (fileCount ?? 0) + i + 1);
|
||||
|
||||
if (rejectedReason) {
|
||||
onFileRejected?.({ file, reason: rejectedReason });
|
||||
continue;
|
||||
}
|
||||
|
||||
validFiles.push(file);
|
||||
}
|
||||
|
||||
await onUpload(validFiles);
|
||||
|
||||
uploading = false;
|
||||
};
|
||||
|
||||
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,
|
||||
)}
|
||||
>
|
||||
{#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>
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
Installed from @ieedan/shadcn-svelte-extras
|
||||
*/
|
||||
|
||||
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 < MEGABYTE) return `${(bytes / KILOBYTE).toFixed(0)} KB`;
|
||||
|
||||
if (bytes < GIGABYTE) return `${(bytes / MEGABYTE).toFixed(0)} MB`;
|
||||
|
||||
return `${(bytes / GIGABYTE).toFixed(0)} GB`;
|
||||
};
|
||||
|
||||
// Utilities for working with file sizes
|
||||
export const BYTE = 1;
|
||||
export const KILOBYTE = 1024;
|
||||
export const MEGABYTE = 1024 * KILOBYTE;
|
||||
export const GIGABYTE = 1024 * MEGABYTE;
|
||||
|
||||
// utilities for limiting accepted files
|
||||
export const ACCEPT_IMAGE = "image/*";
|
||||
export const ACCEPT_VIDEO = "video/*";
|
||||
export const ACCEPT_AUDIO = "audio/*";
|
||||
|
||||
export { FileDropZone, type FileRejectedReason, type FileDropZoneProps };
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
Installed from @ieedan/shadcn-svelte-extras
|
||||
*/
|
||||
|
||||
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";
|
||||
|
||||
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;
|
||||
|
||||
// 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">;
|
||||
7
packages/frontend/src/lib/components/ui/input/index.ts
Normal file
7
packages/frontend/src/lib/components/ui/input/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./input.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Input,
|
||||
};
|
||||
57
packages/frontend/src/lib/components/ui/input/input.svelte
Normal file
57
packages/frontend/src/lib/components/ui/input/input.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import type {
|
||||
HTMLInputAttributes,
|
||||
HTMLInputTypeAttribute,
|
||||
} from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
|
||||
|
||||
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();
|
||||
</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}
|
||||
/>
|
||||
{: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}
|
||||
/>
|
||||
{/if}
|
||||
7
packages/frontend/src/lib/components/ui/label/index.ts
Normal file
7
packages/frontend/src/lib/components/ui/label/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./label.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Label,
|
||||
};
|
||||
20
packages/frontend/src/lib/components/ui/label/label.svelte
Normal file
20
packages/frontend/src/lib/components/ui/label/label.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Label as LabelPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
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}
|
||||
/>
|
||||
37
packages/frontend/src/lib/components/ui/select/index.ts
Normal file
37
packages/frontend/src/lib/components/ui/select/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
|
||||
import Group from "./select-group.svelte";
|
||||
import Label from "./select-label.svelte";
|
||||
import Item from "./select-item.svelte";
|
||||
import Content from "./select-content.svelte";
|
||||
import Trigger from "./select-trigger.svelte";
|
||||
import Separator from "./select-separator.svelte";
|
||||
import ScrollDownButton from "./select-scroll-down-button.svelte";
|
||||
import ScrollUpButton from "./select-scroll-up-button.svelte";
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +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";
|
||||
|
||||
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}
|
||||
>
|
||||
<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>
|
||||
</SelectPrimitive.Portal>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
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();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.GroupHeading
|
||||
bind:ref
|
||||
data-slot="select-group-heading"
|
||||
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</SelectPrimitive.GroupHeading>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Group data-slot="select-group" {...restProps} />
|
||||
@@ -0,0 +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";
|
||||
|
||||
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}
|
||||
>
|
||||
{#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>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="select-label"
|
||||
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +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";
|
||||
|
||||
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}
|
||||
>
|
||||
<ChevronDownIcon class="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
@@ -0,0 +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";
|
||||
|
||||
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}
|
||||
>
|
||||
<ChevronUpIcon class="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
@@ -0,0 +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";
|
||||
|
||||
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}
|
||||
/>
|
||||
@@ -0,0 +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";
|
||||
|
||||
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}
|
||||
>
|
||||
{@render children?.()}
|
||||
<ChevronDownIcon class="size-4 opacity-50" />
|
||||
</SelectPrimitive.Trigger>
|
||||
@@ -0,0 +1,7 @@
|
||||
import Root from "./separator.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Separator,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Separator as SeparatorPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
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}
|
||||
/>
|
||||
7
packages/frontend/src/lib/components/ui/slider/index.ts
Normal file
7
packages/frontend/src/lib/components/ui/slider/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./slider.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Slider,
|
||||
};
|
||||
52
packages/frontend/src/lib/components/ui/slider/slider.svelte
Normal file
52
packages/frontend/src/lib/components/ui/slider/slider.svelte
Normal file
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
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();
|
||||
</script>
|
||||
|
||||
<!--
|
||||
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}
|
||||
>
|
||||
{#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>
|
||||
1
packages/frontend/src/lib/components/ui/sonner/index.ts
Normal file
1
packages/frontend/src/lib/components/ui/sonner/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Toaster } from "./sonner.svelte";
|
||||
16
packages/frontend/src/lib/components/ui/sonner/sonner.svelte
Normal file
16
packages/frontend/src/lib/components/ui/sonner/sonner.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Toaster as Sonner,
|
||||
type ToasterProps as SonnerProps,
|
||||
} from "svelte-sonner";
|
||||
import { mode } from "mode-watcher";
|
||||
|
||||
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}
|
||||
/>
|
||||
16
packages/frontend/src/lib/components/ui/tabs/index.ts
Normal file
16
packages/frontend/src/lib/components/ui/tabs/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import Root from "./tabs.svelte";
|
||||
import Content from "./tabs-content.svelte";
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
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}
|
||||
/>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
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}
|
||||
/>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
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}
|
||||
/>
|
||||
19
packages/frontend/src/lib/components/ui/tabs/tabs.svelte
Normal file
19
packages/frontend/src/lib/components/ui/tabs/tabs.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
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();
|
||||
</script>
|
||||
|
||||
<TabsPrimitive.Root
|
||||
bind:ref
|
||||
bind:value
|
||||
data-slot="tabs"
|
||||
class={cn("flex flex-col gap-2", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
Installed from @ieedan/shadcn-svelte-extras
|
||||
*/
|
||||
|
||||
import TagsInput from "./tags-input.svelte";
|
||||
|
||||
export { TagsInput };
|
||||
|
||||
export type * from "./types";
|
||||
@@ -0,0 +1,28 @@
|
||||
<!--
|
||||
Installed from @ieedan/shadcn-svelte-extras
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import XIcon from "@lucide/svelte/icons/x";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
disabled: boolean | null;
|
||||
active: boolean;
|
||||
onDelete: (value: string) => void;
|
||||
};
|
||||
|
||||
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}
|
||||
>
|
||||
<span>
|
||||
{value}
|
||||
</span>
|
||||
<button type="button" {disabled} onclick={() => onDelete(value)}>
|
||||
<XIcon class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,211 @@
|
||||
<!--
|
||||
Installed from @ieedan/shadcn-svelte-extras
|
||||
-->
|
||||
|
||||
<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";
|
||||
|
||||
const defaultValidate: TagsInputProps["validate"] = (val, tags) => {
|
||||
const transformed = val.trim();
|
||||
|
||||
// disallow empties
|
||||
if (transformed.length === 0) return undefined;
|
||||
|
||||
// disallow duplicates
|
||||
if (tags.find((t) => transformed === t)) return undefined;
|
||||
|
||||
return transformed;
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
$effect(() => {
|
||||
// whenever input value changes reset invalid
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
inputValue;
|
||||
|
||||
untrack(() => {
|
||||
invalid = false;
|
||||
});
|
||||
});
|
||||
|
||||
const enter = () => {
|
||||
if (isComposing) return;
|
||||
|
||||
const validated = validate(inputValue, value);
|
||||
|
||||
if (!validated) {
|
||||
invalid = true;
|
||||
return;
|
||||
}
|
||||
|
||||
value = [...value, validated];
|
||||
inputValue = "";
|
||||
};
|
||||
|
||||
const compositionStart = () => {
|
||||
isComposing = true;
|
||||
};
|
||||
|
||||
const compositionEnd = () => {
|
||||
isComposing = false;
|
||||
};
|
||||
|
||||
const keydown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
|
||||
if (e.key === "Enter") {
|
||||
// prevent form submit
|
||||
e.preventDefault();
|
||||
|
||||
if (isComposing) return;
|
||||
|
||||
enter();
|
||||
return;
|
||||
}
|
||||
|
||||
const isAtBeginning =
|
||||
target.selectionStart === 0 && target.selectionEnd === 0;
|
||||
|
||||
let shouldResetIndex = true;
|
||||
|
||||
if (e.key === "Backspace") {
|
||||
if (isAtBeginning) {
|
||||
e.preventDefault();
|
||||
|
||||
if (tagIndex !== undefined) {
|
||||
deleteIndex(tagIndex);
|
||||
|
||||
// focus previous
|
||||
const prev = tagIndex - 1;
|
||||
|
||||
if (prev < 0) {
|
||||
tagIndex = undefined;
|
||||
} else {
|
||||
tagIndex = prev;
|
||||
}
|
||||
} else {
|
||||
tagIndex = value.length - 1;
|
||||
}
|
||||
|
||||
shouldResetIndex = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Delete") {
|
||||
if (isAtBeginning) {
|
||||
if (inputValue.length === 0) {
|
||||
if (tagIndex !== undefined) {
|
||||
e.preventDefault();
|
||||
|
||||
deleteIndex(tagIndex);
|
||||
|
||||
// stay focused on the same index unless value.length === 0
|
||||
if (value.length === 0) tagIndex = undefined;
|
||||
|
||||
shouldResetIndex = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (next > value.length - 1) {
|
||||
tagIndex = undefined;
|
||||
} else {
|
||||
tagIndex = next;
|
||||
}
|
||||
|
||||
shouldResetIndex = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reset the tag index to undefined
|
||||
if (shouldResetIndex) {
|
||||
tagIndex = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteValue = (val: string) => {
|
||||
const index = value.findIndex((v) => val === v);
|
||||
|
||||
if (index === -1) return;
|
||||
|
||||
deleteIndex(index);
|
||||
};
|
||||
|
||||
const deleteIndex = (index: number) => {
|
||||
value = [...value.slice(0, index), ...value.slice(index + 1)];
|
||||
};
|
||||
|
||||
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}
|
||||
>
|
||||
{#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>
|
||||
13
packages/frontend/src/lib/components/ui/tags-input/types.ts
Normal file
13
packages/frontend/src/lib/components/ui/tags-input/types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
Installed from @ieedan/shadcn-svelte-extras
|
||||
*/
|
||||
|
||||
import type { HTMLInputAttributes } from "svelte/elements";
|
||||
|
||||
export type TagsInputPropsWithoutHTML = {
|
||||
value?: string[];
|
||||
validate?: (val: string, tags: string[]) => string | undefined;
|
||||
};
|
||||
|
||||
export type TagsInputProps = TagsInputPropsWithoutHTML &
|
||||
Omit<HTMLInputAttributes, "value">;
|
||||
@@ -0,0 +1,7 @@
|
||||
import Root from "./textarea.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Textarea,
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
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();
|
||||
</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}
|
||||
></textarea>
|
||||
35
packages/frontend/src/lib/directus.ts
Normal file
35
packages/frontend/src/lib/directus.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { authentication, createDirectus, rest } from "@directus/sdk";
|
||||
import { PUBLIC_API_URL } from "$env/static/public";
|
||||
import type { CurrentUser } from "./types";
|
||||
|
||||
export const directusApiUrl = PUBLIC_API_URL || "http://localhost:3000/api";
|
||||
|
||||
export const getDirectusInstance = (fetch?: typeof globalThis.fetch) => {
|
||||
const options: { globals?: { fetch: typeof globalThis.fetch } } = fetch
|
||||
? { globals: { fetch } }
|
||||
: {};
|
||||
const directus = createDirectus(directusApiUrl, options)
|
||||
.with(rest())
|
||||
.with(authentication("session"));
|
||||
return directus;
|
||||
};
|
||||
|
||||
export const getAssetUrl = (
|
||||
id: string,
|
||||
transform?: "mini" | "thumbnail" | "preview" | "medium" | "banner",
|
||||
) => {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
return `${directusApiUrl}/assets/${id}${transform ? "?transform=" + transform : ""}`;
|
||||
};
|
||||
|
||||
export const isModel = (user: CurrentUser) => {
|
||||
if (user.role.name === "Model") {
|
||||
return true;
|
||||
}
|
||||
if (user.policies.find((p) => p.policy.name === "Model")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
11
packages/frontend/src/lib/i18n/index.ts
Normal file
11
packages/frontend/src/lib/i18n/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { init, addMessages } from "svelte-i18n";
|
||||
import en from "./locales/en";
|
||||
|
||||
const defaultLocale = "en";
|
||||
|
||||
addMessages("en", en);
|
||||
|
||||
init({
|
||||
fallbackLocale: defaultLocale,
|
||||
initialLocale: defaultLocale,
|
||||
});
|
||||
877
packages/frontend/src/lib/i18n/locales/en.ts
Normal file
877
packages/frontend/src/lib/i18n/locales/en.ts
Normal file
@@ -0,0 +1,877 @@
|
||||
export default {
|
||||
common: {
|
||||
loading: "Loading...",
|
||||
error: "Error",
|
||||
success: "Success",
|
||||
cancel: "Cancel",
|
||||
save: "Save",
|
||||
delete: "Delete",
|
||||
edit: "Edit",
|
||||
view: "View",
|
||||
back: "Back",
|
||||
next: "Next",
|
||||
previous: "Previous",
|
||||
search: "Search",
|
||||
filter: "Filter",
|
||||
sort: "Sort",
|
||||
clear: "Clear",
|
||||
submit: "Submit",
|
||||
close: "Close",
|
||||
open: "Open",
|
||||
yes: "Yes",
|
||||
no: "No",
|
||||
},
|
||||
header: {
|
||||
home: "Home",
|
||||
models: "Models",
|
||||
videos: "Videos",
|
||||
magazine: "Magazine",
|
||||
about: "About",
|
||||
login: "Log In",
|
||||
login_hint: "Return to your passion",
|
||||
signup: "Sign Up",
|
||||
signup_hint: "Join now our community",
|
||||
logout: "Log Out",
|
||||
logout_hint: "Sign out of your account",
|
||||
dashboard: "Dashboard",
|
||||
dashboard_hint: "Your settings and more",
|
||||
play: "Play",
|
||||
play_hint: "Bring your toys",
|
||||
profile: "Profile",
|
||||
mailto: "sexy@pivoine.art",
|
||||
x: "bordeaux1981",
|
||||
youtube: "lovesting",
|
||||
navigation: "Navigation",
|
||||
account: "Account",
|
||||
},
|
||||
brand: {
|
||||
name: "SexyArt",
|
||||
tagline: "Where Love Meets Artistry",
|
||||
description:
|
||||
"The premier destination for artistic adult content, intimate storytelling, and creative expression through video and magazine content.",
|
||||
},
|
||||
home: {
|
||||
hero: {
|
||||
title: "Where Love Meets Artistry",
|
||||
subtitle: "Artistry",
|
||||
description:
|
||||
"Experience the most intimate and beautiful love stories through our exclusive video content and magazine features.",
|
||||
cta_videos: "Explore Videos",
|
||||
cta_models: "Meet Our Models",
|
||||
},
|
||||
featured_models: {
|
||||
title: "Featured Models",
|
||||
description: "Meet our most beloved creators",
|
||||
rating: "rating",
|
||||
videos: "videos",
|
||||
view_profile: "View Profile",
|
||||
join_community: "Join Our Community",
|
||||
join_community_description:
|
||||
"Become part of the most exclusive love and romance community. Access premium content, connect with models, and experience love like never before.",
|
||||
},
|
||||
trending: {
|
||||
title: "Trending Now",
|
||||
description: "Most watched romantic content",
|
||||
views: "views",
|
||||
trending: "Latest",
|
||||
},
|
||||
community: {
|
||||
title: "Join Our Community",
|
||||
description:
|
||||
"Become part of the most exclusive love and romance community. Access premium content, connect with models, and experience love like never before.",
|
||||
cta_join: "Start Your Journey",
|
||||
cta_magazine: "Read Magazine",
|
||||
},
|
||||
},
|
||||
me: {
|
||||
title: "Dashboard",
|
||||
welcome: "Welcome back, {name}",
|
||||
view_profile: "View Public Profile",
|
||||
settings: {
|
||||
title: "Settings",
|
||||
profile_title: "Profile Settings",
|
||||
profile_subtitle: "Update your profile information",
|
||||
avatar: "Avatar",
|
||||
first_name: "First Name",
|
||||
first_name_placeholder: "John",
|
||||
artist_name: "Artist Name",
|
||||
artist_name_placeholder: "Johnny",
|
||||
description: "Description",
|
||||
description_placeholder: "Your description",
|
||||
tags: "Tags",
|
||||
tags_placeholder: "Enter tags",
|
||||
last_name: "Last Name",
|
||||
last_name_placeholder: "Doe",
|
||||
update_profile: "Update Profile",
|
||||
updating_profile: "Updating Profile...",
|
||||
toast_update: "Your settings have been updated!",
|
||||
error: "Heads Up!",
|
||||
privacy_title: "Privacy & Security",
|
||||
privacy_subtitle: "Manage your account privacy and security settings",
|
||||
update_security: "Update Security",
|
||||
updating_security: "Updating Security...",
|
||||
password_error: "The password has to match the confirmation password.",
|
||||
email: "Email",
|
||||
email_placeholder: "your@email.com",
|
||||
password: "Password",
|
||||
password_placeholder: "Create a strong password",
|
||||
confirm_password: "Confirm Password",
|
||||
confirm_password_placeholder: "Confirm your password",
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
login: {
|
||||
title: "Sign In",
|
||||
description: "Enter your credentials to access your account",
|
||||
welcome: "Welcome back to your passion",
|
||||
email: "Email",
|
||||
email_placeholder: "your@email.com",
|
||||
password: "Password",
|
||||
password_placeholder: "Enter your password",
|
||||
remember_me: "Remember me",
|
||||
forgot_password: "Forgot password?",
|
||||
signing_in: "Signing in...",
|
||||
sign_in: "Sign In",
|
||||
or_continue: "Or continue with",
|
||||
google: "Google",
|
||||
facebook: "Facebook",
|
||||
no_account: "Don't have an account?",
|
||||
sign_up_link: "Sign up now",
|
||||
error: "Heads Up!",
|
||||
},
|
||||
signup: {
|
||||
title: "Create Account",
|
||||
description: "Start your journey with us today",
|
||||
welcome: "Join the most passionate community",
|
||||
first_name: "First Name",
|
||||
first_name_placeholder: "John",
|
||||
last_name: "Last Name",
|
||||
last_name_placeholder: "Doe",
|
||||
email: "Email",
|
||||
email_placeholder: "your@email.com",
|
||||
account_type: "Account Type",
|
||||
account_viewer: "Content Viewer",
|
||||
account_creator: "Content Creator/Model",
|
||||
password: "Password",
|
||||
password_placeholder: "Create a strong password",
|
||||
confirm_password: "Confirm Password",
|
||||
confirm_password_placeholder: "Confirm your password",
|
||||
terms_agreement:
|
||||
"I agree to the {terms} and {privacy}. I confirm I am 18+ years old.",
|
||||
terms_of_service: "Terms of Service",
|
||||
privacy_policy: "Privacy Policy",
|
||||
creating_account: "Creating account...",
|
||||
create_account: "Create Account",
|
||||
have_account: "Already have an account?",
|
||||
sign_in_link: "Sign in here",
|
||||
error: "Heads Up!",
|
||||
agree_error: "You must confirm our terms of service and your age.",
|
||||
password_error: "The password has to match the confirmation password.",
|
||||
toast_register: "A verification email has been sent to {email}!",
|
||||
toast_verify: "Your account has been activated!",
|
||||
},
|
||||
password_request: {
|
||||
title: "Password Request",
|
||||
description: "Enter your email to reset your password",
|
||||
welcome: "Return to your passion",
|
||||
email: "Email",
|
||||
email_placeholder: "your@email.com",
|
||||
requesting: "Submitting...",
|
||||
request: "Submit",
|
||||
error: "Heads Up!",
|
||||
toast_request: "A password reset email has been sent to {email}!",
|
||||
},
|
||||
password_reset: {
|
||||
title: "Password Reset",
|
||||
description: "Enter your new password",
|
||||
welcome: "Return to your passion now",
|
||||
password: "Password",
|
||||
password_placeholder: "Create a strong password",
|
||||
confirm_password: "Confirm Password",
|
||||
confirm_password_placeholder: "Confirm your password",
|
||||
resetting: "Resetting...",
|
||||
reset: "Reset",
|
||||
error: "Heads Up!",
|
||||
password_error: "The password has to match the confirmation password.",
|
||||
toast_reset: "Your password has been reset!",
|
||||
},
|
||||
},
|
||||
models: {
|
||||
title: "Our Models",
|
||||
description:
|
||||
"Discover the most beautiful and talented creators sharing their passion and artistry.",
|
||||
search_placeholder: "Search models...",
|
||||
categories: {
|
||||
all: "All Categories",
|
||||
romantic: "Romantic",
|
||||
artistic: "Artistic",
|
||||
intimate: "Intimate",
|
||||
},
|
||||
sort: {
|
||||
popular: "Most Popular",
|
||||
rating: "Highest Rated",
|
||||
videos: "Most Videos",
|
||||
name: "A-Z",
|
||||
},
|
||||
online: "Online",
|
||||
followers: "followers",
|
||||
view_profile: "View Profile",
|
||||
follow: "Follow",
|
||||
no_results: "No models found matching your criteria.",
|
||||
clear_filters: "Clear Filters",
|
||||
back: "Back to Models",
|
||||
joined: "Joined {join_date}",
|
||||
comments: "Comments",
|
||||
videos: "Videos",
|
||||
photos: "Photos",
|
||||
},
|
||||
videos: {
|
||||
title: "Your Videos",
|
||||
description:
|
||||
"Explore our curated collection of intimate and artistic video content",
|
||||
search_placeholder: "Search videos or models...",
|
||||
categories: {
|
||||
all: "All Categories",
|
||||
romantic: "Romantic",
|
||||
artistic: "Artistic",
|
||||
intimate: "Intimate",
|
||||
performance: "Performance",
|
||||
},
|
||||
duration: {
|
||||
all: "Any Duration",
|
||||
short: "Short (< 10min)",
|
||||
medium: "Medium (10-20min)",
|
||||
long: "Long (20min+)",
|
||||
},
|
||||
sort: {
|
||||
trending: "Trending",
|
||||
recent: "Most Recent",
|
||||
popular: "Most Liked",
|
||||
duration: "By Duration",
|
||||
name: "A-Z",
|
||||
},
|
||||
premium: "Premium",
|
||||
views: "views",
|
||||
watch: "Watch",
|
||||
no_results: "No videos found matching your criteria.",
|
||||
clear_filters: "Clear Filters",
|
||||
comments: "Comments ({comments})",
|
||||
hide: "Hide",
|
||||
show: "Show",
|
||||
add_comment_placeholder: "Add a comment...",
|
||||
toast_comment: "Your comment has been sent",
|
||||
comment: "Comment",
|
||||
commenting: "Commenting...",
|
||||
error: "Heads Up!",
|
||||
back: "Back to Videos",
|
||||
},
|
||||
magazine: {
|
||||
title: "SexyArt Magazine",
|
||||
description:
|
||||
"Insights, stories, and inspiration from the world of love, art, and intimate expression",
|
||||
search_placeholder: "Search articles...",
|
||||
categories: {
|
||||
all: "All Categories",
|
||||
photography: "Photography",
|
||||
production: "Production",
|
||||
interview: "Interviews",
|
||||
psychology: "Psychology",
|
||||
trends: "Trends",
|
||||
spotlight: "Spotlight",
|
||||
},
|
||||
sort: {
|
||||
recent: "Most Recent",
|
||||
popular: "Most Popular",
|
||||
featured: "Featured First",
|
||||
name: "A-Z",
|
||||
},
|
||||
featured: "Featured",
|
||||
read_time: "{time} min read",
|
||||
read_article: "Read Article",
|
||||
no_results: "No articles found matching your criteria.",
|
||||
clear_filters: "Clear Filters",
|
||||
back: "Back to Magazine",
|
||||
},
|
||||
tags: {
|
||||
title: "{tag}",
|
||||
description: 'Items tagged "{tag}".',
|
||||
search_placeholder: "Search items...",
|
||||
categories: {
|
||||
all: "All Types",
|
||||
video: "Video",
|
||||
article: "Article",
|
||||
model: "Model",
|
||||
},
|
||||
view: "View {category}",
|
||||
no_results: "No items found matching your criteria.",
|
||||
clear_filters: "Clear Filters",
|
||||
},
|
||||
dashboard: {
|
||||
title: "Creator Dashboard",
|
||||
welcome: "Welcome back, {name}",
|
||||
view_profile: "View Public Profile",
|
||||
tabs: {
|
||||
overview: "Overview",
|
||||
content: "Content",
|
||||
upload: "Upload",
|
||||
settings: "Settings",
|
||||
},
|
||||
stats: {
|
||||
total_views: "Total Views",
|
||||
total_likes: "Total Likes",
|
||||
subscribers: "Subscribers",
|
||||
earnings: "Earnings",
|
||||
},
|
||||
upload: {
|
||||
title: "Upload New Content",
|
||||
description: "Share your latest creations with your audience",
|
||||
content_type: "Content Type",
|
||||
video: "Video",
|
||||
photo: "Photo",
|
||||
drop_files: "Drop your {type} here or click to browse",
|
||||
file_types: {
|
||||
video: "MP4, MOV up to 2GB",
|
||||
photo: "JPG, PNG up to 10MB",
|
||||
},
|
||||
choose_file: "Choose File",
|
||||
title_label: "Title",
|
||||
title_placeholder: "Enter content title",
|
||||
category: "Category",
|
||||
description_label: "Description",
|
||||
description_placeholder: "Describe your content...",
|
||||
upload_content: "Upload Content",
|
||||
},
|
||||
},
|
||||
about: {
|
||||
title: "About SexyArt",
|
||||
subtitle:
|
||||
"Where passion meets artistry, and intimate storytelling becomes a celebration of human connection.",
|
||||
join_community: "Join Our Community",
|
||||
stats: {
|
||||
members: "Active Members",
|
||||
videos: "Premium Videos",
|
||||
models: "Featured Models",
|
||||
experience: "Industry Experience",
|
||||
yearsFormatted: "{years} years",
|
||||
},
|
||||
story: {
|
||||
title: "Our Story",
|
||||
subtitle:
|
||||
"Born from a vision to transform how intimate content is created, shared, and appreciated",
|
||||
description_part1:
|
||||
"SexyArt was founded in 2019 with a simple yet powerful mission: to create a platform where intimate content could be appreciated as an art form, where creators could express their authentic selves, and where viewers could connect with content that celebrates love, passion, and human connection.",
|
||||
description_part2:
|
||||
"We recognized that the adult content industry needed a platform that prioritized artistic expression, creator empowerment, and community building. Our founders, coming from backgrounds in photography, digital media, and community management, set out to build something different.",
|
||||
description_part3:
|
||||
"Today, SexyArt is home to hundreds of talented creators and thousands of passionate community members who share our vision of elevating intimate content to new artistic heights.",
|
||||
},
|
||||
values: {
|
||||
title: "Our Values",
|
||||
subtitle:
|
||||
"The principles that guide everything we do and shape our community",
|
||||
authentic_expression: {
|
||||
title: "Authentic Expression",
|
||||
description:
|
||||
"We believe in celebrating genuine love, intimacy, and human connection through artistic expression.",
|
||||
},
|
||||
safety_respect: {
|
||||
title: "Safety & Respect",
|
||||
description:
|
||||
"Creating a secure environment where creators and viewers can explore content with confidence and respect.",
|
||||
},
|
||||
artistic_excellence: {
|
||||
title: "Artistic Excellence",
|
||||
description:
|
||||
"Promoting high-quality, artistic content that elevates intimate storytelling to an art form.",
|
||||
},
|
||||
community_first: {
|
||||
title: "Community First",
|
||||
description:
|
||||
"Building meaningful connections between creators and their audience through shared passion and appreciation.",
|
||||
},
|
||||
},
|
||||
team: {
|
||||
title: "Meet Our Team",
|
||||
sebastian: {
|
||||
name: "Sebastian Krüger",
|
||||
role: "Founder & CEO",
|
||||
image: "/img/sebastian.jpg",
|
||||
bio: "Visionary leader with 15+ years in digital media and content creation.",
|
||||
},
|
||||
valknar: {
|
||||
name: "Valknar",
|
||||
role: "Creative Director",
|
||||
image: "/img/valknar.gif",
|
||||
bio: "DJ and visual storyteller specializing in diffusion AI art.",
|
||||
},
|
||||
subtitle: "The passionate individuals behind SexyArt's success",
|
||||
},
|
||||
mission: {
|
||||
title: "Our Mission",
|
||||
description:
|
||||
"To create the world's most respectful, artistic, and empowering platform for intimate content, where creators can thrive and audiences can discover meaningful connections through the art of love.",
|
||||
cta_creator: "Become a Creator",
|
||||
cta_community: "Join Our Community",
|
||||
},
|
||||
contact: {
|
||||
title: "Get in Touch",
|
||||
description:
|
||||
"Have questions about our platform or interested in partnering with us? We'd love to hear from you.",
|
||||
general: {
|
||||
title: "General Inquiries",
|
||||
description: "Questions about our platform or services",
|
||||
mailto: "sexy@pivoine.art",
|
||||
},
|
||||
creators: {
|
||||
title: "Creator Support",
|
||||
description: "Support for our content creators",
|
||||
mailto: "support@pivoine.art",
|
||||
},
|
||||
},
|
||||
},
|
||||
faq: {
|
||||
title: "Frequently Asked Questions",
|
||||
description:
|
||||
"Find answers to common questions about SexyArt, our platform, and services",
|
||||
search_placeholder: "Search frequently asked questions...",
|
||||
search_results: "Search Results ({count})",
|
||||
no_results: "No questions found matching your search.",
|
||||
clear_search: "Clear Search",
|
||||
getting_started: {
|
||||
title: "Getting Started",
|
||||
questions: [
|
||||
{
|
||||
question: "How do I create an account on SexyArt?",
|
||||
answer:
|
||||
"Creating an account is simple! Click the 'Join Now' button in the top navigation, fill out the registration form with your email and basic information, verify you're 18+, and agree to our terms. You'll receive a confirmation email to activate your account.",
|
||||
},
|
||||
{
|
||||
question: "What types of content can I find on SexyArt?",
|
||||
answer:
|
||||
"SexyArt features high-quality artistic adult content including intimate photography, romantic videos, artistic nude content, and creative adult entertainment. All content is created by verified models and creators who focus on artistic expression and storytelling.",
|
||||
},
|
||||
{
|
||||
question: "Is SexyArt safe and secure?",
|
||||
answer:
|
||||
"Yes! We use industry-standard encryption, secure payment processing, and strict privacy measures. All creators are verified, and we have comprehensive content moderation. Your personal information and viewing habits are kept completely private.",
|
||||
},
|
||||
{
|
||||
question: "Can I access SexyArt on mobile devices?",
|
||||
answer:
|
||||
"Absolutely! SexyArt is fully responsive and works perfectly on smartphones, tablets, and desktop computers. You can enjoy the same high-quality experience across all your devices.",
|
||||
},
|
||||
],
|
||||
},
|
||||
creators: {
|
||||
title: "For Creators & Models",
|
||||
questions: [
|
||||
{
|
||||
question: "How do I become a creator on SexyArt?",
|
||||
answer:
|
||||
"To become a creator, sign up for a Creator account during registration or upgrade your existing account. You'll need to verify your identity, provide tax information, and agree to our creator terms. Once approved, you can start uploading content and building your audience.",
|
||||
},
|
||||
{
|
||||
question: "How much can I earn as a creator?",
|
||||
answer:
|
||||
"Creator earnings vary based on content quality, audience engagement, and marketing efforts. Our creators typically earn between $500-$10,000+ per month. We offer competitive revenue sharing, with creators keeping 70-80% of their earnings after platform fees.",
|
||||
},
|
||||
{
|
||||
question: "What content guidelines do I need to follow?",
|
||||
answer:
|
||||
"All content must feature consenting adults 18+, be original or properly licensed, and comply with our community standards. We prohibit violent, non-consensual, or illegal content. Focus on artistic, creative, and high-quality productions for best results.",
|
||||
},
|
||||
{
|
||||
question: "How do I promote my content and gain followers?",
|
||||
answer:
|
||||
"Use engaging titles and descriptions, post consistently, interact with your audience through comments and messages, collaborate with other creators, and utilize our promotional tools. High-quality content and authentic engagement are key to building a loyal fanbase.",
|
||||
},
|
||||
],
|
||||
},
|
||||
payments: {
|
||||
title: "Payments & Subscriptions",
|
||||
},
|
||||
privacy: {
|
||||
title: "Privacy & Safety",
|
||||
questions: [
|
||||
{
|
||||
question: "How do you protect my privacy?",
|
||||
answer:
|
||||
"We use advanced encryption, never share personal information with third parties, offer anonymous browsing options, and allow you to control your privacy settings. Your viewing history and personal data are kept strictly confidential.",
|
||||
},
|
||||
{
|
||||
question: "Can I block or report inappropriate content?",
|
||||
answer:
|
||||
"Yes! Every piece of content has report and block options. Our moderation team reviews all reports within 24 hours. You can also block specific creators or users to customize your experience.",
|
||||
},
|
||||
{
|
||||
question: "How do you verify creator identities?",
|
||||
answer:
|
||||
"All creators must provide government-issued ID, proof of age (18+), and complete identity verification. We also require signed model releases for all content featuring multiple people. This ensures all content is legal and consensual.",
|
||||
},
|
||||
{
|
||||
question: "What if I forget my password?",
|
||||
answer:
|
||||
"Click 'Forgot Password' on the login page, enter your email address, and we'll send you a secure reset link. For additional security, you can enable two-factor authentication in your account settings.",
|
||||
},
|
||||
],
|
||||
},
|
||||
technical: {
|
||||
title: "Technical Support",
|
||||
questions: [
|
||||
{
|
||||
question: "Why is my video not loading?",
|
||||
answer:
|
||||
"Video issues are usually related to internet connection or browser settings. Try refreshing the page, clearing your browser cache, or switching to a different browser. For persistent issues, check our system status page or contact support.",
|
||||
},
|
||||
{
|
||||
question: "How do I update my account information?",
|
||||
answer:
|
||||
"Go to Account Settings from your profile menu. You can update your email, password, payment methods, and privacy preferences. Some changes may require email verification for security.",
|
||||
},
|
||||
{
|
||||
question: "Can I download content for offline viewing?",
|
||||
answer:
|
||||
"Premium subscribers can download select content for offline viewing within our mobile app. Downloaded content expires after 30 days and cannot be shared or transferred to other devices.",
|
||||
},
|
||||
{
|
||||
question: "How do I contact customer support?",
|
||||
answer:
|
||||
"You can reach our support team via email at support@sexyart.com, through the live chat feature (available 24/7), or by submitting a ticket through your account dashboard. We typically respond within 2-4 hours.",
|
||||
},
|
||||
],
|
||||
},
|
||||
support: {
|
||||
title: "Still Need Help?",
|
||||
description:
|
||||
"Can't find the answer you're looking for? Our support team is here to help you 24/7.",
|
||||
contact: "Contact Support",
|
||||
contact_email: "support@pivoine.art",
|
||||
live_chat: "Live Chat",
|
||||
},
|
||||
},
|
||||
imprint: {
|
||||
title: "Imprint",
|
||||
description: "Legal information and company details",
|
||||
company_information: "Company Information",
|
||||
company_name: {
|
||||
title: "Company Name",
|
||||
value: "SexyArt",
|
||||
},
|
||||
legal_form: {
|
||||
title: "Legal Form",
|
||||
value: "-",
|
||||
},
|
||||
registration_number: {
|
||||
title: "Registration Number",
|
||||
value: "-",
|
||||
},
|
||||
tax_id: {
|
||||
title: "Registration Tax ID",
|
||||
value: "-",
|
||||
},
|
||||
contact_information: "Contact Information",
|
||||
registered_address: "Registered Address",
|
||||
address: {
|
||||
company: "SexyArt",
|
||||
name: "Sebastian Krüger",
|
||||
street: "Neue Weinsteige 21",
|
||||
city: "70180 Stuttgart",
|
||||
country: "Germany",
|
||||
},
|
||||
phone: {
|
||||
title: "Phone",
|
||||
value: "+49 (174) 8188918",
|
||||
},
|
||||
email: {
|
||||
title: "Email",
|
||||
value: "admin@pivoine.art",
|
||||
},
|
||||
website: {
|
||||
title: "Website",
|
||||
value: "pivoine.art",
|
||||
},
|
||||
disclaimer: "Disclaimer",
|
||||
disclaimer_text: [
|
||||
"The information contained on this website is for general information purposes only. While we endeavor to keep the information up to date and correct, we make no representations or warranties of any kind, express or implied, about the completeness, accuracy, reliability, suitability, or availability with respect to the website or the information, products, services, or related graphics contained on the website for any purpose.",
|
||||
"This website contains adult content and is intended for mature audiences only. By accessing this site, you confirm that you are at least 18 years of age and that you are legally allowed to view such content in your jurisdiction.",
|
||||
"All content on this platform is created by consenting adults and is protected by copyright laws. Unauthorized reproduction or distribution of any content is strictly prohibited.",
|
||||
],
|
||||
last_updated: "Last updated: September 5, 2025",
|
||||
},
|
||||
legal: {
|
||||
title: "Legal Information",
|
||||
description: "Our commitment to transparency, privacy, and user rights",
|
||||
privacy: {
|
||||
title: "Privacy Policy",
|
||||
last_updated: "Last updated: September 5, 2025",
|
||||
information: {
|
||||
title: "1. Information We Collect",
|
||||
text: [
|
||||
"<strong>Personal Information:</strong> When you create an account, we collect information such as your email address, username, and profile information you choose to provide.",
|
||||
"<strong>Content Information:</strong> We collect information about the content you upload, view, and interact with on our platform.",
|
||||
],
|
||||
},
|
||||
information_use: {
|
||||
title: "2. How We Use Your Information",
|
||||
subtitle: "We use your information to:",
|
||||
text: [
|
||||
"<li>Provide and improve our services</li><li>Communicate with you about your account and our services</li><li>Personalize your experience on our platform</li><li>Ensure platform security and prevent fraud</li><li>Comply with legal obligations</li>",
|
||||
],
|
||||
},
|
||||
information_sharing: {
|
||||
title: "3. Information Sharing",
|
||||
subtitle:
|
||||
"We do not sell your personal information. We may share your information in the following circumstances:",
|
||||
text: [
|
||||
"<li>With your consent</li><li>With service providers who help us operate our platform</li><li>To comply with legal requirements</li><li>To protect our rights and the safety of our users</li><li>In connection with a business transaction</li>",
|
||||
],
|
||||
},
|
||||
security: {
|
||||
title: "4. Data Security",
|
||||
text: [
|
||||
"We implement appropriate technical and organizational measures to protect your personal information against unauthorized access, alteration, disclosure, or destruction. This includes encryption, secure servers, and regular security audits.",
|
||||
],
|
||||
},
|
||||
rights: {
|
||||
title: "5. Your Rights",
|
||||
subtitle: "You have the right to:",
|
||||
text: [
|
||||
"<li>Access your personal information</li><li>Correct inaccurate information</li><li>Delete your account and personal information</li><li>Object to processing of your information</li><li>Data portability</li><li>Withdraw consent at any time</li>",
|
||||
],
|
||||
},
|
||||
},
|
||||
terms: {
|
||||
title: "Terms of Service",
|
||||
last_updated: "Last updated: September 5, 2025",
|
||||
acceptance: {
|
||||
title: "1. Acceptance of Terms",
|
||||
text: [
|
||||
"By accessing and using SexyArt, you accept and agree to be bound by the terms and provision of this agreement. If you do not agree to abide by the above, please do not use this service.",
|
||||
],
|
||||
},
|
||||
age: {
|
||||
title: "2. Age Restriction",
|
||||
text: [
|
||||
"You must be at least 18 years old to use this service. By using our platform, you represent and warrant that you are at least 18 years of age and have the legal capacity to enter into this agreement.",
|
||||
],
|
||||
},
|
||||
accounts: {
|
||||
title: "3. User Accounts",
|
||||
subtitle: "When creating an account, you agree to:",
|
||||
text: [
|
||||
"<li>Provide accurate and complete information</li><li>Maintain the security of your account credentials</li><li>Accept responsibility for all activities under your account</li><li>Notify us immediately of any unauthorized use</li>",
|
||||
],
|
||||
},
|
||||
content: {
|
||||
title: "4. Content Guidelines",
|
||||
subtitle:
|
||||
"All content must comply with our community guidelines. Prohibited content includes:",
|
||||
text: [
|
||||
"<li>Content involving minors</li><li>Non-consensual content</li><li>Violent or harmful content</li><li>Copyrighted material without permission</li><li>Spam or misleading content</li>",
|
||||
],
|
||||
},
|
||||
payment: {
|
||||
title: "5. Payment Terms",
|
||||
subtitle: "For paid services:",
|
||||
text: [
|
||||
"<li>All payments are processed securely through third-party providers</li><li>Subscriptions renew automatically unless cancelled</li><li>Refunds are subject to our refund policy</li><li>Prices may change with 30 days notice</li>",
|
||||
],
|
||||
},
|
||||
termination: {
|
||||
title: "6. Termination",
|
||||
text: [
|
||||
"We reserve the right to terminate or suspend your account at any time for violations of these terms. You may also terminate your account at any time through your account settings.",
|
||||
],
|
||||
},
|
||||
},
|
||||
community: {
|
||||
title: "Community Guidelines",
|
||||
description: "Creating a safe and respectful environment for all",
|
||||
values: {
|
||||
title: "Our Community Values",
|
||||
text: [
|
||||
"SexyArt is built on respect, consent, and artistic expression. We believe in creating a space where creators and viewers can connect through shared appreciation for intimate art and storytelling.",
|
||||
],
|
||||
},
|
||||
respect: {
|
||||
title: "Respect and Consent",
|
||||
text: [
|
||||
"<li>All content must be created with full consent of all participants</li><li>Respect creators' boundaries and content preferences</li><li>No harassment, bullying, or discriminatory behavior</li><li>Respect privacy and do not share personal information</li>",
|
||||
],
|
||||
},
|
||||
standards: {
|
||||
title: "Content Standards",
|
||||
text: [
|
||||
"<li>Content should celebrate love, intimacy, and human connection</li><li>Artistic and creative expression is encouraged</li><li>Content must comply with all applicable laws</li><li>No violent, degrading, or harmful content</li>",
|
||||
],
|
||||
},
|
||||
interaction: {
|
||||
title: "Community Interaction",
|
||||
text: [
|
||||
"<li>Engage respectfully in comments and messages</li><li>Support creators through positive feedback</li><li>Report inappropriate content or behavior</li><li>Help maintain a welcoming environment for all</li>",
|
||||
],
|
||||
},
|
||||
enforcement: {
|
||||
title: "Enforcement",
|
||||
text: [
|
||||
"Violations of our community guidelines may result in content removal, account suspension, or permanent ban. We review all reports and take appropriate action to maintain a safe environment.",
|
||||
],
|
||||
},
|
||||
},
|
||||
cookie: {
|
||||
title: "Cookie Policy",
|
||||
description: "How we use cookies and similar technologies",
|
||||
what: {
|
||||
title: "What Are Cookies",
|
||||
text: [
|
||||
"Cookies are small text files that are stored on your device when you visit our website. They help us provide you with a better experience by remembering your preferences and improving our services.",
|
||||
],
|
||||
},
|
||||
types: {
|
||||
title: "Types of Cookies We Use",
|
||||
essential: {
|
||||
title: "Essential Cookies",
|
||||
text: [
|
||||
"These cookies are necessary for the website to function properly. They enable basic features like page navigation and access to secure areas.",
|
||||
],
|
||||
},
|
||||
},
|
||||
managing: {
|
||||
title: "Managing Cookies",
|
||||
subtitle: "You can control cookies through:",
|
||||
text: [
|
||||
"<li>Your browser settings</li><li>Third-party opt-out tools</li>",
|
||||
"Please note that disabling certain cookies may affect the functionality of our website.",
|
||||
],
|
||||
},
|
||||
third_party: {
|
||||
title: "Third-Party Cookies",
|
||||
text: [
|
||||
"We may use third-party services that set their own cookies. These include analytics providers, payment processors, and content delivery networks. Please refer to their respective privacy policies for more information.",
|
||||
],
|
||||
},
|
||||
},
|
||||
questions: "Questions About Our Legal Policies?",
|
||||
questions_description:
|
||||
"If you have any questions about our legal policies, please don't hesitate to contact us.",
|
||||
questions_email: "support@pivoine.art",
|
||||
},
|
||||
play: {
|
||||
title: "SexyPlay",
|
||||
description: "Bring your toys.",
|
||||
scan: "Start Scan",
|
||||
scanning: "Scanning...",
|
||||
no_results: "No Devices founds",
|
||||
},
|
||||
error: {
|
||||
not_found: "Oops! Page Not Found",
|
||||
common: "Oops! An Error Occured",
|
||||
description:
|
||||
"The page you're looking for seems to have vanished into the digital void. Don't worry, even in the world of love and art, sometimes we lose our way.",
|
||||
go_home: "Go Home",
|
||||
explore_videos: "Explore Videos",
|
||||
quick_links: "Or try one of these popular sections:",
|
||||
featured_models: "Featured Models",
|
||||
magazine: "Magazine",
|
||||
about_us: "About Us",
|
||||
},
|
||||
footer: {
|
||||
description:
|
||||
"The premier destination for artistic adult content, intimate storytelling, and creative expression through video and magazine content.",
|
||||
quick_links: "Quick Links",
|
||||
models: "Models",
|
||||
videos: "Videos",
|
||||
magazine: "Magazine",
|
||||
about: "About",
|
||||
support: "Support",
|
||||
contact_support: "Contact Support",
|
||||
contact_support_email: "support@pivoine.art",
|
||||
model_applications: "Model Applications",
|
||||
model_applications_email: "sexy@pivoine.art",
|
||||
contact: {
|
||||
email: "sexy@pivoine.art",
|
||||
x: "bordeaux1981",
|
||||
youtube: "lovesting",
|
||||
},
|
||||
faq: "FAQ",
|
||||
legal: "Legal",
|
||||
privacy_policy: "Privacy Policy",
|
||||
terms_of_service: "Terms of Service",
|
||||
imprint: "Imprint",
|
||||
copyright: "© 2025 Valknar. All rights reserved. | 18+ Content Warning",
|
||||
},
|
||||
sharing_popup: {
|
||||
title: "Share Content",
|
||||
description: "Choose how you'd like to share this {type}",
|
||||
subtitle: "Share your content",
|
||||
share: {
|
||||
x: "Share on X (Twitter)",
|
||||
facebook: "Share on Facebook",
|
||||
email: "Share via Email",
|
||||
whatsapp: "Share on WhatsApp",
|
||||
telegram: "Share on Telegram",
|
||||
copy: "Copy Link to Clipboard",
|
||||
},
|
||||
success: {
|
||||
x: "Opened X (Twitter) sharing window",
|
||||
facebook: "Opened Facebook sharing window",
|
||||
email: "Opened email client",
|
||||
whatsapp: "Opened WhatsApp sharing",
|
||||
telegram: "Opened Telegram sharing",
|
||||
copy: "Copied link to clipboard",
|
||||
},
|
||||
close: "Close",
|
||||
},
|
||||
age_verification_dialog: {
|
||||
title: "Age Verification",
|
||||
description:
|
||||
'By clicking "Confirm", you verify that you are 18 years or older.',
|
||||
age: "18+",
|
||||
confirm: "Confirm",
|
||||
exit: "Exit",
|
||||
exit_url: "https://pivoine.art",
|
||||
},
|
||||
newsletter_signup: {
|
||||
title: "Stay Updated",
|
||||
description:
|
||||
"Get the latest articles and insights delivered to your inbox.",
|
||||
email: "Email",
|
||||
email_placeholder: "your@email.com",
|
||||
cta: "Subscribe to Newsletter",
|
||||
close: "Close",
|
||||
subscribe: "Subscribe",
|
||||
subscribing: "Subscribing",
|
||||
toast_subscribe: "Your email has been added to the newsletter list!",
|
||||
},
|
||||
sharing_popup_button: {
|
||||
share: "Share",
|
||||
},
|
||||
image_viewer: {
|
||||
index: "Image {index} of {size}",
|
||||
previous: "Previous",
|
||||
next: "Next",
|
||||
close: "Close",
|
||||
download: "Download",
|
||||
},
|
||||
device_card: {
|
||||
active: "Active",
|
||||
paused: "Paused",
|
||||
current_value: "Current Value",
|
||||
battery: "Battery",
|
||||
last_seen: "Last seen",
|
||||
connect: "Connect",
|
||||
disconnect: "Disconnect",
|
||||
actuator_types: {
|
||||
unknown: "Unknown",
|
||||
vibrate: "Vibrate",
|
||||
rotate: "Rotate",
|
||||
oscillate: "Oscillate",
|
||||
constrict: "Constrict",
|
||||
inflate: "Inflate",
|
||||
position: "Position",
|
||||
},
|
||||
},
|
||||
head: {
|
||||
title: "SexyArt | {title}",
|
||||
},
|
||||
};
|
||||
402
packages/frontend/src/lib/services.ts
Normal file
402
packages/frontend/src/lib/services.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
import { getDirectusInstance } from "$lib/directus";
|
||||
import {
|
||||
readItems,
|
||||
registerUser,
|
||||
updateMe,
|
||||
readMe,
|
||||
registerUserVerify,
|
||||
readUsers,
|
||||
passwordRequest,
|
||||
passwordReset,
|
||||
customEndpoint,
|
||||
readFolders,
|
||||
deleteFile,
|
||||
uploadFiles,
|
||||
createComment,
|
||||
readComments,
|
||||
aggregate,
|
||||
} from "@directus/sdk";
|
||||
import type { Article, Model, Stats, User, Video } from "$lib/types";
|
||||
import { PUBLIC_URL || http://localhost:3000 } from "$env/static/public";
|
||||
|
||||
const userFields = [
|
||||
"*",
|
||||
{
|
||||
avatar: ["*"],
|
||||
policies: ["*", { policy: ["name", "id"] }],
|
||||
role: ["*", { policies: [{ policy: ["name", "id"] }] }],
|
||||
},
|
||||
];
|
||||
|
||||
export async function isAuthenticated(token: string) {
|
||||
try {
|
||||
const directus = getDirectusInstance(fetch);
|
||||
directus.setToken(token);
|
||||
const user = await directus.request(
|
||||
readMe({
|
||||
fields: userFields,
|
||||
}),
|
||||
);
|
||||
return { authenticated: true, user };
|
||||
} catch {
|
||||
return { authenticated: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function register(
|
||||
email: string,
|
||||
password: string,
|
||||
firstName: string,
|
||||
lastName: string,
|
||||
) {
|
||||
const directus = getDirectusInstance(fetch);
|
||||
return directus.request(
|
||||
registerUser(email, password, {
|
||||
verification_url: `${PUBLIC_URL || http://localhost:3000}/signup/verify`,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function verify(token: string, fetch?: typeof globalThis.fetch) {
|
||||
const directus = fetch
|
||||
? getDirectusInstance((args) => fetch(args, { redirect: "manual" }))
|
||||
: getDirectusInstance(fetch);
|
||||
return directus.request(registerUserVerify(token));
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string) {
|
||||
const directus = getDirectusInstance(fetch);
|
||||
return directus.login({ email, password });
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
const directus = getDirectusInstance(fetch);
|
||||
return directus.logout();
|
||||
}
|
||||
|
||||
export async function requestPassword(email: string) {
|
||||
const directus = getDirectusInstance(fetch);
|
||||
return directus.request(
|
||||
passwordRequest(email, `${PUBLIC_URL || http://localhost:3000}/password/reset`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function resetPassword(token: string, password: string) {
|
||||
const directus = getDirectusInstance(fetch);
|
||||
return directus.request(passwordReset(token, password));
|
||||
}
|
||||
|
||||
export async function getArticles(fetch?: typeof globalThis.fetch) {
|
||||
const directus = getDirectusInstance(fetch);
|
||||
return directus.request<Article[]>(
|
||||
readItems("sexy_articles", {
|
||||
fields: ["*", "author.*"],
|
||||
where: { publish_date: { _lte: new Date().toISOString() } },
|
||||
sort: ["-publish_date"],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getArticleBySlug(
|
||||
slug: string,
|
||||
fetch?: typeof globalThis.fetch,
|
||||
) {
|
||||
const directus = getDirectusInstance(fetch);
|
||||
return directus
|
||||
.request<Article[]>(
|
||||
readItems("sexy_articles", {
|
||||
fields: ["*", "author.*"],
|
||||
filter: { slug: { _eq: slug } },
|
||||
}),
|
||||
)
|
||||
.then((articles) => {
|
||||
if (articles.length === 0) {
|
||||
throw new Error("Article not found");
|
||||
}
|
||||
return articles[0];
|
||||
});
|
||||
}
|
||||
|
||||
export async function getVideos(fetch?: typeof globalThis.fetch) {
|
||||
const directus = getDirectusInstance(fetch);
|
||||
return directus
|
||||
.request<Video[]>(
|
||||
readItems("sexy_videos", {
|
||||
fields: [
|
||||
"*",
|
||||
{
|
||||
models: [
|
||||
"*",
|
||||
{
|
||||
directus_users_id: ["*"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"movie.*",
|
||||
],
|
||||
filter: { upload_date: { _lte: new Date().toISOString() } },
|
||||
sort: ["-upload_date"],
|
||||
}),
|
||||
)
|
||||
.then((videos) => {
|
||||
videos.forEach((video) => {
|
||||
video.models = video.models.map((u) => u.directus_users_id!);
|
||||
});
|
||||
return videos;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getVideosForModel(id, fetch?: typeof globalThis.fetch) {
|
||||
const directus = getDirectusInstance(fetch);
|
||||
return directus.request<Video[]>(
|
||||
readItems("sexy_videos", {
|
||||
fields: ["*", "movie.*"],
|
||||
filter: {
|
||||
models: {
|
||||
directus_users_id: {
|
||||
id,
|
||||
},
|
||||
},
|
||||
},
|
||||
sort: ["-upload_date"],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getFeaturedVideos(
|
||||
limit: number,
|
||||
fetch?: typeof globalThis.fetch,
|
||||
) {
|
||||
const directus = getDirectusInstance(fetch);
|
||||
return directus
|
||||
.request<Video[]>(
|
||||
readItems("sexy_videos", {
|
||||
fields: [
|
||||
"*",
|
||||
{
|
||||
models: [
|
||||
"*",
|
||||
{
|
||||
directus_users_id: ["*"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"movie.*",
|
||||
],
|
||||
filter: {
|
||||
upload_date: { _lte: new Date().toISOString() },
|
||||
featured: true,
|
||||
},
|
||||
sort: ["-upload_date"],
|
||||
limit,
|
||||
}),
|
||||
)
|
||||
.then((videos) => {
|
||||
videos.forEach((video) => {
|
||||
video.models = video.models.map((u) => u.directus_users_id!);
|
||||
});
|
||||
return videos;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getVideoBySlug(
|
||||
slug: string,
|
||||
fetch?: typeof globalThis.fetch,
|
||||
) {
|
||||
const directus = getDirectusInstance(fetch);
|
||||
return directus
|
||||
.request<Video[]>(
|
||||
readItems("sexy_videos", {
|
||||
fields: [
|
||||
"*",
|
||||
{
|
||||
models: [
|
||||
"*",
|
||||
{
|
||||
directus_users_id: ["*"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"movie.*",
|
||||
],
|
||||
filter: { slug },
|
||||
}),
|
||||
)
|
||||
.then((videos) => {
|
||||
if (videos.length === 0) {
|
||||
throw new Error("Video not found");
|
||||
}
|
||||
videos[0].models = videos[0].models.map((u) => u.directus_users_id!);
|
||||
|
||||
return videos[0];
|
||||
});
|
||||
}
|
||||
|
||||
const modelFilter = {
|
||||
_or: [
|
||||
{
|
||||
policies: {
|
||||
policy: {
|
||||
name: {
|
||||
_eq: "Model",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
role: {
|
||||
name: {
|
||||
_eq: "Model",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export async function getModels(fetch?: typeof globalThis.fetch) {
|
||||
const directus = getDirectusInstance(fetch);
|
||||
return directus.request<Model[]>(
|
||||
readUsers({
|
||||
fields: ["*"],
|
||||
filter: modelFilter,
|
||||
sort: ["-join_date"],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getFeaturedModels(
|
||||
limit = 3,
|
||||
fetch?: typeof globalThis.fetch,
|
||||
) {
|
||||
const directus = getDirectusInstance(fetch);
|
||||
return directus.request<Model[]>(
|
||||
readUsers({
|
||||
fields: ["*"],
|
||||
filter: { _and: [modelFilter, { featured: { _eq: true } }] },
|
||||
sort: ["-join_date"],
|
||||
limit,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getModelBySlug(
|
||||
slug: string,
|
||||
fetch?: typeof globalThis.fetch,
|
||||
) {
|
||||
const directus = getDirectusInstance(fetch);
|
||||
return directus
|
||||
.request<Model[]>(
|
||||
readUsers({
|
||||
fields: [
|
||||
"*",
|
||||
{
|
||||
photos: [
|
||||
"*",
|
||||
{
|
||||
directus_files_id: ["*"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"banner.*",
|
||||
],
|
||||
filter: { _and: [modelFilter, { slug: { _eq: slug } }] },
|
||||
}),
|
||||
)
|
||||
.then((models) => {
|
||||
if (models.length === 0) {
|
||||
throw new Error("Model not found");
|
||||
}
|
||||
models[0].photos = models[0].photos.map((p) => p.directus_files_id!);
|
||||
return models[0];
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateProfile(user: Partial<User>) {
|
||||
const directus = getDirectusInstance(fetch);
|
||||
return directus.request<User>(updateMe(user as never));
|
||||
}
|
||||
|
||||
export async function getStats(fetch?: typeof globalThis.fetch) {
|
||||
const directus = getDirectusInstance(fetch);
|
||||
return directus.request<Stats>(
|
||||
customEndpoint({
|
||||
path: "/sexy/stats",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getFolders(fetch?: typeof globalThis.fetch) {
|
||||
const directus = getDirectusInstance(fetch);
|
||||
return directus.request(readFolders());
|
||||
}
|
||||
|
||||
export async function removeFile(id: string) {
|
||||
const directus = getDirectusInstance(fetch);
|
||||
return directus.request(deleteFile(id));
|
||||
}
|
||||
|
||||
export async function uploadFile(data: FormData) {
|
||||
const directus = getDirectusInstance(fetch);
|
||||
return directus.request(uploadFiles(data));
|
||||
}
|
||||
|
||||
export async function createCommentForVideo(item: string, comment: string) {
|
||||
const directus = getDirectusInstance(fetch);
|
||||
return directus.request(
|
||||
createComment({
|
||||
collection: "sexy_videos",
|
||||
item,
|
||||
comment,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCommentsForVideo(
|
||||
item: string,
|
||||
fetch?: typeof globalThis.fetch,
|
||||
) {
|
||||
const directus = getDirectusInstance(fetch);
|
||||
return directus.request(
|
||||
readComments({
|
||||
fields: ["*", { user_created: ["*"] }],
|
||||
filter: { collection: "sexy_videos", item },
|
||||
sort: ["-date_created"],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function countCommentsForModel(
|
||||
user_created: string,
|
||||
fetch?: typeof globalThis.fetch,
|
||||
) {
|
||||
const directus = getDirectusInstance(fetch);
|
||||
return directus
|
||||
.request<[{ count: number }]>(
|
||||
aggregate("directus_comments", {
|
||||
aggregate: {
|
||||
count: "*",
|
||||
},
|
||||
query: {
|
||||
filter: { user_created },
|
||||
},
|
||||
}),
|
||||
)
|
||||
.then((result) => result[0].count);
|
||||
}
|
||||
|
||||
export async function getItemsByTag(
|
||||
category: "video" | "article" | "model",
|
||||
tag: string,
|
||||
fetch?: typeof globalThis.fetch,
|
||||
) {
|
||||
switch (category) {
|
||||
case "video":
|
||||
return getVideos(fetch);
|
||||
case "model":
|
||||
return getModels(fetch);
|
||||
case "article":
|
||||
return getArticles(fetch);
|
||||
}
|
||||
}
|
||||
124
packages/frontend/src/lib/types.ts
Normal file
124
packages/frontend/src/lib/types.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { 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;
|
||||
}
|
||||
|
||||
export interface CurrentUser extends User {
|
||||
avatar: File;
|
||||
role: {
|
||||
name: string;
|
||||
};
|
||||
policies: {
|
||||
policy: {
|
||||
name: string;
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface AuthStatus {
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string;
|
||||
comment: string;
|
||||
item: string;
|
||||
user_created: User;
|
||||
date_created: Date;
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
videos_count: number;
|
||||
models_count: number;
|
||||
viewers_count: number;
|
||||
}
|
||||
|
||||
export interface BluetoothDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
actuatorValues: number[];
|
||||
sensorValues: number[];
|
||||
batteryLevel: number;
|
||||
isConnected: boolean;
|
||||
lastSeen: Date;
|
||||
info: ButtplugClientDevice;
|
||||
}
|
||||
|
||||
export interface ShareContent {
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
type: "video" | "model" | "article" | "link";
|
||||
}
|
||||
44
packages/frontend/src/lib/utils.ts
Normal file
44
packages/frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { browser } from "$app/environment";
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
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 WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
||||
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & {
|
||||
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;
|
||||
};
|
||||
|
||||
export const getUserInitials = (name: string) => {
|
||||
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;
|
||||
|
||||
return `${hours < 10 ? "0" + hours : hours}:${minutes < 10 ? "0" + minutes : minutes}:${seconds < 10 ? "0" + seconds : seconds}`;
|
||||
};
|
||||
21
packages/frontend/src/lib/utils/utils.ts
Normal file
21
packages/frontend/src/lib/utils/utils.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
Installed from @ieedan/shadcn-svelte-extras
|
||||
*/
|
||||
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
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 WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
||||
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & {
|
||||
ref?: U | null;
|
||||
};
|
||||
123
packages/frontend/src/routes/+error.svelte
Normal file
123
packages/frontend/src/routes/+error.svelte
Normal file
@@ -0,0 +1,123 @@
|
||||
<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";
|
||||
</script>
|
||||
|
||||
<Meta
|
||||
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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
5
packages/frontend/src/routes/+layout.server.ts
Normal file
5
packages/frontend/src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export async function load({ locals }) {
|
||||
return {
|
||||
authStatus: locals.authStatus,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user