A new start

This commit is contained in:
Valknar XXX
2025-10-25 22:04:41 +02:00
commit be0fc11a5c
193 changed files with 25076 additions and 0 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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
>

View File

@@ -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}

View 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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)}&quote=${encodeURIComponent(content.title)}`;
window.open(url, "_blank", "width=600,height=400");
toast.success($_("sharing_popup.success.facebook"));
};
const shareViaEmail = () => {
const subject = encodeURIComponent(content.title);
const body = encodeURIComponent(`${content.description}\n\n${content.url}`);
const url = `mailto:?subject=${subject}&body=${body}`;
window.location.href = url;
toast.success($_("sharing_popup.success.email"));
};
const 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>

View File

@@ -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} />

View File

@@ -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>

View 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="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>

View File

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

View 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>

View File

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

View File

@@ -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}
/>

View File

@@ -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}
/>

View 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}
/>

View 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,
};

View 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}

View 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,
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-header"
class={cn(
"@container/card-header 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>

View File

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

View 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>

View 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,
};

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { Checkbox as CheckboxPrimitive } from "bits-ui";
import CheckIcon from "@lucide/svelte/icons/check";
import MinusIcon from "@lucide/svelte/icons/minus";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
...restProps
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
</script>
<CheckboxPrimitive.Root
bind:ref
data-slot="checkbox"
class={cn(
"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>

View File

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

View File

@@ -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} />

View File

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

View File

@@ -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}
/>

View File

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

View File

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

View File

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

View File

@@ -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}
/>

View File

@@ -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} />

View 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,
};

View File

@@ -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>

View File

@@ -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 };

View File

@@ -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">;

View File

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

View 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}

View File

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

View 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}
/>

View 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,
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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} />

View File

@@ -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>

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}
/>

View File

@@ -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>

View File

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

View File

@@ -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}
/>

View File

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

View 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>

View File

@@ -0,0 +1 @@
export { default as Toaster } from "./sonner.svelte";

View 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}
/>

View 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,
};

View File

@@ -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}
/>

View File

@@ -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}
/>

View File

@@ -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}
/>

View 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}
/>

View File

@@ -0,0 +1,9 @@
/*
Installed from @ieedan/shadcn-svelte-extras
*/
import TagsInput from "./tags-input.svelte";
export { TagsInput };
export type * from "./types";

View File

@@ -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>

View File

@@ -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>

View 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">;

View File

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

View File

@@ -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>