style: apply prettier formatting to all files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,77 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "$lib/components/ui/dialog";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Separator } from "$lib/components/ui/separator";
|
||||
import { onMount } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "$lib/components/ui/dialog";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Separator } from "$lib/components/ui/separator";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
const AGE_VERIFICATION_KEY = "age-verified";
|
||||
const AGE_VERIFICATION_KEY = "age-verified";
|
||||
|
||||
let isOpen = true;
|
||||
let isOpen = true;
|
||||
|
||||
function handleAgeConfirmation() {
|
||||
localStorage.setItem(AGE_VERIFICATION_KEY, "true");
|
||||
isOpen = false;
|
||||
}
|
||||
function handleAgeConfirmation() {
|
||||
localStorage.setItem(AGE_VERIFICATION_KEY, "true");
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const storedVerification = localStorage.getItem(AGE_VERIFICATION_KEY);
|
||||
if (storedVerification === "true") {
|
||||
isOpen = false;
|
||||
}
|
||||
});
|
||||
onMount(() => {
|
||||
const storedVerification = localStorage.getItem(AGE_VERIFICATION_KEY);
|
||||
if (storedVerification === "true") {
|
||||
isOpen = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog bind:open={isOpen}>
|
||||
<DialogContent
|
||||
class="sm:max-w-md"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogHeader class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 shrink-0 grow-0 rounded-full bg-gradient-to-br from-primary to-purple-600 flex items-center justify-center"
|
||||
>
|
||||
<span class="text-primary-foreground text-sm"
|
||||
>{$_("age_verification_dialog.age")}</span
|
||||
>
|
||||
</div>
|
||||
<div class="">
|
||||
<DialogTitle class="text-left text-xl font-semibold text-primary-foreground"
|
||||
>{$_("age_verification_dialog.title")}</DialogTitle
|
||||
>
|
||||
<DialogDescription class="text-left text-sm">
|
||||
{$_("age_verification_dialog.description")}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<Separator class="my-4" />
|
||||
|
||||
<!-- Close Button -->
|
||||
<div class="flex justify-end gap-4">
|
||||
<Button variant="destructive" href={$_("age_verification_dialog.exit_url")} size="sm">
|
||||
{$_("age_verification_dialog.exit")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onclick={handleAgeConfirmation}
|
||||
class="cursor-pointer"
|
||||
<DialogContent
|
||||
class="sm:max-w-md"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogHeader class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 shrink-0 grow-0 rounded-full bg-gradient-to-br from-primary to-purple-600 flex items-center justify-center"
|
||||
>
|
||||
<span class="text-primary-foreground text-sm">{$_("age_verification_dialog.age")}</span>
|
||||
</div>
|
||||
<div class="">
|
||||
<DialogTitle class="text-left text-xl font-semibold text-primary-foreground"
|
||||
>{$_("age_verification_dialog.title")}</DialogTitle
|
||||
>
|
||||
<span class="icon-[ri--check-line]"></span>
|
||||
{$_("age_verification_dialog.confirm")}
|
||||
</Button>
|
||||
<DialogDescription class="text-left text-sm">
|
||||
{$_("age_verification_dialog.description")}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<Separator class="my-4" />
|
||||
|
||||
<!-- Close Button -->
|
||||
<div class="flex justify-end gap-4">
|
||||
<Button variant="destructive" href={$_("age_verification_dialog.exit_url")} size="sm">
|
||||
{$_("age_verification_dialog.exit")}
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onclick={handleAgeConfirmation} class="cursor-pointer">
|
||||
<span class="icon-[ri--check-line]"></span>
|
||||
{$_("age_verification_dialog.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
<!-- Advanced Plasma Background -->
|
||||
<div class="absolute inset-0 pointer-events-none">
|
||||
<!-- Primary gradient layers -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-primary/6 via-accent/10 to-primary/4 opacity-60"
|
||||
></div>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-tl from-accent/4 via-primary/8 to-accent/6 opacity-40"
|
||||
></div>
|
||||
<!-- Primary gradient layers -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-primary/6 via-accent/10 to-primary/4 opacity-60"
|
||||
></div>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-tl from-accent/4 via-primary/8 to-accent/6 opacity-40"
|
||||
></div>
|
||||
|
||||
<!-- Large floating orbs -->
|
||||
<!-- <div
|
||||
<!-- Large floating orbs -->
|
||||
<!-- <div
|
||||
class="absolute top-20 left-20 w-80 h-80 bg-gradient-to-br from-primary/12 via-accent/18 to-primary/8 rounded-full blur-3xl animate-blob-slow"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-20 right-20 w-96 h-96 bg-gradient-to-tl from-accent/12 via-primary/18 to-accent/8 rounded-full blur-3xl animate-blob-slow animation-delay-6000"
|
||||
></div> -->
|
||||
|
||||
<!-- Medium morphing elements -->
|
||||
<!-- <div
|
||||
<!-- Medium morphing elements -->
|
||||
<!-- <div
|
||||
class="absolute top-1/2 left-1/3 w-64 h-64 bg-gradient-to-r from-primary/10 via-accent/15 to-primary/8 rounded-full blur-2xl animate-blob-reverse animation-delay-3000"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-1/3 right-1/3 w-72 h-72 bg-gradient-to-l from-accent/10 via-primary/15 to-accent/8 rounded-full blur-2xl animate-blob-reverse animation-delay-9000"
|
||||
></div> -->
|
||||
|
||||
<!-- Soft particle effects -->
|
||||
<!-- <div
|
||||
<!-- Soft particle effects -->
|
||||
<!-- <div
|
||||
class="absolute top-1/4 right-1/4 w-48 h-48 bg-gradient-to-br from-primary/15 to-accent/12 rounded-full blur-xl animate-float animation-delay-2000"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-1/4 left-1/4 w-56 h-56 bg-gradient-to-tl from-accent/15 to-primary/12 rounded-full blur-xl animate-float animation-delay-8000"
|
||||
></div> -->
|
||||
|
||||
<!-- Premium glassmorphism overlay -->
|
||||
<!-- <div
|
||||
<!-- Premium glassmorphism overlay -->
|
||||
<!-- <div
|
||||
class="absolute inset-0 bg-gradient-to-br from-primary/2 via-transparent to-accent/3 backdrop-blur-[1px]"
|
||||
></div> -->
|
||||
|
||||
<!-- Animated Plasma Background -->
|
||||
<div
|
||||
class="absolute top-1/3 left-1/3 w-72 h-72 bg-gradient-to-r from-accent/20 via-primary/25 to-accent/15 rounded-full blur-2xl animate-blob"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-1/3 right-1/3 w-88 h-88 bg-gradient-to-r from-primary/20 via-accent/25 to-primary/15 rounded-full blur-3xl animate-blob-reverse animation-delay-3000"
|
||||
></div>
|
||||
<div
|
||||
class="absolute top-1/2 right-1/4 w-64 h-64 bg-gradient-to-r from-accent/15 via-primary/20 to-accent/10 rounded-full blur-2xl animate-float animation-delay-1000"
|
||||
></div>
|
||||
<!-- Animated Plasma Background -->
|
||||
<div
|
||||
class="absolute top-1/3 left-1/3 w-72 h-72 bg-gradient-to-r from-accent/20 via-primary/25 to-accent/15 rounded-full blur-2xl animate-blob"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-1/3 right-1/3 w-88 h-88 bg-gradient-to-r from-primary/20 via-accent/25 to-primary/15 rounded-full blur-3xl animate-blob-reverse animation-delay-3000"
|
||||
></div>
|
||||
<div
|
||||
class="absolute top-1/2 right-1/4 w-64 h-64 bg-gradient-to-r from-accent/15 via-primary/20 to-accent/10 rounded-full blur-2xl animate-float animation-delay-1000"
|
||||
></div>
|
||||
|
||||
<!-- Global Plasma Background -->
|
||||
<!-- <div
|
||||
<!-- Global Plasma Background -->
|
||||
<!-- <div
|
||||
class="absolute top-32 right-32 w-72 h-72 bg-gradient-to-r from-accent/18 via-primary/22 to-accent/12 rounded-full blur-3xl animate-blob"
|
||||
></div>
|
||||
<div
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
<script lang="ts">
|
||||
const { isMobileMenuOpen = $bindable(), label, onclick } = $props();
|
||||
const { isMobileMenuOpen = $bindable(), label, onclick } = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="block rounded-full cursor-pointer"
|
||||
onclick={onclick}
|
||||
aria-label={label}
|
||||
>
|
||||
<button class="block rounded-full cursor-pointer" {onclick} aria-label={label}>
|
||||
<div
|
||||
class="relative flex overflow-hidden items-center justify-center rounded-full w-[50px] h-[50px] transform transition-all duration-200 shadow-md opacity-90 translate-x-3"
|
||||
>
|
||||
@@ -14,23 +10,23 @@ const { isMobileMenuOpen = $bindable(), label, onclick } = $props();
|
||||
class="flex flex-col justify-between w-[16px] h-[10px] transform transition-all duration-300 origin-center overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class={`bg-white h-[2px] w-7 transform transition-all duration-300 origin-left ${isMobileMenuOpen ? 'translate-x-10' : ''}`}
|
||||
class={`bg-white h-[2px] w-7 transform transition-all duration-300 origin-left ${isMobileMenuOpen ? "translate-x-10" : ""}`}
|
||||
></div>
|
||||
<div
|
||||
class={`bg-white h-[2px] w-7 rounded transform transition-all duration-300 delay-75 ${isMobileMenuOpen ? 'translate-x-10' : ''}`}
|
||||
class={`bg-white h-[2px] w-7 rounded transform transition-all duration-300 delay-75 ${isMobileMenuOpen ? "translate-x-10" : ""}`}
|
||||
></div>
|
||||
<div
|
||||
class={`bg-white h-[2px] w-7 transform transition-all duration-300 origin-left delay-150 ${isMobileMenuOpen ? 'translate-x-10' : ''}`}
|
||||
class={`bg-white h-[2px] w-7 transform transition-all duration-300 origin-left delay-150 ${isMobileMenuOpen ? "translate-x-10" : ""}`}
|
||||
></div>
|
||||
|
||||
<div
|
||||
class={`absolute items-center justify-between transform transition-all duration-500 top-6.5 -translate-x-10 flex w-0 ${isMobileMenuOpen ? 'translate-x-0 w-12' : ''}`}
|
||||
class={`absolute items-center justify-between transform transition-all duration-500 top-6.5 -translate-x-10 flex w-0 ${isMobileMenuOpen ? "translate-x-0 w-12" : ""}`}
|
||||
>
|
||||
<div
|
||||
class={`absolute bg-white h-[2px] w-4 transform transition-all duration-500 rotate-0 delay-300 ${isMobileMenuOpen ? 'rotate-45' : ''}`}
|
||||
class={`absolute bg-white h-[2px] w-4 transform transition-all duration-500 rotate-0 delay-300 ${isMobileMenuOpen ? "rotate-45" : ""}`}
|
||||
></div>
|
||||
<div
|
||||
class={`absolute bg-white h-[2px] w-4 transform transition-all duration-500 -rotate-0 delay-300 ${isMobileMenuOpen ? '-rotate-45' : ''}`}
|
||||
class={`absolute bg-white h-[2px] w-4 transform transition-all duration-500 -rotate-0 delay-300 ${isMobileMenuOpen ? "-rotate-45" : ""}`}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,99 +1,92 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils";
|
||||
import { Slider } from "$lib/components/ui/slider";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
|
||||
import type { BluetoothDevice } from "$lib/types";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { cn } from "$lib/utils";
|
||||
import { Slider } from "$lib/components/ui/slider";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
|
||||
import type { BluetoothDevice } from "$lib/types";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
interface Props {
|
||||
device: BluetoothDevice;
|
||||
onChange: (scalarIndex: number, val: number) => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
interface Props {
|
||||
device: BluetoothDevice;
|
||||
onChange: (scalarIndex: number, val: number) => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
let { device, onChange, onStop }: Props = $props();
|
||||
let { device, onChange, onStop }: Props = $props();
|
||||
|
||||
function getBatteryColor(level: number) {
|
||||
if (!device.hasBattery) {
|
||||
return "text-gray-400";
|
||||
}
|
||||
if (level > 60) return "text-green-400";
|
||||
if (level > 30) return "text-yellow-400";
|
||||
return "text-red-400";
|
||||
}
|
||||
function getBatteryColor(level: number) {
|
||||
if (!device.hasBattery) {
|
||||
return "text-gray-400";
|
||||
}
|
||||
if (level > 60) return "text-green-400";
|
||||
if (level > 30) return "text-yellow-400";
|
||||
return "text-red-400";
|
||||
}
|
||||
|
||||
function getBatteryBgColor(level: number) {
|
||||
if (!device.hasBattery) {
|
||||
return "bg-gray-400/20";
|
||||
}
|
||||
if (level > 60) return "bg-green-400/20";
|
||||
if (level > 30) return "bg-yellow-400/20";
|
||||
return "bg-red-400/20";
|
||||
}
|
||||
function getBatteryBgColor(level: number) {
|
||||
if (!device.hasBattery) {
|
||||
return "bg-gray-400/20";
|
||||
}
|
||||
if (level > 60) return "bg-green-400/20";
|
||||
if (level > 30) return "bg-yellow-400/20";
|
||||
return "bg-red-400/20";
|
||||
}
|
||||
|
||||
function getScalarAnimations() {
|
||||
return device.actuators
|
||||
.filter((a) => a.value > 0)
|
||||
.map((a) => `animate-${a.outputType.toLowerCase()}`);
|
||||
}
|
||||
function getScalarAnimations() {
|
||||
return device.actuators
|
||||
.filter((a) => a.value > 0)
|
||||
.map((a) => `animate-${a.outputType.toLowerCase()}`);
|
||||
}
|
||||
|
||||
function isActive() {
|
||||
return device.actuators.some((a) => a.value > 0);
|
||||
}
|
||||
function isActive() {
|
||||
return device.actuators.some((a) => a.value > 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card
|
||||
class="group hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 border-border/50 hover:border-primary/30 bg-card/50 backdrop-blur-sm"
|
||||
class="group hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 border-border/50 hover:border-primary/30 bg-card/50 backdrop-blur-sm"
|
||||
>
|
||||
<CardHeader class="pb-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="p-2 rounded-lg bg-gradient-to-br from-primary/20 to-accent/20 border border-primary/30 flex shrink-0 grow-0"
|
||||
>
|
||||
<span class={cn([...getScalarAnimations(), "icon-[ri--rocket-line] w-5 h-5 text-primary"])}></span>
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
class="font-semibold text-card-foreground group-hover:text-primary transition-colors"
|
||||
>
|
||||
{device.name}
|
||||
</h3>
|
||||
<!-- <p class="text-sm text-muted-foreground">
|
||||
<CardHeader class="pb-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="p-2 rounded-lg bg-gradient-to-br from-primary/20 to-accent/20 border border-primary/30 flex shrink-0 grow-0"
|
||||
>
|
||||
<span
|
||||
class={cn([...getScalarAnimations(), "icon-[ri--rocket-line] w-5 h-5 text-primary"])}
|
||||
></span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-card-foreground group-hover:text-primary transition-colors">
|
||||
{device.name}
|
||||
</h3>
|
||||
<!-- <p class="text-sm text-muted-foreground">
|
||||
{device.deviceType}
|
||||
</p> -->
|
||||
</div>
|
||||
</div>
|
||||
<button class={`${isActive() ? "cursor-pointer" : ""} flex items-center gap-2`} onclick={() => isActive() && onStop()}>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full {isActive()
|
||||
? 'bg-green-400'
|
||||
: 'bg-red-400'}"
|
||||
></div>
|
||||
{#if isActive()}
|
||||
<div
|
||||
class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-75"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
<span
|
||||
class="text-xs font-medium {isActive()
|
||||
? 'text-green-400'
|
||||
: 'text-red-400'}"
|
||||
>
|
||||
{isActive()
|
||||
? $_("device_card.active")
|
||||
: $_("device_card.paused")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</div>
|
||||
<button
|
||||
class={`${isActive() ? "cursor-pointer" : ""} flex items-center gap-2`}
|
||||
onclick={() => isActive() && onStop()}
|
||||
>
|
||||
<div class="relative">
|
||||
<div class="w-2 h-2 rounded-full {isActive() ? 'bg-green-400' : 'bg-red-400'}"></div>
|
||||
{#if isActive()}
|
||||
<div
|
||||
class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-75"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-xs font-medium {isActive() ? 'text-green-400' : 'text-red-400'}">
|
||||
{isActive() ? $_("device_card.active") : $_("device_card.paused")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4">
|
||||
<!-- Current Value -->
|
||||
<!-- <div
|
||||
<CardContent class="space-y-4">
|
||||
<!-- Current Value -->
|
||||
<!-- <div
|
||||
class="flex items-center justify-between p-3 rounded-lg bg-muted/30 border border-border/30"
|
||||
>
|
||||
<span class="text-sm text-muted-foreground"
|
||||
@@ -103,58 +96,54 @@ function isActive() {
|
||||
>
|
||||
</div> -->
|
||||
|
||||
<!-- Battery Level -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="icon-[ri--battery-2-charge-line] w-4 h-4 {getBatteryColor(
|
||||
device.batteryLevel,
|
||||
)}"
|
||||
></span>
|
||||
<span class="text-sm text-muted-foreground">{$_("device_card.battery")}</span>
|
||||
</div>
|
||||
{#if device.hasBattery}
|
||||
<span class="text-sm font-medium {getBatteryColor(device.batteryLevel)}">
|
||||
{device.batteryLevel}%
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="w-full bg-muted/50 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500 {getBatteryBgColor(
|
||||
device.batteryLevel,
|
||||
)} bg-gradient-to-r from-current to-current/80"
|
||||
style="width: {device.batteryLevel}%"
|
||||
></div>
|
||||
</div>
|
||||
<!-- Battery Level -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="icon-[ri--battery-2-charge-line] w-4 h-4 {getBatteryColor(device.batteryLevel)}"
|
||||
></span>
|
||||
<span class="text-sm text-muted-foreground">{$_("device_card.battery")}</span>
|
||||
</div>
|
||||
{#if device.hasBattery}
|
||||
<span class="text-sm font-medium {getBatteryColor(device.batteryLevel)}">
|
||||
{device.batteryLevel}%
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="w-full bg-muted/50 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500 {getBatteryBgColor(
|
||||
device.batteryLevel,
|
||||
)} bg-gradient-to-r from-current to-current/80"
|
||||
style="width: {device.batteryLevel}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Last Seen -->
|
||||
<!-- <div
|
||||
<!-- Last Seen -->
|
||||
<!-- <div
|
||||
class="flex items-center justify-between text-xs text-muted-foreground"
|
||||
>
|
||||
<span>{$_("device_card.last_seen")}</span>
|
||||
<span>{device.lastSeen.toLocaleTimeString()}</span>
|
||||
</div> -->
|
||||
|
||||
<!-- Action Button -->
|
||||
{#each device.actuators as actuator, idx (idx)}
|
||||
<div class="space-y-2">
|
||||
<Label for={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}
|
||||
>{$_(
|
||||
`device_card.actuator_types.${actuator.outputType.toLowerCase()}`,
|
||||
)}</Label
|
||||
>
|
||||
<Slider
|
||||
id={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}
|
||||
type="single"
|
||||
value={actuator.value}
|
||||
onValueChange={(val) => onChange(idx, val)}
|
||||
max={actuator.maxSteps}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</CardContent>
|
||||
<!-- Action Button -->
|
||||
{#each device.actuators as actuator, idx (idx)}
|
||||
<div class="space-y-2">
|
||||
<Label for={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}
|
||||
>{$_(`device_card.actuator_types.${actuator.outputType.toLowerCase()}`)}</Label
|
||||
>
|
||||
<Slider
|
||||
id={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}
|
||||
type="single"
|
||||
value={actuator.value}
|
||||
onValueChange={(val) => onChange(idx, val)}
|
||||
max={actuator.maxSteps}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,120 +1,120 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import Logo from "../logo/logo.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import Logo from "../logo/logo.svelte";
|
||||
</script>
|
||||
|
||||
<footer
|
||||
class="bg-gradient-to-t from-card/95 to-card/85 backdrop-blur-xl mt-20 shadow-2xl shadow-primary/10"
|
||||
class="bg-gradient-to-t from-card/95 to-card/85 backdrop-blur-xl mt-20 shadow-2xl shadow-primary/10"
|
||||
>
|
||||
<div class="container mx-auto px-4 py-12">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<!-- Brand -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3 text-xl font-bold">
|
||||
<Logo />
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">{$_("brand.description")}</p>
|
||||
<div class="flex gap-3">
|
||||
<a
|
||||
aria-label="Email"
|
||||
href="mailto:{$_('footer.contact.email')}"
|
||||
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<span class="icon-[ri--mail-line] w-4 h-4 text-primary"></span>
|
||||
</a>
|
||||
<a
|
||||
aria-label="X"
|
||||
href="https://www.x.com/{$_('footer.contact.x')}"
|
||||
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<span class="icon-[ri--twitter-x-line] w-4 h-4 text-primary"></span>
|
||||
</a>
|
||||
<a
|
||||
aria-label="YouTube"
|
||||
href="https://www.youtube.com/@{$_('footer.contact.youtube')}"
|
||||
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<span class="icon-[ri--youtube-line] w-4 h-4 text-primary"></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Links -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-semibold text-foreground">
|
||||
{$_("footer.quick_links")}
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<a
|
||||
href="/models"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.models")}</a
|
||||
>
|
||||
<a
|
||||
href="/videos"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.videos")}</a
|
||||
>
|
||||
<a
|
||||
href="/magazine"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.magazine")}</a
|
||||
>
|
||||
<a
|
||||
href="/about"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.about")}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Support -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-semibold text-foreground">{$_("footer.support")}</h3>
|
||||
<div class="space-y-2">
|
||||
<a
|
||||
href="mailto:{$_('footer.contact_support_email')}"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.contact_support")}</a
|
||||
>
|
||||
<a
|
||||
href="mailto:{$_('footer.model_applications_email')}"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.model_applications")}</a
|
||||
>
|
||||
<a
|
||||
href="/faq"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.faq")}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legal -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-semibold text-foreground">{$_("footer.legal")}</h3>
|
||||
<div class="space-y-2">
|
||||
<a
|
||||
href="/legal"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.privacy_policy")}</a
|
||||
>
|
||||
<a
|
||||
href="/legal"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.terms_of_service")}</a
|
||||
>
|
||||
<a
|
||||
href="/imprint"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.imprint")}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container mx-auto px-4 py-12">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<!-- Brand -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3 text-xl font-bold">
|
||||
<Logo />
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border/50 mt-8 pt-8 text-center">
|
||||
<p class="text-sm text-muted-foreground">{$_("footer.copyright")}</p>
|
||||
<p class="text-sm text-muted-foreground">{$_("brand.description")}</p>
|
||||
<div class="flex gap-3">
|
||||
<a
|
||||
aria-label="Email"
|
||||
href="mailto:{$_('footer.contact.email')}"
|
||||
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<span class="icon-[ri--mail-line] w-4 h-4 text-primary"></span>
|
||||
</a>
|
||||
<a
|
||||
aria-label="X"
|
||||
href="https://www.x.com/{$_('footer.contact.x')}"
|
||||
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<span class="icon-[ri--twitter-x-line] w-4 h-4 text-primary"></span>
|
||||
</a>
|
||||
<a
|
||||
aria-label="YouTube"
|
||||
href="https://www.youtube.com/@{$_('footer.contact.youtube')}"
|
||||
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<span class="icon-[ri--youtube-line] w-4 h-4 text-primary"></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Links -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-semibold text-foreground">
|
||||
{$_("footer.quick_links")}
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<a
|
||||
href="/models"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.models")}</a
|
||||
>
|
||||
<a
|
||||
href="/videos"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.videos")}</a
|
||||
>
|
||||
<a
|
||||
href="/magazine"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.magazine")}</a
|
||||
>
|
||||
<a
|
||||
href="/about"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.about")}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Support -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-semibold text-foreground">{$_("footer.support")}</h3>
|
||||
<div class="space-y-2">
|
||||
<a
|
||||
href="mailto:{$_('footer.contact_support_email')}"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.contact_support")}</a
|
||||
>
|
||||
<a
|
||||
href="mailto:{$_('footer.model_applications_email')}"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.model_applications")}</a
|
||||
>
|
||||
<a
|
||||
href="/faq"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.faq")}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legal -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-semibold text-foreground">{$_("footer.legal")}</h3>
|
||||
<div class="space-y-2">
|
||||
<a
|
||||
href="/legal"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.privacy_policy")}</a
|
||||
>
|
||||
<a
|
||||
href="/legal"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.terms_of_service")}</a
|
||||
>
|
||||
<a
|
||||
href="/imprint"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.imprint")}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border/50 mt-8 pt-8 text-center">
|
||||
<p class="text-sm text-muted-foreground">{$_("footer.copyright")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -7,9 +7,7 @@
|
||||
stroke="#ce47eb"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<metadata>
|
||||
Created by potrace 1.15, written by Peter Selinger 2001-2017
|
||||
</metadata>
|
||||
<metadata> Created by potrace 1.15, written by Peter Selinger 2001-2017 </metadata>
|
||||
<g transform="translate(0.000000,904.000000) scale(0.100000,-0.100000)">
|
||||
<path
|
||||
d="M7930 7043 c-73 -10 -95 -18 -134 -51 -25 -20 -66 -53 -91 -72 -26
|
||||
@@ -117,4 +115,4 @@ m-3487 -790 c-17 -35 -55 -110 -84 -168 -29 -58 -72 -163 -96 -235 -45 -134
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import { page } from "$app/state";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import type { AuthStatus } from "$lib/types";
|
||||
import { logout } from "$lib/services";
|
||||
import { goto } from "$app/navigation";
|
||||
import { getAssetUrl } from "$lib/directus";
|
||||
import LogoutButton from "../logout-button/logout-button.svelte";
|
||||
import Separator from "../ui/separator/separator.svelte";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
|
||||
import { getUserInitials } from "$lib/utils";
|
||||
import BurgerMenuButton from "../burger-menu-button/burger-menu-button.svelte";
|
||||
import Girls from "../girls/girls.svelte";
|
||||
import Logo from "../logo/logo.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { page } from "$app/state";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import type { AuthStatus } from "$lib/types";
|
||||
import { logout } from "$lib/services";
|
||||
import { goto } from "$app/navigation";
|
||||
import { getAssetUrl } from "$lib/directus";
|
||||
import LogoutButton from "../logout-button/logout-button.svelte";
|
||||
import Separator from "../ui/separator/separator.svelte";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
|
||||
import { getUserInitials } from "$lib/utils";
|
||||
import BurgerMenuButton from "../burger-menu-button/burger-menu-button.svelte";
|
||||
import Girls from "../girls/girls.svelte";
|
||||
import Logo from "../logo/logo.svelte";
|
||||
|
||||
interface Props {
|
||||
authStatus: AuthStatus;
|
||||
}
|
||||
interface Props {
|
||||
authStatus: AuthStatus;
|
||||
}
|
||||
|
||||
let { authStatus }: Props = $props();
|
||||
let { authStatus }: Props = $props();
|
||||
|
||||
let isMobileMenuOpen = $state(false);
|
||||
let isMobileMenuOpen = $state(false);
|
||||
|
||||
const navLinks = [
|
||||
{ name: $_("header.home"), href: "/" },
|
||||
{ name: $_("header.models"), href: "/models" },
|
||||
{ name: $_("header.videos"), href: "/videos" },
|
||||
{ name: $_("header.magazine"), href: "/magazine" },
|
||||
{ name: $_("header.about"), href: "/about" },
|
||||
];
|
||||
const navLinks = [
|
||||
{ name: $_("header.home"), href: "/" },
|
||||
{ name: $_("header.models"), href: "/models" },
|
||||
{ name: $_("header.videos"), href: "/videos" },
|
||||
{ name: $_("header.magazine"), href: "/magazine" },
|
||||
{ name: $_("header.about"), href: "/about" },
|
||||
];
|
||||
|
||||
async function handleLogout() {
|
||||
closeMenu();
|
||||
await logout();
|
||||
goto("/login", { invalidateAll: true });
|
||||
}
|
||||
async function handleLogout() {
|
||||
closeMenu();
|
||||
await logout();
|
||||
goto("/login", { invalidateAll: true });
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
isMobileMenuOpen = false;
|
||||
}
|
||||
function closeMenu() {
|
||||
isMobileMenuOpen = false;
|
||||
}
|
||||
|
||||
function isActiveLink(link: any) {
|
||||
return (
|
||||
(page.url.pathname === "/" && link === navLinks[0]) ||
|
||||
(page.url.pathname.startsWith(link.href) && link !== navLinks[0])
|
||||
);
|
||||
}
|
||||
function isActiveLink(link: any) {
|
||||
return (
|
||||
(page.url.pathname === "/" && link === navLinks[0]) ||
|
||||
(page.url.pathname.startsWith(link.href) && link !== navLinks[0])
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<header
|
||||
@@ -58,7 +58,7 @@ function isActiveLink(link: any) {
|
||||
href="/"
|
||||
class="flex w-full items-center gap-3 hover:scale-105 transition-all duration-300"
|
||||
>
|
||||
<Logo hideName={true} />
|
||||
<Logo hideName={true} />
|
||||
</a>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
@@ -67,12 +67,12 @@ function isActiveLink(link: any) {
|
||||
<a
|
||||
href={link.href}
|
||||
class={`text-sm hover:text-foreground transition-colors duration-200 font-medium relative group ${
|
||||
isActiveLink(link) ? 'text-foreground' : 'text-foreground/85'
|
||||
isActiveLink(link) ? "text-foreground" : "text-foreground/85"
|
||||
}`}
|
||||
>
|
||||
{link.name}
|
||||
<span
|
||||
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink(link) ? 'w-full' : 'group-hover:w-full'}`}
|
||||
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink(link) ? "w-full" : "group-hover:w-full"}`}
|
||||
></span>
|
||||
</a>
|
||||
{/each}
|
||||
@@ -95,29 +95,29 @@ function isActiveLink(link: any) {
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
class={`hidden sm:flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: '/me' }) ? 'text-foreground' : 'hover:text-foreground'}`}
|
||||
class={`hidden sm:flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/me" }) ? "text-foreground" : "hover:text-foreground"}`}
|
||||
href="/me"
|
||||
title={$_('header.dashboard')}
|
||||
title={$_("header.dashboard")}
|
||||
>
|
||||
<span class="icon-[ri--dashboard-2-line] h-4 w-4"></span>
|
||||
<span
|
||||
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: '/me' }) ? 'w-full' : 'group-hover:w-full'}`}
|
||||
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: "/me" }) ? "w-full" : "group-hover:w-full"}`}
|
||||
></span>
|
||||
<span class="sr-only">{$_('header.dashboard')}</span>
|
||||
<span class="sr-only">{$_("header.dashboard")}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
class={`hidden sm:flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: '/play' }) ? 'text-foreground' : 'hover:text-foreground'}`}
|
||||
class={`hidden sm:flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/play" }) ? "text-foreground" : "hover:text-foreground"}`}
|
||||
href="/play"
|
||||
title={$_('header.play')}
|
||||
title={$_("header.play")}
|
||||
>
|
||||
<span class="icon-[ri--rocket-line] h-4 w-4"></span>
|
||||
<span
|
||||
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: '/play' }) ? 'w-full' : 'group-hover:w-full'}`}
|
||||
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: "/play" }) ? "w-full" : "group-hover:w-full"}`}
|
||||
></span>
|
||||
<span class="sr-only">{$_('header.play')}</span>
|
||||
<span class="sr-only">{$_("header.play")}</span>
|
||||
</Button>
|
||||
|
||||
<Separator orientation="vertical" class="hidden md:flex mx-1 h-6 bg-border/50" />
|
||||
@@ -126,9 +126,10 @@ function isActiveLink(link: any) {
|
||||
|
||||
<LogoutButton
|
||||
user={{
|
||||
name: authStatus.user!.artist_name || authStatus.user!.email.split('@')[0] || 'User',
|
||||
avatar: getAssetUrl(authStatus.user!.avatar?.id, 'mini')!,
|
||||
email: authStatus.user!.email
|
||||
name:
|
||||
authStatus.user!.artist_name || authStatus.user!.email.split("@")[0] || "User",
|
||||
avatar: getAssetUrl(authStatus.user!.avatar?.id, "mini")!,
|
||||
email: authStatus.user!.email,
|
||||
}}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
@@ -136,18 +137,16 @@ function isActiveLink(link: any) {
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex w-full items-center justify-end gap-4">
|
||||
<Button variant="outline" class="font-medium" href="/login"
|
||||
>{$_('header.login')}</Button
|
||||
>
|
||||
<Button variant="outline" class="font-medium" href="/login">{$_("header.login")}</Button>
|
||||
<Button
|
||||
href="/signup"
|
||||
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 font-medium"
|
||||
>{$_('header.signup')}</Button
|
||||
>{$_("header.signup")}</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
<BurgerMenuButton
|
||||
label={$_('header.navigation')}
|
||||
label={$_("header.navigation")}
|
||||
bind:isMobileMenuOpen
|
||||
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
|
||||
/>
|
||||
@@ -155,26 +154,24 @@ function isActiveLink(link: any) {
|
||||
</div>
|
||||
<!-- Mobile Navigation -->
|
||||
<div
|
||||
class={`border-t border-border/20 bg-background/95 bg-gradient-to-br from-primary to-accent backdrop-blur-xl max-h-[calc(100vh-4rem)] overflow-y-auto shadow-xl/30 transition-all duration-250 ${isMobileMenuOpen ? 'opacity-100' : 'opacity-0'}`}
|
||||
class={`border-t border-border/20 bg-background/95 bg-gradient-to-br from-primary to-accent backdrop-blur-xl max-h-[calc(100vh-4rem)] overflow-y-auto shadow-xl/30 transition-all duration-250 ${isMobileMenuOpen ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
{#if isMobileMenuOpen}
|
||||
<div class="container mx-auto grid grid-cols-1 lg:grid-cols-3">
|
||||
<div class="hidden lg:flex col-span-2">
|
||||
<Girls />
|
||||
</div>
|
||||
<div class="py-6 px-4 space-y-6 lg:col-start-3 border-t border-border/20 bg-background/95 ">
|
||||
<div class="py-6 px-4 space-y-6 lg:col-start-3 border-t border-border/20 bg-background/95">
|
||||
<!-- User Profile Card -->
|
||||
{#if authStatus.authenticated}
|
||||
<div
|
||||
class="relative overflow-hidden rounded-2xl border border-border/50 bg-gradient-to-br from-card to-card/50 p-4 backdrop-blur-sm"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-primary/5 to-accent/5"
|
||||
></div>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-primary/5 to-accent/5"></div>
|
||||
<div class="relative flex items-center gap-4">
|
||||
<Avatar class="h-14 w-14 ring-2 ring-primary/30">
|
||||
<AvatarImage
|
||||
src={getAssetUrl(authStatus.user!.avatar?.id, 'mini')}
|
||||
src={getAssetUrl(authStatus.user!.avatar?.id, "mini")}
|
||||
alt={authStatus.user!.artist_name}
|
||||
/>
|
||||
<AvatarFallback
|
||||
@@ -212,17 +209,15 @@ function isActiveLink(link: any) {
|
||||
{/if}
|
||||
<!-- Navigation Cards -->
|
||||
<div class="space-y-3">
|
||||
<h3
|
||||
class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider"
|
||||
>
|
||||
{$_('header.navigation')}
|
||||
<h3 class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{$_("header.navigation")}
|
||||
</h3>
|
||||
<div class="grid gap-2">
|
||||
{#each navLinks as link (link.href)}
|
||||
<a
|
||||
href={link.href}
|
||||
class="flex items-center justify-between rounded-xl border border-border/50 bg-card/50 p-4 backdrop-blur-sm transition-all hover:bg-card hover:border-primary/20 {isActiveLink(
|
||||
link
|
||||
link,
|
||||
)
|
||||
? 'border-primary/30 bg-primary/5'
|
||||
: ''}"
|
||||
@@ -233,8 +228,7 @@ function isActiveLink(link: any) {
|
||||
<!-- {#if isActiveLink(link)}
|
||||
<div class="h-2 w-2 rounded-full bg-primary"></div>
|
||||
{/if} -->
|
||||
<span
|
||||
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground"
|
||||
<span class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground"
|
||||
></span>
|
||||
</div>
|
||||
</a>
|
||||
@@ -244,16 +238,14 @@ function isActiveLink(link: any) {
|
||||
|
||||
<!-- Account Actions -->
|
||||
<div class="space-y-3">
|
||||
<h3
|
||||
class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider"
|
||||
>
|
||||
{$_('header.account')}
|
||||
<h3 class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{$_("header.account")}
|
||||
</h3>
|
||||
|
||||
<div class="grid gap-2">
|
||||
{#if authStatus.authenticated}
|
||||
<a
|
||||
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: '/me' }) ? 'border-primary/30 bg-primary/5' : ''}`}
|
||||
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: "/me" }) ? "border-primary/30 bg-primary/5" : ""}`}
|
||||
href="/me"
|
||||
onclick={closeMenu}
|
||||
>
|
||||
@@ -266,13 +258,9 @@ function isActiveLink(link: any) {
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-foreground"
|
||||
>{$_('header.dashboard')}</span
|
||||
>
|
||||
<span class="font-medium text-foreground">{$_("header.dashboard")}</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground"
|
||||
>{$_('header.dashboard_hint')}</span
|
||||
>
|
||||
<span class="text-sm text-muted-foreground">{$_("header.dashboard_hint")}</span>
|
||||
</div>
|
||||
<span
|
||||
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
|
||||
@@ -280,7 +268,7 @@ function isActiveLink(link: any) {
|
||||
</a>
|
||||
|
||||
<a
|
||||
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: '/play' }) ? 'border-primary/30 bg-primary/5' : ''}`}
|
||||
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: "/play" }) ? "border-primary/30 bg-primary/5" : ""}`}
|
||||
href="/play"
|
||||
onclick={closeMenu}
|
||||
>
|
||||
@@ -293,13 +281,9 @@ function isActiveLink(link: any) {
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-foreground"
|
||||
>{$_('header.play')}</span
|
||||
>
|
||||
<span class="font-medium text-foreground">{$_("header.play")}</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground"
|
||||
>{$_('header.play_hint')}</span
|
||||
>
|
||||
<span class="text-sm text-muted-foreground">{$_("header.play_hint")}</span>
|
||||
</div>
|
||||
<span
|
||||
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
|
||||
@@ -307,7 +291,7 @@ function isActiveLink(link: any) {
|
||||
</a>
|
||||
{:else}
|
||||
<a
|
||||
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: '/login' }) ? 'border-primary/30 bg-primary/5' : ''}`}
|
||||
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: "/login" }) ? "border-primary/30 bg-primary/5" : ""}`}
|
||||
href="/login"
|
||||
onclick={closeMenu}
|
||||
>
|
||||
@@ -320,13 +304,9 @@ function isActiveLink(link: any) {
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-foreground"
|
||||
>{$_('header.login')}</span
|
||||
>
|
||||
<span class="font-medium text-foreground">{$_("header.login")}</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground"
|
||||
>{$_('header.login_hint')}</span
|
||||
>
|
||||
<span class="text-sm text-muted-foreground">{$_("header.login_hint")}</span>
|
||||
</div>
|
||||
<span
|
||||
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
|
||||
@@ -334,7 +314,7 @@ function isActiveLink(link: any) {
|
||||
</a>
|
||||
|
||||
<a
|
||||
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: '/signup' }) ? 'border-primary/30 bg-primary/5' : ''}`}
|
||||
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: "/signup" }) ? "border-primary/30 bg-primary/5" : ""}`}
|
||||
href="/signup"
|
||||
onclick={closeMenu}
|
||||
>
|
||||
@@ -347,13 +327,9 @@ function isActiveLink(link: any) {
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-foreground"
|
||||
>{$_('header.signup')}</span
|
||||
>
|
||||
<span class="font-medium text-foreground">{$_("header.signup")}</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground"
|
||||
>{$_('header.signup_hint')}</span
|
||||
>
|
||||
<span class="text-sm text-muted-foreground">{$_("header.signup_hint")}</span>
|
||||
</div>
|
||||
<span
|
||||
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
|
||||
@@ -372,17 +348,11 @@ function isActiveLink(link: any) {
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-destructive/10 group-hover:bg-destructive/20 transition-all"
|
||||
>
|
||||
<span
|
||||
class="icon-[ri--logout-circle-r-line] h-4 w-4 text-destructive"
|
||||
></span>
|
||||
<span class="icon-[ri--logout-circle-r-line] h-4 w-4 text-destructive"></span>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<span class="font-medium text-foreground"
|
||||
>{$_('header.logout')}</span
|
||||
>
|
||||
<span class="text-sm text-muted-foreground"
|
||||
>{$_('header.logout_hint')}</span
|
||||
>
|
||||
<span class="font-medium text-foreground">{$_("header.logout")}</span>
|
||||
<span class="text-sm text-muted-foreground">{$_("header.logout_hint")}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
class?: string;
|
||||
size?: string | number;
|
||||
}
|
||||
interface Props {
|
||||
class?: string;
|
||||
size?: string | number;
|
||||
}
|
||||
|
||||
let { class: className = "", size = "24" }: Props = $props();
|
||||
let { class: className = "", size = "24" }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 512 512"
|
||||
class={className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 512 512"
|
||||
class={className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g class="" transform="translate(0,0)" style=""
|
||||
><path
|
||||
d="M418.813 30.625c-21.178 26.27-49.712 50.982-84.125 70.844-36.778 21.225-75.064 33.62-110.313 38.06a310.317 310.317 0 0 0 6.813 18.25c16.01.277 29.366-.434 36.406-1.5l9.47-1.53 8.436-1.28.22 10.186a307.48 307.48 0 0 1-1.095 18.72l56.625 8.843c.86-.095 1.713-.15 2.563-.157 11.188-.114 21.44 7.29 24.468 18.593.657 2.448.922 4.903.845 7.313 5.972-2.075 11.753-4.305 17.28-6.72l9.595-4.188 2.313 10.22a340.211 340.211 0 0 1 7.375 48.062C438.29 247.836 468.438 225.71 493 197.5c-3.22-36.73-16.154-78.04-39.125-117.813a290.509 290.509 0 0 0-2.22-3.78l-27.56 71.374c5.154.762 10.123 3.158 14.092 7.126 9.81 9.807 9.813 25.69 0 35.5-9.812 9.81-25.722 9.807-35.53 0-8.86-8.858-9.69-22.68-2.532-32.5l38.938-100.844a322.02 322.02 0 0 0-20.25-25.937zM51.842 118.72c-8.46 17.373-15.76 36.198-21.187 56.436-14.108 52.617-13.96 103.682-2.812 143.438 13.3-2.605 26.442-3.96 39.312-4.03 1.855-.012 3.688.02 5.53.06 20.857.48 40.98 4.332 59.97 11.5a355.064 355.064 0 0 1-1.656-34.218c0-27.8 3.135-54.377 9-78.937l2.47-10.407 9.655 4.562c29.467 13.98 66.194 23.424 106.28 25.22 5.136-20.05 8.19-39.78 9.408-58.75-35.198 4.83-75.387 2.766-116.407-8.22-38.363-10.272-72.314-26.78-99.562-46.656zm230.594 82.218c-1.535 10.452-3.615 21.03-6.218 31.687a312.754 312.754 0 0 0 46-3.97 24.98 24.98 0 0 1-1.532-21.748l-38.25-5.97zM105 201.375l4.156 18.22-21.594 4.905c8.75 5.174 13.353 15.703 10.594 26-3.32 12.394-16.045 19.758-28.437 16.438-12.394-3.32-19.76-16.075-16.44-28.47a23.235 23.235 0 0 1 3.126-6.874l-21.062 4.78-4.125-18.218 73.78-16.78zm388.594 22.813c-25.53 25.46-55.306 45.445-86.906 60.5.05 2.397.093 4.8.093 7.218 0 9.188-.354 18.232-1.03 27.125 16.635 1.33 32.045-1.7 45.344-9.374 25.925-14.962 40.608-45.694 42.5-85.47zm-338.844 3c-4.03 19.993-6.33 41.31-6.406 63.593l.125-.342c30.568 10.174 62.622 17.572 95.25 21.375l7.5.875.718 7.5 5.687 60.125-18.625 1.75-2.53-26.75a23.117 23.117 0 0 1-14.845.968c-12.393-3.32-19.76-16.042-16.438-28.436.285-1.06.647-2.08 1.063-3.063a496.627 496.627 0 0 1-57.406-14.53c2.69 49.62 16.154 94.04 36.094 126.656 22.366 36.588 52.13 57.78 83.968 57.78 31.838.003 61.602-21.19 83.97-57.78 19.536-31.96 32.846-75.244 35.905-123.656a499.132 499.132 0 0 1-48.25 11.656c1.914 4.57 2.415 9.78 1.033 14.938-3.322 12.394-16.045 19.758-28.438 16.437a23.01 23.01 0 0 1-2.125-.686l-2.5 26.47-18.594-1.752 5.688-60.125.72-7.5 7.498-.875c29.245-3.407 57.995-9.717 85.657-18.312v-1.594c0-21.573-2.27-42.23-6.064-61.75C351.132 242.653 313.092 250 272.312 250c-43.59 0-83.986-8.658-117.562-22.813zm-87.5 105.968c-10.87.102-21.995 1.22-33.375 3.313 12.695 31.62 33.117 53.07 59 60 16.9 4.523 34.896 2.536 52.813-5.25-4.382-13.89-7.874-28.606-10.344-43.97-21.115-9.623-43.934-14.32-68.094-14.094zm137.5 80.22h130.813c-40.082 44.594-92.623 42.844-130.813 0z"
|
||||
fill-opacity="1"
|
||||
style="fill: currentColor; stroke: #ce47eb; stroke-width: 10px;"
|
||||
|
||||
></path></g
|
||||
></svg
|
||||
<g class="" transform="translate(0,0)" style=""
|
||||
><path
|
||||
d="M418.813 30.625c-21.178 26.27-49.712 50.982-84.125 70.844-36.778 21.225-75.064 33.62-110.313 38.06a310.317 310.317 0 0 0 6.813 18.25c16.01.277 29.366-.434 36.406-1.5l9.47-1.53 8.436-1.28.22 10.186a307.48 307.48 0 0 1-1.095 18.72l56.625 8.843c.86-.095 1.713-.15 2.563-.157 11.188-.114 21.44 7.29 24.468 18.593.657 2.448.922 4.903.845 7.313 5.972-2.075 11.753-4.305 17.28-6.72l9.595-4.188 2.313 10.22a340.211 340.211 0 0 1 7.375 48.062C438.29 247.836 468.438 225.71 493 197.5c-3.22-36.73-16.154-78.04-39.125-117.813a290.509 290.509 0 0 0-2.22-3.78l-27.56 71.374c5.154.762 10.123 3.158 14.092 7.126 9.81 9.807 9.813 25.69 0 35.5-9.812 9.81-25.722 9.807-35.53 0-8.86-8.858-9.69-22.68-2.532-32.5l38.938-100.844a322.02 322.02 0 0 0-20.25-25.937zM51.842 118.72c-8.46 17.373-15.76 36.198-21.187 56.436-14.108 52.617-13.96 103.682-2.812 143.438 13.3-2.605 26.442-3.96 39.312-4.03 1.855-.012 3.688.02 5.53.06 20.857.48 40.98 4.332 59.97 11.5a355.064 355.064 0 0 1-1.656-34.218c0-27.8 3.135-54.377 9-78.937l2.47-10.407 9.655 4.562c29.467 13.98 66.194 23.424 106.28 25.22 5.136-20.05 8.19-39.78 9.408-58.75-35.198 4.83-75.387 2.766-116.407-8.22-38.363-10.272-72.314-26.78-99.562-46.656zm230.594 82.218c-1.535 10.452-3.615 21.03-6.218 31.687a312.754 312.754 0 0 0 46-3.97 24.98 24.98 0 0 1-1.532-21.748l-38.25-5.97zM105 201.375l4.156 18.22-21.594 4.905c8.75 5.174 13.353 15.703 10.594 26-3.32 12.394-16.045 19.758-28.437 16.438-12.394-3.32-19.76-16.075-16.44-28.47a23.235 23.235 0 0 1 3.126-6.874l-21.062 4.78-4.125-18.218 73.78-16.78zm388.594 22.813c-25.53 25.46-55.306 45.445-86.906 60.5.05 2.397.093 4.8.093 7.218 0 9.188-.354 18.232-1.03 27.125 16.635 1.33 32.045-1.7 45.344-9.374 25.925-14.962 40.608-45.694 42.5-85.47zm-338.844 3c-4.03 19.993-6.33 41.31-6.406 63.593l.125-.342c30.568 10.174 62.622 17.572 95.25 21.375l7.5.875.718 7.5 5.687 60.125-18.625 1.75-2.53-26.75a23.117 23.117 0 0 1-14.845.968c-12.393-3.32-19.76-16.042-16.438-28.436.285-1.06.647-2.08 1.063-3.063a496.627 496.627 0 0 1-57.406-14.53c2.69 49.62 16.154 94.04 36.094 126.656 22.366 36.588 52.13 57.78 83.968 57.78 31.838.003 61.602-21.19 83.97-57.78 19.536-31.96 32.846-75.244 35.905-123.656a499.132 499.132 0 0 1-48.25 11.656c1.914 4.57 2.415 9.78 1.033 14.938-3.322 12.394-16.045 19.758-28.438 16.437a23.01 23.01 0 0 1-2.125-.686l-2.5 26.47-18.594-1.752 5.688-60.125.72-7.5 7.498-.875c29.245-3.407 57.995-9.717 85.657-18.312v-1.594c0-21.573-2.27-42.23-6.064-61.75C351.132 242.653 313.092 250 272.312 250c-43.59 0-83.986-8.658-117.562-22.813zm-87.5 105.968c-10.87.102-21.995 1.22-33.375 3.313 12.695 31.62 33.117 53.07 59 60 16.9 4.523 34.896 2.536 52.813-5.25-4.382-13.89-7.874-28.606-10.344-43.97-21.115-9.623-43.934-14.32-68.094-14.094zm137.5 80.22h130.813c-40.082 44.594-92.623 42.844-130.813 0z"
|
||||
fill-opacity="1"
|
||||
style="fill: currentColor; stroke: #ce47eb; stroke-width: 10px;"
|
||||
></path></g
|
||||
></svg
|
||||
>
|
||||
|
||||
@@ -1,109 +1,107 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import { browser } from "$app/environment";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import Button from "../ui/button/button.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { browser } from "$app/environment";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import Button from "../ui/button/button.svelte";
|
||||
|
||||
const { images = [] } = $props();
|
||||
const { images = [] } = $props();
|
||||
|
||||
let isViewerOpen = $state(false);
|
||||
let currentImageIndex = $state(0);
|
||||
let imageLoading = $state(false);
|
||||
let isViewerOpen = $state(false);
|
||||
let currentImageIndex = $state(0);
|
||||
let imageLoading = $state(false);
|
||||
|
||||
let currentImage = $derived(images[currentImageIndex]);
|
||||
let canGoPrev = $derived(currentImageIndex > 0);
|
||||
let canGoNext = $derived(currentImageIndex < images.length - 1);
|
||||
let currentImage = $derived(images[currentImageIndex]);
|
||||
let canGoPrev = $derived(currentImageIndex > 0);
|
||||
let canGoNext = $derived(currentImageIndex < images.length - 1);
|
||||
|
||||
function openViewer(index) {
|
||||
currentImageIndex = index;
|
||||
isViewerOpen = true;
|
||||
imageLoading = true;
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
function openViewer(index) {
|
||||
currentImageIndex = index;
|
||||
isViewerOpen = true;
|
||||
imageLoading = true;
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
function closeViewer() {
|
||||
isViewerOpen = false;
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
function closeViewer() {
|
||||
isViewerOpen = false;
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
|
||||
function navigatePrev() {
|
||||
if (canGoPrev) {
|
||||
currentImageIndex--;
|
||||
imageLoading = true;
|
||||
}
|
||||
}
|
||||
function navigatePrev() {
|
||||
if (canGoPrev) {
|
||||
currentImageIndex--;
|
||||
imageLoading = true;
|
||||
}
|
||||
}
|
||||
|
||||
function navigateNext() {
|
||||
if (canGoNext) {
|
||||
currentImageIndex++;
|
||||
imageLoading = true;
|
||||
}
|
||||
}
|
||||
function navigateNext() {
|
||||
if (canGoNext) {
|
||||
currentImageIndex++;
|
||||
imageLoading = true;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadImage() {
|
||||
const link = document.createElement("a");
|
||||
link.href = currentImage.url;
|
||||
link.download = currentImage.title.replace(/\\s+/g, "_") + ".jpg";
|
||||
link.target = "_blank";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
function downloadImage() {
|
||||
const link = document.createElement("a");
|
||||
link.href = currentImage.url;
|
||||
link.download = currentImage.title.replace(/\\s+/g, "_") + ".jpg";
|
||||
link.target = "_blank";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
if (!isViewerOpen) return;
|
||||
function handleKeydown(event) {
|
||||
if (!isViewerOpen) return;
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowLeft":
|
||||
event.preventDefault();
|
||||
navigatePrev();
|
||||
break;
|
||||
case "ArrowRight":
|
||||
event.preventDefault();
|
||||
navigateNext();
|
||||
break;
|
||||
case "Escape":
|
||||
event.preventDefault();
|
||||
closeViewer();
|
||||
break;
|
||||
case "d":
|
||||
case "D":
|
||||
event.preventDefault();
|
||||
downloadImage();
|
||||
break;
|
||||
}
|
||||
}
|
||||
switch (event.key) {
|
||||
case "ArrowLeft":
|
||||
event.preventDefault();
|
||||
navigatePrev();
|
||||
break;
|
||||
case "ArrowRight":
|
||||
event.preventDefault();
|
||||
navigateNext();
|
||||
break;
|
||||
case "Escape":
|
||||
event.preventDefault();
|
||||
closeViewer();
|
||||
break;
|
||||
case "d":
|
||||
case "D":
|
||||
event.preventDefault();
|
||||
downloadImage();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageLoad() {
|
||||
imageLoading = false;
|
||||
}
|
||||
function handleImageLoad() {
|
||||
imageLoading = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!browser) {
|
||||
return;
|
||||
}
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
// Preload images
|
||||
images.forEach((img) => {
|
||||
const preload = new Image();
|
||||
preload.src = img.url;
|
||||
});
|
||||
});
|
||||
onMount(() => {
|
||||
if (!browser) {
|
||||
return;
|
||||
}
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
// Preload images
|
||||
images.forEach((img) => {
|
||||
const preload = new Image();
|
||||
preload.src = img.url;
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (!browser) {
|
||||
return;
|
||||
}
|
||||
window.removeEventListener("keydown", handleKeydown);
|
||||
document.body.style.overflow = "";
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (!browser) {
|
||||
return;
|
||||
}
|
||||
window.removeEventListener("keydown", handleKeydown);
|
||||
document.body.style.overflow = "";
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Gallery Grid -->
|
||||
<div class="w-full mx-auto">
|
||||
<div
|
||||
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 animate-fade-in"
|
||||
>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 animate-fade-in">
|
||||
{#each images as image, index (index)}
|
||||
<button
|
||||
onclick={() => openViewer(index)}
|
||||
@@ -145,14 +143,9 @@ onDestroy(() => {
|
||||
|
||||
<!-- Image Viewer Modal -->
|
||||
{#if isViewerOpen}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center animate-fade-in"
|
||||
>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center animate-fade-in">
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/95 backdrop-blur-xl"
|
||||
onclick={closeViewer}
|
||||
></div>
|
||||
<div class="absolute inset-0 bg-black/95 backdrop-blur-xl" onclick={closeViewer}></div>
|
||||
|
||||
<!-- Viewer Content -->
|
||||
<div class="relative w-[90vw] h-[90vh] flex flex-col animate-slide-up">
|
||||
@@ -166,9 +159,9 @@ onDestroy(() => {
|
||||
<div class="text-primary font-medium mb-3">
|
||||
{$_("image_viewer.index", {
|
||||
values: {
|
||||
index: currentImageIndex + 1,
|
||||
size: images.length
|
||||
}
|
||||
index: currentImageIndex + 1,
|
||||
size: images.length,
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
<p class="text-zinc-400 max-w-2xl">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import PeonyIcon from "../icon/peony-icon.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import PeonyIcon from "../icon/peony-icon.svelte";
|
||||
|
||||
const { hideName = false } = $props();
|
||||
const { hideName = false } = $props();
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
@@ -11,11 +11,11 @@ const { hideName = false } = $props();
|
||||
<span
|
||||
class={`logo text-3xl text-foreground opacity-90 tracking-wide font-extrabold drop-shadow-x ${hideName ? "hidden sm:inline-block" : ""}`}
|
||||
>
|
||||
{$_('brand.name')}
|
||||
{$_("brand.name")}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.logo {
|
||||
font-family: 'Dancing Script', cursive;
|
||||
font-family: "Dancing Script", cursive;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,148 +1,184 @@
|
||||
<script lang="ts">
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
|
||||
import { getUserInitials } from "$lib/utils";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
|
||||
import { getUserInitials } from "$lib/utils";
|
||||
|
||||
interface User {
|
||||
name?: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
}
|
||||
interface User {
|
||||
name?: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
onLogout: () => void;
|
||||
}
|
||||
interface Props {
|
||||
user: User;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
let { user, onLogout }: Props = $props();
|
||||
let { user, onLogout }: Props = $props();
|
||||
|
||||
let isDragging = $state(false);
|
||||
let slidePosition = $state(0);
|
||||
let startX = 0;
|
||||
let currentX = 0;
|
||||
let maxSlide = 117; // Maximum slide distance
|
||||
let threshold = 0.75; // 70% threshold to trigger logout
|
||||
let isDragging = $state(false);
|
||||
let slidePosition = $state(0);
|
||||
let startX = 0;
|
||||
let currentX = 0;
|
||||
let maxSlide = 117; // Maximum slide distance
|
||||
let threshold = 0.75; // 70% threshold to trigger logout
|
||||
|
||||
// Calculate slide progress (0 to 1)
|
||||
const slideProgress = $derived(Math.min(slidePosition / maxSlide, 1));
|
||||
const isNearThreshold = $derived(slideProgress > threshold);
|
||||
// Calculate slide progress (0 to 1)
|
||||
const slideProgress = $derived(Math.min(slidePosition / maxSlide, 1));
|
||||
const isNearThreshold = $derived(slideProgress > threshold);
|
||||
|
||||
const handleStart = (clientX: number) => {
|
||||
isDragging = true;
|
||||
startX = clientX;
|
||||
currentX = clientX;
|
||||
};
|
||||
const handleStart = (clientX: number) => {
|
||||
isDragging = true;
|
||||
startX = clientX;
|
||||
currentX = clientX;
|
||||
};
|
||||
|
||||
const handleMove = (clientX: number) => {
|
||||
if (!isDragging) return;
|
||||
const handleMove = (clientX: number) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
currentX = clientX;
|
||||
const deltaX = currentX - startX;
|
||||
slidePosition = Math.max(0, Math.min(deltaX, maxSlide));
|
||||
};
|
||||
currentX = clientX;
|
||||
const deltaX = currentX - startX;
|
||||
slidePosition = Math.max(0, Math.min(deltaX, maxSlide));
|
||||
};
|
||||
|
||||
const handleEnd = () => {
|
||||
if (!isDragging) return;
|
||||
const handleEnd = () => {
|
||||
if (!isDragging) return;
|
||||
|
||||
isDragging = false;
|
||||
isDragging = false;
|
||||
|
||||
if (slideProgress >= threshold) {
|
||||
// Trigger logout
|
||||
slidePosition = maxSlide;
|
||||
onLogout();
|
||||
} else {
|
||||
// Snap back
|
||||
slidePosition = 0;
|
||||
}
|
||||
};
|
||||
if (slideProgress >= threshold) {
|
||||
// Trigger logout
|
||||
slidePosition = maxSlide;
|
||||
onLogout();
|
||||
} else {
|
||||
// Snap back
|
||||
slidePosition = 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Mouse events
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
handleStart(e.clientX);
|
||||
};
|
||||
// Mouse events
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
handleStart(e.clientX);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
handleMove(e.clientX);
|
||||
};
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
handleMove(e.clientX);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
handleEnd();
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
handleEnd();
|
||||
};
|
||||
|
||||
// Touch events
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
handleStart(e.touches[0].clientX);
|
||||
};
|
||||
// Touch events
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
handleStart(e.touches[0].clientX);
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
e.preventDefault();
|
||||
handleMove(e.touches[0].clientX);
|
||||
};
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
e.preventDefault();
|
||||
handleMove(e.touches[0].clientX);
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
handleEnd();
|
||||
};
|
||||
const handleTouchEnd = () => {
|
||||
handleEnd();
|
||||
};
|
||||
|
||||
// Add global event listeners when dragging
|
||||
$effect(() => {
|
||||
if (isDragging) {
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
document.addEventListener("touchmove", handleTouchMove, { passive: false });
|
||||
document.addEventListener("touchend", handleTouchEnd);
|
||||
// Add global event listeners when dragging
|
||||
$effect(() => {
|
||||
if (isDragging) {
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
document.addEventListener("touchmove", handleTouchMove, { passive: false });
|
||||
document.addEventListener("touchend", handleTouchEnd);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
document.removeEventListener("touchmove", handleTouchMove);
|
||||
document.removeEventListener("touchend", handleTouchEnd);
|
||||
};
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
document.removeEventListener("touchmove", handleTouchMove);
|
||||
document.removeEventListener("touchend", handleTouchEnd);
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative h-10 w-40 rounded-full bg-muted/30 overflow-hidden select-none transition-all duration-300 bg-muted/40 shadow-lg shadow-accent/10 {isDragging ? 'cursor-grabbing' : ''}"
|
||||
style="background: linear-gradient(90deg,
|
||||
class="relative h-10 w-40 rounded-full bg-muted/30 overflow-hidden select-none transition-all duration-300 bg-muted/40 shadow-lg shadow-accent/10 {isDragging
|
||||
? 'cursor-grabbing'
|
||||
: ''}"
|
||||
style="background: linear-gradient(90deg,
|
||||
oklch(var(--primary) / 0.3) 0%,
|
||||
oklch(var(--primary) / 0.3) {(1 - slideProgress) * 100}%,
|
||||
oklch(var(--accent) / {0.1 + slideProgress * 0.2}) {(1 - slideProgress) * 100}%,
|
||||
oklch(var(--accent) / {0.2 + slideProgress * 0.3}) 100%
|
||||
)"
|
||||
>
|
||||
<!-- Background slide indicator -->
|
||||
<div
|
||||
class="absolute inset-0 rounded-full transition-all duration-200"
|
||||
style="background: linear-gradient(90deg,
|
||||
<!-- Background slide indicator -->
|
||||
<div
|
||||
class="absolute inset-0 rounded-full transition-all duration-200"
|
||||
style="background: linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
transparent {Math.max(0, slideProgress * 100 - 20)}%,
|
||||
oklch(var(--accent) / {slideProgress * 0.1}) {slideProgress * 100}%,
|
||||
oklch(var(--accent) / {slideProgress * 0.2}) 100%
|
||||
)"
|
||||
></div>
|
||||
></div>
|
||||
|
||||
<!-- Sliding user info -->
|
||||
<button class="cursor-grab absolute left-0 top-0 h-full flex items-center gap-3 px-2 transition-all duration-200 ease-out rounded-full bg-background/80 backdrop-blur-sm border border-border/50 bg-background/90 border-primary/20 {isDragging ? '' : 'transition-all duration-300 ease-out'}" style="transform: translateX({slidePosition}px); width: calc(100% - {slidePosition}px);" onmousedown={handleMouseDown} ontouchstart={handleTouchStart}>
|
||||
<Avatar class="h-7 w-7 ring-2 ring-accent/20 transition-all duration-200 {isNearThreshold ? 'ring-destructive/40' : ''}" style="opacity: {Math.max(0.1, 1 - slideProgress * 1.8)}">
|
||||
<AvatarImage src={user.avatar} alt={user.name || user.email} />
|
||||
<AvatarFallback class="bg-gradient-to-br from-primary to-accent text-primary-foreground text-xs font-semibold transition-all duration-200 {isNearThreshold ? 'from-destructive to-destructive/80' : ''}">
|
||||
{getUserInitials(user.name || user.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="text-left flex flex-col min-w-0 flex-1">
|
||||
<span class="text-sm font-medium text-foreground leading-none truncate transition-all duration-200 {isNearThreshold ? 'text-destructive' : ''}" style="opacity: {Math.max(0.15, 1 - slideProgress * 1.5)}">{user?.name ? user.name.split(" ")[0] : "User"}</span>
|
||||
<span class="text-xs text-muted-foreground leading-none transition-all duration-200 {isNearThreshold ? 'text-destructive/70' : ''}" style="opacity: {Math.max(0.1, 1 - slideProgress * 1.8)}">
|
||||
{slideProgress > 0.3 ? "Logout" : "Online"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<!-- Sliding user info -->
|
||||
<button
|
||||
class="cursor-grab absolute left-0 top-0 h-full flex items-center gap-3 px-2 transition-all duration-200 ease-out rounded-full bg-background/80 backdrop-blur-sm border border-border/50 bg-background/90 border-primary/20 {isDragging
|
||||
? ''
|
||||
: 'transition-all duration-300 ease-out'}"
|
||||
style="transform: translateX({slidePosition}px); width: calc(100% - {slidePosition}px);"
|
||||
onmousedown={handleMouseDown}
|
||||
ontouchstart={handleTouchStart}
|
||||
>
|
||||
<Avatar
|
||||
class="h-7 w-7 ring-2 ring-accent/20 transition-all duration-200 {isNearThreshold
|
||||
? 'ring-destructive/40'
|
||||
: ''}"
|
||||
style="opacity: {Math.max(0.1, 1 - slideProgress * 1.8)}"
|
||||
>
|
||||
<AvatarImage src={user.avatar} alt={user.name || user.email} />
|
||||
<AvatarFallback
|
||||
class="bg-gradient-to-br from-primary to-accent text-primary-foreground text-xs font-semibold transition-all duration-200 {isNearThreshold
|
||||
? 'from-destructive to-destructive/80'
|
||||
: ''}"
|
||||
>
|
||||
{getUserInitials(user.name || user.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="text-left flex flex-col min-w-0 flex-1">
|
||||
<span
|
||||
class="text-sm font-medium text-foreground leading-none truncate transition-all duration-200 {isNearThreshold
|
||||
? 'text-destructive'
|
||||
: ''}"
|
||||
style="opacity: {Math.max(0.15, 1 - slideProgress * 1.5)}"
|
||||
>{user?.name ? user.name.split(" ")[0] : "User"}</span
|
||||
>
|
||||
<span
|
||||
class="text-xs text-muted-foreground leading-none transition-all duration-200 {isNearThreshold
|
||||
? 'text-destructive/70'
|
||||
: ''}"
|
||||
style="opacity: {Math.max(0.1, 1 - slideProgress * 1.8)}"
|
||||
>
|
||||
{slideProgress > 0.3 ? "Logout" : "Online"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Logout icon area -->
|
||||
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center w-8 h-8 rounded-full transition-all duration-200 {isNearThreshold ? 'bg-destructive text-destructive-foreground scale-110' : 'bg-transparent text-foreground'}">
|
||||
<span class="icon-[ri--logout-circle-r-line] h-4 w-4 transition-transform duration-200 {isNearThreshold ? 'scale-110' : ''}" ></span>
|
||||
</div>
|
||||
<!-- Logout icon area -->
|
||||
<div
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center w-8 h-8 rounded-full transition-all duration-200 {isNearThreshold
|
||||
? 'bg-destructive text-destructive-foreground scale-110'
|
||||
: 'bg-transparent text-foreground'}"
|
||||
>
|
||||
<span
|
||||
class="icon-[ri--logout-circle-r-line] h-4 w-4 transition-transform duration-200 {isNearThreshold
|
||||
? 'scale-110'
|
||||
: ''}"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Progress indicator -->
|
||||
<!-- <div class="absolute bottom-0 left-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-200 rounded-full" style="width: {slideProgress * 100}%"></div> -->
|
||||
<!-- Progress indicator -->
|
||||
<!-- <div class="absolute bottom-0 left-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-200 rounded-full" style="width: {slideProgress * 100}%"></div> -->
|
||||
</div>
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import { env } from "$env/dynamic/public";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { env } from "$env/dynamic/public";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
}
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
description,
|
||||
image = `${env.PUBLIC_URL || "http://localhost:3000"}/img/kamasutra.jpg`,
|
||||
}: Props = $props();
|
||||
let {
|
||||
title,
|
||||
description,
|
||||
image = `${env.PUBLIC_URL || "http://localhost:3000"}/img/kamasutra.jpg`,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_("head.title", { values: { title } })}</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta
|
||||
property="og:title"
|
||||
content={$_("head.title", { values: { title } })}
|
||||
/>
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={image} />
|
||||
<title>{$_("head.title", { values: { title } })}</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta property="og:title" content={$_("head.title", { values: { title } })} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={image} />
|
||||
</svelte:head>
|
||||
|
||||
@@ -1,180 +1,163 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import type { Recording } from "$lib/types";
|
||||
import { cn } from "$lib/utils";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import type { Recording } from "$lib/types";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
interface Props {
|
||||
recording: Recording;
|
||||
onPlay?: (id: string) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
}
|
||||
interface Props {
|
||||
recording: Recording;
|
||||
onPlay?: (id: string) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
}
|
||||
|
||||
let { recording, onPlay, onDelete }: Props = $props();
|
||||
let { recording, onPlay, onDelete }: Props = $props();
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
function formatDuration(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function getStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case "published":
|
||||
return "text-green-400 bg-green-400/20";
|
||||
case "draft":
|
||||
return "text-yellow-400 bg-yellow-400/20";
|
||||
case "archived":
|
||||
return "text-red-400 bg-red-400/20";
|
||||
default:
|
||||
return "text-gray-400 bg-gray-400/20";
|
||||
}
|
||||
}
|
||||
function getStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case "published":
|
||||
return "text-green-400 bg-green-400/20";
|
||||
case "draft":
|
||||
return "text-yellow-400 bg-yellow-400/20";
|
||||
case "archived":
|
||||
return "text-red-400 bg-red-400/20";
|
||||
default:
|
||||
return "text-gray-400 bg-gray-400/20";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card
|
||||
class="group hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 border-border/50 hover:border-primary/30 bg-card/50 backdrop-blur-sm"
|
||||
class="group hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 border-border/50 hover:border-primary/30 bg-card/50 backdrop-blur-sm"
|
||||
>
|
||||
<CardHeader class="pb-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<h3
|
||||
class="font-semibold text-card-foreground group-hover:text-primary transition-colors"
|
||||
>
|
||||
{recording.title}
|
||||
</h3>
|
||||
<span
|
||||
class={cn(
|
||||
"text-xs px-2 py-0.5 rounded-full",
|
||||
getStatusColor(recording.status),
|
||||
)}
|
||||
>
|
||||
{$_(`recording_card.status_${recording.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
{#if recording.description}
|
||||
<p class="text-sm text-muted-foreground line-clamp-2">
|
||||
{recording.description}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardHeader class="pb-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<h3 class="font-semibold text-card-foreground group-hover:text-primary transition-colors">
|
||||
{recording.title}
|
||||
</h3>
|
||||
<span class={cn("text-xs px-2 py-0.5 rounded-full", getStatusColor(recording.status))}>
|
||||
{$_(`recording_card.status_${recording.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
{#if recording.description}
|
||||
<p class="text-sm text-muted-foreground line-clamp-2">
|
||||
{recording.description}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4">
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div
|
||||
class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30"
|
||||
>
|
||||
<span class="icon-[ri--time-line] w-4 h-4 text-primary mb-1"></span>
|
||||
<span class="text-xs text-muted-foreground"
|
||||
>{$_("recording_card.duration")}</span
|
||||
>
|
||||
<span class="font-medium text-sm">{formatDuration(recording.duration)}</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30"
|
||||
>
|
||||
<span class="icon-[ri--pulse-line] w-4 h-4 text-accent mb-1"></span>
|
||||
<span class="text-xs text-muted-foreground">{$_("recording_card.events")}</span>
|
||||
<span class="font-medium text-sm">{recording.events.length}</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30"
|
||||
>
|
||||
<span class="icon-[ri--gamepad-line] w-4 h-4 text-primary mb-1"></span>
|
||||
<span class="text-xs text-muted-foreground">{$_("recording_card.devices")}</span>
|
||||
<span class="font-medium text-sm">{recording.device_info.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<CardContent class="space-y-4">
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30">
|
||||
<span class="icon-[ri--time-line] w-4 h-4 text-primary mb-1"></span>
|
||||
<span class="text-xs text-muted-foreground">{$_("recording_card.duration")}</span>
|
||||
<span class="font-medium text-sm">{formatDuration(recording.duration)}</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30">
|
||||
<span class="icon-[ri--pulse-line] w-4 h-4 text-accent mb-1"></span>
|
||||
<span class="text-xs text-muted-foreground">{$_("recording_card.events")}</span>
|
||||
<span class="font-medium text-sm">{recording.events.length}</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30">
|
||||
<span class="icon-[ri--gamepad-line] w-4 h-4 text-primary mb-1"></span>
|
||||
<span class="text-xs text-muted-foreground">{$_("recording_card.devices")}</span>
|
||||
<span class="font-medium text-sm">{recording.device_info.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Info -->
|
||||
<div class="space-y-1">
|
||||
{#each recording.device_info.slice(0, 2) as device (device.name)}
|
||||
<div
|
||||
class="flex items-center gap-2 text-xs text-muted-foreground bg-muted/20 rounded px-2 py-1"
|
||||
>
|
||||
<span class="icon-[ri--rocket-line] w-3 h-3"></span>
|
||||
<span>{device.name}</span>
|
||||
<span class="text-xs opacity-60">• {device.capabilities.join(", ")}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{#if recording.device_info.length > 2}
|
||||
<div class="text-xs text-muted-foreground/60 px-2">
|
||||
+{recording.device_info.length - 2} more device{recording.device_info.length -
|
||||
2 >
|
||||
1
|
||||
? "s"
|
||||
: ""}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Device Info -->
|
||||
<div class="space-y-1">
|
||||
{#each recording.device_info.slice(0, 2) as device (device.name)}
|
||||
<div
|
||||
class="flex items-center gap-2 text-xs text-muted-foreground bg-muted/20 rounded px-2 py-1"
|
||||
>
|
||||
<span class="icon-[ri--rocket-line] w-3 h-3"></span>
|
||||
<span>{device.name}</span>
|
||||
<span class="text-xs opacity-60">• {device.capabilities.join(", ")}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{#if recording.device_info.length > 2}
|
||||
<div class="text-xs text-muted-foreground/60 px-2">
|
||||
+{recording.device_info.length - 2} more device{recording.device_info.length - 2 > 1
|
||||
? "s"
|
||||
: ""}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
{#if recording.tags && recording.tags.length > 0}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each recording.tags as tag (tag)}
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary border border-primary/20"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Tags -->
|
||||
{#if recording.tags && recording.tags.length > 0}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each recording.tags as tag (tag)}
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary border border-primary/20"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground pt-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<span>
|
||||
{new Date(recording.date_created).toLocaleDateString()}
|
||||
</span>
|
||||
{#if recording.public}
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="icon-[ri--global-line] w-3 h-3"></span>
|
||||
{$_("recording_card.public")}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="icon-[ri--lock-line] w-3 h-3"></span>
|
||||
{$_("recording_card.private")}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if recording.linked_video}
|
||||
<span class="flex items-center gap-1 text-accent">
|
||||
<span class="icon-[ri--video-line] w-3 h-3"></span>
|
||||
{$_("recording_card.linked_video")}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Metadata -->
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground pt-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<span>
|
||||
{new Date(recording.date_created).toLocaleDateString()}
|
||||
</span>
|
||||
{#if recording.public}
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="icon-[ri--global-line] w-3 h-3"></span>
|
||||
{$_("recording_card.public")}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="icon-[ri--lock-line] w-3 h-3"></span>
|
||||
{$_("recording_card.private")}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if recording.linked_video}
|
||||
<span class="flex items-center gap-1 text-accent">
|
||||
<span class="icon-[ri--video-line] w-3 h-3"></span>
|
||||
{$_("recording_card.linked_video")}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 pt-2">
|
||||
{#if onPlay}
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={() => onPlay?.(recording.id)}
|
||||
class="flex-1 cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
>
|
||||
<span class="icon-[ri--play-fill] w-4 h-4 mr-1"></span>
|
||||
{$_("recording_card.play")}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if onDelete}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => onDelete?.(recording.id)}
|
||||
class="cursor-pointer border-destructive/20 hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<span class="icon-[ri--delete-bin-line] w-4 h-4"></span>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</CardContent>
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 pt-2">
|
||||
{#if onPlay}
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={() => onPlay?.(recording.id)}
|
||||
class="flex-1 cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
>
|
||||
<span class="icon-[ri--play-fill] w-4 h-4 mr-1"></span>
|
||||
{$_("recording_card.play")}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if onDelete}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => onDelete?.(recording.id)}
|
||||
class="cursor-pointer border-destructive/20 hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<span class="icon-[ri--delete-bin-line] w-4 h-4"></span>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
onclick: () => void;
|
||||
icon: string;
|
||||
label: string;
|
||||
}
|
||||
interface Props {
|
||||
onclick: () => void;
|
||||
icon: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
let { onclick, icon, label }: Props = $props();
|
||||
let { onclick, icon, label }: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
{onclick}
|
||||
aria-label={label}
|
||||
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors cursor-pointer"
|
||||
{onclick}
|
||||
aria-label={label}
|
||||
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors cursor-pointer"
|
||||
>
|
||||
<span class={icon + " w-4 h-4 text-primary"}></span>
|
||||
<span class={icon + " w-4 h-4 text-primary"}></span>
|
||||
</button>
|
||||
|
||||
@@ -1,110 +1,110 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import ShareButton from "./share-button.svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import type { ShareContent } from "$lib/types";
|
||||
import { _ } from "svelte-i18n";
|
||||
import ShareButton from "./share-button.svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import type { ShareContent } from "$lib/types";
|
||||
|
||||
interface Props {
|
||||
content: ShareContent;
|
||||
}
|
||||
interface Props {
|
||||
content: ShareContent;
|
||||
}
|
||||
|
||||
let { content }: Props = $props();
|
||||
let { content }: Props = $props();
|
||||
|
||||
// Share handlers
|
||||
const shareToX = () => {
|
||||
const text = `${content.title} - ${content.description}`;
|
||||
const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(content.url)}`;
|
||||
window.open(url, "_blank", "width=600,height=400");
|
||||
toast.success($_("sharing_popup.success.x"));
|
||||
};
|
||||
// Share handlers
|
||||
const shareToX = () => {
|
||||
const text = `${content.title} - ${content.description}`;
|
||||
const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(content.url)}`;
|
||||
window.open(url, "_blank", "width=600,height=400");
|
||||
toast.success($_("sharing_popup.success.x"));
|
||||
};
|
||||
|
||||
const shareToFacebook = () => {
|
||||
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(content.url)}"e=${encodeURIComponent(content.title)}`;
|
||||
window.open(url, "_blank", "width=600,height=400");
|
||||
toast.success($_("sharing_popup.success.facebook"));
|
||||
};
|
||||
const shareToFacebook = () => {
|
||||
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(content.url)}"e=${encodeURIComponent(content.title)}`;
|
||||
window.open(url, "_blank", "width=600,height=400");
|
||||
toast.success($_("sharing_popup.success.facebook"));
|
||||
};
|
||||
|
||||
const shareViaEmail = () => {
|
||||
const subject = encodeURIComponent(content.title);
|
||||
const body = encodeURIComponent(`${content.description}\n\n${content.url}`);
|
||||
const url = `mailto:?subject=${subject}&body=${body}`;
|
||||
window.location.href = url;
|
||||
toast.success($_("sharing_popup.success.email"));
|
||||
};
|
||||
const shareViaEmail = () => {
|
||||
const subject = encodeURIComponent(content.title);
|
||||
const body = encodeURIComponent(`${content.description}\n\n${content.url}`);
|
||||
const url = `mailto:?subject=${subject}&body=${body}`;
|
||||
window.location.href = url;
|
||||
toast.success($_("sharing_popup.success.email"));
|
||||
};
|
||||
|
||||
const shareToWhatsApp = () => {
|
||||
const text = `${content.title}\n\n${content.description}\n\n${content.url}`;
|
||||
const url = `https://wa.me/?text=${encodeURIComponent(text)}`;
|
||||
window.open(url, "_blank");
|
||||
toast.success($_("sharing_popup.success.whatsapp"));
|
||||
};
|
||||
const shareToWhatsApp = () => {
|
||||
const text = `${content.title}\n\n${content.description}\n\n${content.url}`;
|
||||
const url = `https://wa.me/?text=${encodeURIComponent(text)}`;
|
||||
window.open(url, "_blank");
|
||||
toast.success($_("sharing_popup.success.whatsapp"));
|
||||
};
|
||||
|
||||
const shareToTelegram = () => {
|
||||
const text = `${content.title}\n\n${content.description}`;
|
||||
const url = `https://t.me/share/url?url=${encodeURIComponent(content.url)}&text=${encodeURIComponent(text)}`;
|
||||
window.open(url, "_blank");
|
||||
toast.success($_("sharing_popup.success.telegram"));
|
||||
};
|
||||
const shareToTelegram = () => {
|
||||
const text = `${content.title}\n\n${content.description}`;
|
||||
const url = `https://t.me/share/url?url=${encodeURIComponent(content.url)}&text=${encodeURIComponent(text)}`;
|
||||
window.open(url, "_blank");
|
||||
toast.success($_("sharing_popup.success.telegram"));
|
||||
};
|
||||
|
||||
const copyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content.url);
|
||||
toast.success($_("sharing_popup.success.copy"));
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = content.url;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textArea);
|
||||
toast.success($_("sharing_popup.success.copy"));
|
||||
}
|
||||
};
|
||||
const copyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content.url);
|
||||
toast.success($_("sharing_popup.success.copy"));
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = content.url;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textArea);
|
||||
toast.success($_("sharing_popup.success.copy"));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="text-center space-y-4">
|
||||
<h4 class="text-sm font-medium text-muted-foreground">
|
||||
{$_("sharing_popup.subtitle")}
|
||||
</h4>
|
||||
<div class="text-center space-y-4">
|
||||
<h4 class="text-sm font-medium text-muted-foreground">
|
||||
{$_("sharing_popup.subtitle")}
|
||||
</h4>
|
||||
|
||||
<div class="flex justify-center gap-3 flex-wrap">
|
||||
<ShareButton
|
||||
onclick={shareToX}
|
||||
icon="icon-[ri--twitter-x-line]"
|
||||
label={$_("sharing_popup.share.x")}
|
||||
/>
|
||||
<div class="flex justify-center gap-3 flex-wrap">
|
||||
<ShareButton
|
||||
onclick={shareToX}
|
||||
icon="icon-[ri--twitter-x-line]"
|
||||
label={$_("sharing_popup.share.x")}
|
||||
/>
|
||||
|
||||
<ShareButton
|
||||
onclick={shareToFacebook}
|
||||
icon="icon-[ri--facebook-line]"
|
||||
label={$_("sharing_popup.share.facebook")}
|
||||
/>
|
||||
<ShareButton
|
||||
onclick={shareToFacebook}
|
||||
icon="icon-[ri--facebook-line]"
|
||||
label={$_("sharing_popup.share.facebook")}
|
||||
/>
|
||||
|
||||
<ShareButton
|
||||
onclick={shareViaEmail}
|
||||
icon="icon-[ri--mail-line]"
|
||||
label={$_("sharing_popup.share.email")}
|
||||
/>
|
||||
<ShareButton
|
||||
onclick={shareViaEmail}
|
||||
icon="icon-[ri--mail-line]"
|
||||
label={$_("sharing_popup.share.email")}
|
||||
/>
|
||||
|
||||
<ShareButton
|
||||
onclick={shareToWhatsApp}
|
||||
icon="icon-[ri--whatsapp-line]"
|
||||
label={$_("sharing_popup.share.whatsapp")}
|
||||
/>
|
||||
<ShareButton
|
||||
onclick={shareToWhatsApp}
|
||||
icon="icon-[ri--whatsapp-line]"
|
||||
label={$_("sharing_popup.share.whatsapp")}
|
||||
/>
|
||||
|
||||
<ShareButton
|
||||
onclick={shareToTelegram}
|
||||
icon="icon-[ri--telegram-2-line]"
|
||||
label={$_("sharing_popup.share.telegram")}
|
||||
/>
|
||||
<ShareButton
|
||||
onclick={shareToTelegram}
|
||||
icon="icon-[ri--telegram-2-line]"
|
||||
label={$_("sharing_popup.share.telegram")}
|
||||
/>
|
||||
|
||||
<ShareButton
|
||||
onclick={copyLink}
|
||||
icon="icon-[ri--file-copy-line]"
|
||||
label={$_("sharing_popup.share.copy")}
|
||||
/>
|
||||
</div>
|
||||
<ShareButton
|
||||
onclick={copyLink}
|
||||
icon="icon-[ri--file-copy-line]"
|
||||
label={$_("sharing_popup.share.copy")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script>
|
||||
import { _ } from "svelte-i18n";
|
||||
import SharingPopup from "./sharing-popup.svelte";
|
||||
import Button from "../ui/button/button.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import SharingPopup from "./sharing-popup.svelte";
|
||||
import Button from "../ui/button/button.svelte";
|
||||
|
||||
const { content } = $props();
|
||||
let isPopupOpen = $state(false);
|
||||
const { content } = $props();
|
||||
let isPopupOpen = $state(false);
|
||||
</script>
|
||||
|
||||
<Button
|
||||
@@ -13,6 +13,6 @@ let isPopupOpen = $state(false);
|
||||
class="flex items-center gap-2 border-primary/20 hover:bg-primary/10 cursor-pointer"
|
||||
>
|
||||
<span class="icon-[ri--share-2-line] w-4 h-4"></span>
|
||||
{$_('sharing_popup_button.share')}
|
||||
{$_("sharing_popup_button.share")}
|
||||
</Button>
|
||||
<SharingPopup bind:open={isPopupOpen} {content} />
|
||||
|
||||
@@ -1,89 +1,89 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "$lib/components/ui/dialog";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Separator } from "$lib/components/ui/separator";
|
||||
import ShareServices from "./share-services.svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "$lib/components/ui/dialog";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Separator } from "$lib/components/ui/separator";
|
||||
import ShareServices from "./share-services.svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface ShareContent {
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
type: "video" | "model" | "article" | "link";
|
||||
}
|
||||
interface ShareContent {
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
type: "video" | "model" | "article" | "link";
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
content: ShareContent;
|
||||
children?: Snippet;
|
||||
}
|
||||
interface Props {
|
||||
open: boolean;
|
||||
content: ShareContent;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { open = $bindable(), content }: Props = $props();
|
||||
let { open = $bindable(), content }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Dialog bind:open>
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-gradient-to-br from-primary to-purple-600 flex items-center justify-center shrink-0 grow-0"
|
||||
>
|
||||
<span class="icon-[ri--share-2-line] text-primary-foreground"></span>
|
||||
</div>
|
||||
<div class="">
|
||||
<DialogTitle class="text-left text-xl font-semibold text-primary-foreground"
|
||||
>{$_("sharing_popup.title")}</DialogTitle
|
||||
>
|
||||
<DialogDescription class="text-left text-sm">
|
||||
{$_("sharing_popup.description", {
|
||||
values: { type: content.type },
|
||||
})}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Preview -->
|
||||
<div class="text-left bg-muted/60 rounded-lg p-4 space-y-2">
|
||||
<h4 class="font-medium text-sm text-primary-foreground">
|
||||
{content.title}
|
||||
</h4>
|
||||
<p class="text-xs text-muted-foreground">{content.description}</p>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="px-2 py-1 bg-primary/10 text-primary rounded-full capitalize">
|
||||
{content.type}
|
||||
</span>
|
||||
<span class="text-muted-foreground text-clip">{content.url}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<Separator class="my-4" />
|
||||
|
||||
<!-- Share Services -->
|
||||
<ShareServices {content} />
|
||||
|
||||
<Separator class="my-4" />
|
||||
|
||||
<!-- Close Button -->
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => (open = false)}
|
||||
class="text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-gradient-to-br from-primary to-purple-600 flex items-center justify-center shrink-0 grow-0"
|
||||
>
|
||||
<span class="icon-[ri--share-2-line] text-primary-foreground"></span>
|
||||
</div>
|
||||
<div class="">
|
||||
<DialogTitle class="text-left text-xl font-semibold text-primary-foreground"
|
||||
>{$_("sharing_popup.title")}</DialogTitle
|
||||
>
|
||||
<span class="icon-[ri--close-large-line]"></span>
|
||||
{$_("sharing_popup.close")}
|
||||
</Button>
|
||||
<DialogDescription class="text-left text-sm">
|
||||
{$_("sharing_popup.description", {
|
||||
values: { type: content.type },
|
||||
})}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</div>
|
||||
|
||||
<!-- Content Preview -->
|
||||
<div class="text-left bg-muted/60 rounded-lg p-4 space-y-2">
|
||||
<h4 class="font-medium text-sm text-primary-foreground">
|
||||
{content.title}
|
||||
</h4>
|
||||
<p class="text-xs text-muted-foreground">{content.description}</p>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="px-2 py-1 bg-primary/10 text-primary rounded-full capitalize">
|
||||
{content.type}
|
||||
</span>
|
||||
<span class="text-muted-foreground text-clip">{content.url}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<Separator class="my-4" />
|
||||
|
||||
<!-- Share Services -->
|
||||
<ShareServices {content} />
|
||||
|
||||
<Separator class="my-4" />
|
||||
|
||||
<!-- Close Button -->
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => (open = false)}
|
||||
class="text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
>
|
||||
<span class="icon-[ri--close-large-line]"></span>
|
||||
{$_("sharing_popup.close")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert-description"
|
||||
class={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
bind:this={ref}
|
||||
data-slot="alert-description"
|
||||
class={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert-title"
|
||||
class={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
|
||||
{...restProps}
|
||||
bind:this={ref}
|
||||
data-slot="alert-title"
|
||||
class={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
<script lang="ts" module>
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
|
||||
export const alertVariants = tv({
|
||||
base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
export const alertVariants = tv({
|
||||
base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
|
||||
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
variant = "default",
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
variant?: AlertVariant;
|
||||
} = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
variant = "default",
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
variant?: AlertVariant;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert"
|
||||
class={cn(alertVariants({ variant }), className)}
|
||||
{...restProps}
|
||||
role="alert"
|
||||
bind:this={ref}
|
||||
data-slot="alert"
|
||||
class={cn(alertVariants({ variant }), className)}
|
||||
{...restProps}
|
||||
role="alert"
|
||||
>
|
||||
{@render children?.()}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -4,11 +4,11 @@ import Title from "./alert-title.svelte";
|
||||
export { alertVariants, type AlertVariant } from "./alert.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Description,
|
||||
Title,
|
||||
//
|
||||
Root as Alert,
|
||||
Description as AlertDescription,
|
||||
Title as AlertTitle,
|
||||
Root,
|
||||
Description,
|
||||
Title,
|
||||
//
|
||||
Root as Alert,
|
||||
Description as AlertDescription,
|
||||
Title as AlertTitle,
|
||||
};
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.FallbackProps = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.FallbackProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Fallback
|
||||
bind:ref
|
||||
data-slot="avatar-fallback"
|
||||
class={cn("bg-muted flex size-full items-center justify-center rounded-full", className)}
|
||||
{...restProps}
|
||||
bind:ref
|
||||
data-slot="avatar-fallback"
|
||||
class={cn("bg-muted flex size-full items-center justify-center rounded-full", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.ImageProps = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.ImageProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Image
|
||||
bind:ref
|
||||
data-slot="avatar-image"
|
||||
class={cn("aspect-square size-full", className)}
|
||||
{...restProps}
|
||||
bind:ref
|
||||
data-slot="avatar-image"
|
||||
class={cn("aspect-square size-full", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
loadingStatus = $bindable("loading"),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.RootProps = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
loadingStatus = $bindable("loading"),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Root
|
||||
bind:ref
|
||||
bind:loadingStatus
|
||||
data-slot="avatar"
|
||||
class={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
|
||||
{...restProps}
|
||||
bind:ref
|
||||
bind:loadingStatus
|
||||
data-slot="avatar"
|
||||
class={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
|
||||
@@ -3,11 +3,11 @@ import Image from "./avatar-image.svelte";
|
||||
import Fallback from "./avatar-fallback.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Image,
|
||||
Fallback,
|
||||
//
|
||||
Root as Avatar,
|
||||
Image as AvatarImage,
|
||||
Fallback as AvatarFallback,
|
||||
Root,
|
||||
Image,
|
||||
Fallback,
|
||||
//
|
||||
Root as Avatar,
|
||||
Image as AvatarImage,
|
||||
Fallback as AvatarFallback,
|
||||
};
|
||||
|
||||
@@ -1,86 +1,80 @@
|
||||
<script lang="ts" module>
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type {
|
||||
HTMLAnchorAttributes,
|
||||
HTMLButtonAttributes,
|
||||
} from "svelte/elements";
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
|
||||
export const buttonVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
|
||||
outline:
|
||||
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
export const buttonVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
|
||||
outline:
|
||||
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
||||
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
||||
|
||||
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||
WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
};
|
||||
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||
WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let {
|
||||
class: className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
ref = $bindable(null),
|
||||
href = undefined,
|
||||
type = "button",
|
||||
disabled,
|
||||
children,
|
||||
...restProps
|
||||
}: ButtonProps = $props();
|
||||
let {
|
||||
class: className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
ref = $bindable(null),
|
||||
href = undefined,
|
||||
type = "button",
|
||||
disabled,
|
||||
children,
|
||||
...restProps
|
||||
}: ButtonProps = $props();
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
href={disabled ? undefined : href}
|
||||
aria-disabled={disabled}
|
||||
role={disabled ? "link" : undefined}
|
||||
tabindex={disabled ? -1 : undefined}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
<a
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
href={disabled ? undefined : href}
|
||||
aria-disabled={disabled}
|
||||
role={disabled ? "link" : undefined}
|
||||
tabindex={disabled ? -1 : undefined}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
{type}
|
||||
{disabled}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
<button
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
{type}
|
||||
{disabled}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import Root, {
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
buttonVariants,
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
buttonVariants,
|
||||
} from "./button.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
type ButtonProps as Props,
|
||||
//
|
||||
Root as Button,
|
||||
buttonVariants,
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
Root,
|
||||
type ButtonProps as Props,
|
||||
//
|
||||
Root as Button,
|
||||
buttonVariants,
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
};
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-action"
|
||||
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||
{...restProps}
|
||||
bind:this={ref}
|
||||
data-slot="card-action"
|
||||
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
|
||||
</script>
|
||||
|
||||
<p
|
||||
bind:this={ref}
|
||||
data-slot="card-description"
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
{...restProps}
|
||||
bind:this={ref}
|
||||
data-slot="card-description"
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{@render children?.()}
|
||||
</p>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-footer"
|
||||
class={cn("[.border-t]:pt-6 flex items-center px-6", className)}
|
||||
{...restProps}
|
||||
bind:this={ref}
|
||||
data-slot="card-footer"
|
||||
class={cn("[.border-t]:pt-6 flex items-center px-6", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-header"
|
||||
class={cn(
|
||||
"@container/card-header has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
bind:this={ref}
|
||||
data-slot="card-header"
|
||||
class={cn(
|
||||
"@container/card-header has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-title"
|
||||
class={cn("font-semibold leading-none", className)}
|
||||
{...restProps}
|
||||
bind:this={ref}
|
||||
data-slot="card-title"
|
||||
class={cn("font-semibold leading-none", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card"
|
||||
class={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
bind:this={ref}
|
||||
data-slot="card"
|
||||
class={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -7,19 +7,19 @@ import Title from "./card-title.svelte";
|
||||
import Action from "./card-action.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Description,
|
||||
Footer,
|
||||
Header,
|
||||
Title,
|
||||
Action,
|
||||
//
|
||||
Root as Card,
|
||||
Content as CardContent,
|
||||
Description as CardDescription,
|
||||
Footer as CardFooter,
|
||||
Header as CardHeader,
|
||||
Title as CardTitle,
|
||||
Action as CardAction,
|
||||
Root,
|
||||
Content,
|
||||
Description,
|
||||
Footer,
|
||||
Header,
|
||||
Title,
|
||||
Action,
|
||||
//
|
||||
Root as Card,
|
||||
Content as CardContent,
|
||||
Description as CardDescription,
|
||||
Footer as CardFooter,
|
||||
Header as CardHeader,
|
||||
Title as CardTitle,
|
||||
Action as CardAction,
|
||||
};
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { Checkbox as CheckboxPrimitive } from "bits-ui";
|
||||
import CheckIcon from "@lucide/svelte/icons/check";
|
||||
import MinusIcon from "@lucide/svelte/icons/minus";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import { Checkbox as CheckboxPrimitive } from "bits-ui";
|
||||
import CheckIcon from "@lucide/svelte/icons/check";
|
||||
import MinusIcon from "@lucide/svelte/icons/minus";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
checked = $bindable(false),
|
||||
indeterminate = $bindable(false),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
checked = $bindable(false),
|
||||
indeterminate = $bindable(false),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
|
||||
</script>
|
||||
|
||||
<CheckboxPrimitive.Root
|
||||
bind:ref
|
||||
data-slot="checkbox"
|
||||
class={cn(
|
||||
"border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shadow-xs peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border outline-none transition-shadow focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
bind:checked
|
||||
bind:indeterminate
|
||||
{...restProps}
|
||||
bind:ref
|
||||
data-slot="checkbox"
|
||||
class={cn(
|
||||
"border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shadow-xs peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border outline-none transition-shadow focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
bind:checked
|
||||
bind:indeterminate
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ checked, indeterminate })}
|
||||
<div data-slot="checkbox-indicator" class="text-current transition-none">
|
||||
{#if checked}
|
||||
<CheckIcon class="size-3.5" />
|
||||
{:else if indeterminate}
|
||||
<MinusIcon class="size-3.5" />
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet children({ checked, indeterminate })}
|
||||
<div data-slot="checkbox-indicator" class="text-current transition-none">
|
||||
{#if checked}
|
||||
<CheckIcon class="size-3.5" />
|
||||
{:else if indeterminate}
|
||||
<MinusIcon class="size-3.5" />
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</CheckboxPrimitive.Root>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Root from "./checkbox.svelte";
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Checkbox,
|
||||
Root,
|
||||
//
|
||||
Root as Checkbox,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps =
|
||||
$props();
|
||||
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import XIcon from "@lucide/svelte/icons/x";
|
||||
import type { Snippet } from "svelte";
|
||||
import * as Dialog from "./index.js";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import XIcon from "@lucide/svelte/icons/x";
|
||||
import type { Snippet } from "svelte";
|
||||
import * as Dialog from "./index.js";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
portalProps,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
||||
portalProps?: DialogPrimitive.PortalProps;
|
||||
children: Snippet;
|
||||
showCloseButton?: boolean;
|
||||
} = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
portalProps,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
||||
portalProps?: DialogPrimitive.PortalProps;
|
||||
children: Snippet;
|
||||
showCloseButton?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Dialog.Portal {...portalProps}>
|
||||
<Dialog.Overlay />
|
||||
<DialogPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="dialog-content"
|
||||
class={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{#if showCloseButton}
|
||||
<DialogPrimitive.Close
|
||||
class="ring-offset-background focus:ring-ring rounded-xs focus:outline-hidden absolute end-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
|
||||
>
|
||||
<XIcon />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
{/if}
|
||||
</DialogPrimitive.Content>
|
||||
<Dialog.Overlay />
|
||||
<DialogPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="dialog-content"
|
||||
class={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{#if showCloseButton}
|
||||
<DialogPrimitive.Close
|
||||
class="ring-offset-background focus:ring-ring rounded-xs focus:outline-hidden absolute end-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
|
||||
>
|
||||
<XIcon />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
{/if}
|
||||
</DialogPrimitive.Content>
|
||||
</Dialog.Portal>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DialogPrimitive.DescriptionProps = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DialogPrimitive.DescriptionProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Description
|
||||
bind:ref
|
||||
data-slot="dialog-description"
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
{...restProps}
|
||||
bind:ref
|
||||
data-slot="dialog-description"
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="dialog-footer"
|
||||
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...restProps}
|
||||
bind:this={ref}
|
||||
data-slot="dialog-footer"
|
||||
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="dialog-header"
|
||||
class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...restProps}
|
||||
bind:this={ref}
|
||||
data-slot="dialog-header"
|
||||
class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DialogPrimitive.OverlayProps = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DialogPrimitive.OverlayProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Overlay
|
||||
bind:ref
|
||||
data-slot="dialog-overlay"
|
||||
class={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
bind:ref
|
||||
data-slot="dialog-overlay"
|
||||
class={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DialogPrimitive.TitleProps = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DialogPrimitive.TitleProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Title
|
||||
bind:ref
|
||||
data-slot="dialog-title"
|
||||
class={cn("text-lg font-semibold leading-none", className)}
|
||||
{...restProps}
|
||||
bind:ref
|
||||
data-slot="dialog-title"
|
||||
class={cn("text-lg font-semibold leading-none", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps =
|
||||
$props();
|
||||
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />
|
||||
|
||||
@@ -13,25 +13,25 @@ const Root = DialogPrimitive.Root;
|
||||
const Portal = DialogPrimitive.Portal;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Title,
|
||||
Portal,
|
||||
Footer,
|
||||
Header,
|
||||
Trigger,
|
||||
Overlay,
|
||||
Content,
|
||||
Description,
|
||||
Close,
|
||||
//
|
||||
Root as Dialog,
|
||||
Title as DialogTitle,
|
||||
Portal as DialogPortal,
|
||||
Footer as DialogFooter,
|
||||
Header as DialogHeader,
|
||||
Trigger as DialogTrigger,
|
||||
Overlay as DialogOverlay,
|
||||
Content as DialogContent,
|
||||
Description as DialogDescription,
|
||||
Close as DialogClose,
|
||||
Root,
|
||||
Title,
|
||||
Portal,
|
||||
Footer,
|
||||
Header,
|
||||
Trigger,
|
||||
Overlay,
|
||||
Content,
|
||||
Description,
|
||||
Close,
|
||||
//
|
||||
Root as Dialog,
|
||||
Title as DialogTitle,
|
||||
Portal as DialogPortal,
|
||||
Footer as DialogFooter,
|
||||
Header as DialogHeader,
|
||||
Trigger as DialogTrigger,
|
||||
Overlay as DialogOverlay,
|
||||
Content as DialogContent,
|
||||
Description as DialogDescription,
|
||||
Close as DialogClose,
|
||||
};
|
||||
|
||||
@@ -3,183 +3,174 @@
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils/utils";
|
||||
import UploadIcon from "@lucide/svelte/icons/upload";
|
||||
import { displaySize } from ".";
|
||||
import { useId } from "bits-ui";
|
||||
import type { FileDropZoneProps, FileRejectedReason } from "./types";
|
||||
import { cn } from "$lib/utils/utils";
|
||||
import UploadIcon from "@lucide/svelte/icons/upload";
|
||||
import { displaySize } from ".";
|
||||
import { useId } from "bits-ui";
|
||||
import type { FileDropZoneProps, FileRejectedReason } from "./types";
|
||||
|
||||
let {
|
||||
id = useId(),
|
||||
children,
|
||||
maxFiles,
|
||||
maxFileSize,
|
||||
fileCount,
|
||||
disabled = false,
|
||||
onUpload,
|
||||
onFileRejected,
|
||||
accept,
|
||||
class: className,
|
||||
...rest
|
||||
}: FileDropZoneProps = $props();
|
||||
let {
|
||||
id = useId(),
|
||||
children,
|
||||
maxFiles,
|
||||
maxFileSize,
|
||||
fileCount,
|
||||
disabled = false,
|
||||
onUpload,
|
||||
onFileRejected,
|
||||
accept,
|
||||
class: className,
|
||||
...rest
|
||||
}: FileDropZoneProps = $props();
|
||||
|
||||
if (maxFiles !== undefined && fileCount === undefined) {
|
||||
console.warn(
|
||||
"Make sure to provide FileDropZone with `fileCount` when using the `maxFiles` prompt",
|
||||
);
|
||||
}
|
||||
if (maxFiles !== undefined && fileCount === undefined) {
|
||||
console.warn(
|
||||
"Make sure to provide FileDropZone with `fileCount` when using the `maxFiles` prompt",
|
||||
);
|
||||
}
|
||||
|
||||
let uploading = $state(false);
|
||||
let uploading = $state(false);
|
||||
|
||||
const drop = async (
|
||||
e: DragEvent & {
|
||||
currentTarget: EventTarget & HTMLLabelElement;
|
||||
},
|
||||
) => {
|
||||
if (disabled || !canUploadFiles) return;
|
||||
const drop = async (
|
||||
e: DragEvent & {
|
||||
currentTarget: EventTarget & HTMLLabelElement;
|
||||
},
|
||||
) => {
|
||||
if (disabled || !canUploadFiles) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.preventDefault();
|
||||
|
||||
const droppedFiles = Array.from(e.dataTransfer?.files ?? []);
|
||||
const droppedFiles = Array.from(e.dataTransfer?.files ?? []);
|
||||
|
||||
await upload(droppedFiles);
|
||||
};
|
||||
await upload(droppedFiles);
|
||||
};
|
||||
|
||||
const change = async (
|
||||
e: Event & {
|
||||
currentTarget: EventTarget & HTMLInputElement;
|
||||
},
|
||||
) => {
|
||||
if (disabled) return;
|
||||
const change = async (
|
||||
e: Event & {
|
||||
currentTarget: EventTarget & HTMLInputElement;
|
||||
},
|
||||
) => {
|
||||
if (disabled) return;
|
||||
|
||||
const selectedFiles = e.currentTarget.files;
|
||||
const selectedFiles = e.currentTarget.files;
|
||||
|
||||
if (!selectedFiles) return;
|
||||
if (!selectedFiles) return;
|
||||
|
||||
await upload(Array.from(selectedFiles));
|
||||
await upload(Array.from(selectedFiles));
|
||||
|
||||
// this if a file fails and we upload the same file again we still get feedback
|
||||
(e.target as HTMLInputElement).value = "";
|
||||
};
|
||||
// this if a file fails and we upload the same file again we still get feedback
|
||||
(e.target as HTMLInputElement).value = "";
|
||||
};
|
||||
|
||||
const shouldAcceptFile = (
|
||||
file: File,
|
||||
fileNumber: number,
|
||||
): FileRejectedReason | undefined => {
|
||||
if (maxFileSize !== undefined && file.size > maxFileSize)
|
||||
return "Maximum file size exceeded";
|
||||
const shouldAcceptFile = (file: File, fileNumber: number): FileRejectedReason | undefined => {
|
||||
if (maxFileSize !== undefined && file.size > maxFileSize) return "Maximum file size exceeded";
|
||||
|
||||
if (maxFiles !== undefined && fileNumber > maxFiles)
|
||||
return "Maximum files uploaded";
|
||||
if (maxFiles !== undefined && fileNumber > maxFiles) return "Maximum files uploaded";
|
||||
|
||||
if (!accept) return undefined;
|
||||
if (!accept) return undefined;
|
||||
|
||||
const acceptedTypes = accept.split(",").map((a) => a.trim().toLowerCase());
|
||||
const fileType = file.type.toLowerCase();
|
||||
const fileName = file.name.toLowerCase();
|
||||
const acceptedTypes = accept.split(",").map((a) => a.trim().toLowerCase());
|
||||
const fileType = file.type.toLowerCase();
|
||||
const fileName = file.name.toLowerCase();
|
||||
|
||||
const isAcceptable = acceptedTypes.some((pattern) => {
|
||||
// check extension like .mp4
|
||||
if (fileType.startsWith(".")) {
|
||||
return fileName.endsWith(pattern);
|
||||
}
|
||||
const isAcceptable = acceptedTypes.some((pattern) => {
|
||||
// check extension like .mp4
|
||||
if (fileType.startsWith(".")) {
|
||||
return fileName.endsWith(pattern);
|
||||
}
|
||||
|
||||
// if pattern has wild card like video/*
|
||||
if (pattern.endsWith("/*")) {
|
||||
const baseType = pattern.slice(0, pattern.indexOf("/*"));
|
||||
return fileType.startsWith(baseType + "/");
|
||||
}
|
||||
// if pattern has wild card like video/*
|
||||
if (pattern.endsWith("/*")) {
|
||||
const baseType = pattern.slice(0, pattern.indexOf("/*"));
|
||||
return fileType.startsWith(baseType + "/");
|
||||
}
|
||||
|
||||
// otherwise it must be a specific type like video/mp4
|
||||
return fileType === pattern;
|
||||
});
|
||||
// otherwise it must be a specific type like video/mp4
|
||||
return fileType === pattern;
|
||||
});
|
||||
|
||||
if (!isAcceptable) return "File type not allowed";
|
||||
if (!isAcceptable) return "File type not allowed";
|
||||
|
||||
return undefined;
|
||||
};
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const upload = async (uploadFiles: File[]) => {
|
||||
uploading = true;
|
||||
const upload = async (uploadFiles: File[]) => {
|
||||
uploading = true;
|
||||
|
||||
const validFiles: File[] = [];
|
||||
const validFiles: File[] = [];
|
||||
|
||||
for (let i = 0; i < uploadFiles.length; i++) {
|
||||
const file = uploadFiles[i];
|
||||
for (let i = 0; i < uploadFiles.length; i++) {
|
||||
const file = uploadFiles[i];
|
||||
|
||||
const rejectedReason = shouldAcceptFile(file, (fileCount ?? 0) + i + 1);
|
||||
const rejectedReason = shouldAcceptFile(file, (fileCount ?? 0) + i + 1);
|
||||
|
||||
if (rejectedReason) {
|
||||
onFileRejected?.({ file, reason: rejectedReason });
|
||||
continue;
|
||||
}
|
||||
if (rejectedReason) {
|
||||
onFileRejected?.({ file, reason: rejectedReason });
|
||||
continue;
|
||||
}
|
||||
|
||||
validFiles.push(file);
|
||||
}
|
||||
validFiles.push(file);
|
||||
}
|
||||
|
||||
await onUpload(validFiles);
|
||||
await onUpload(validFiles);
|
||||
|
||||
uploading = false;
|
||||
};
|
||||
uploading = false;
|
||||
};
|
||||
|
||||
const canUploadFiles = $derived(
|
||||
!disabled &&
|
||||
!uploading &&
|
||||
!(
|
||||
maxFiles !== undefined &&
|
||||
fileCount !== undefined &&
|
||||
fileCount >= maxFiles
|
||||
),
|
||||
);
|
||||
const canUploadFiles = $derived(
|
||||
!disabled &&
|
||||
!uploading &&
|
||||
!(maxFiles !== undefined && fileCount !== undefined && fileCount >= maxFiles),
|
||||
);
|
||||
</script>
|
||||
|
||||
<label
|
||||
ondragover={(e) => e.preventDefault()}
|
||||
ondrop={drop}
|
||||
for={id}
|
||||
aria-disabled={!canUploadFiles}
|
||||
class={cn(
|
||||
"border-border hover:bg-accent/25 flex h-48 w-full place-items-center justify-center rounded-lg border-2 border-dashed p-6 transition-all hover:cursor-pointer aria-disabled:opacity-50 aria-disabled:hover:cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
ondragover={(e) => e.preventDefault()}
|
||||
ondrop={drop}
|
||||
for={id}
|
||||
aria-disabled={!canUploadFiles}
|
||||
class={cn(
|
||||
"border-border hover:bg-accent/25 flex h-48 w-full place-items-center justify-center rounded-lg border-2 border-dashed p-6 transition-all hover:cursor-pointer aria-disabled:opacity-50 aria-disabled:hover:cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<div class="flex flex-col place-items-center justify-center gap-2">
|
||||
<div
|
||||
class="border-border text-muted-foreground flex size-14 place-items-center justify-center rounded-full border border-dashed"
|
||||
>
|
||||
<UploadIcon class="size-7" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5 text-center">
|
||||
<span class="text-muted-foreground font-medium">
|
||||
Drag 'n' drop files here, or click to select files
|
||||
</span>
|
||||
{#if maxFiles || maxFileSize}
|
||||
<span class="text-muted-foreground/75 text-sm">
|
||||
{#if maxFiles}
|
||||
<span>You can upload {maxFiles} files</span>
|
||||
{/if}
|
||||
{#if maxFiles && maxFileSize}
|
||||
<span>(up to {displaySize(maxFileSize)} each)</span>
|
||||
{/if}
|
||||
{#if maxFileSize && !maxFiles}
|
||||
<span>Maximum size {displaySize(maxFileSize)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<input
|
||||
{...rest}
|
||||
disabled={!canUploadFiles}
|
||||
{id}
|
||||
{accept}
|
||||
multiple={maxFiles === undefined || maxFiles - (fileCount ?? 0) > 1}
|
||||
type="file"
|
||||
onchange={change}
|
||||
class="hidden"
|
||||
/>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<div class="flex flex-col place-items-center justify-center gap-2">
|
||||
<div
|
||||
class="border-border text-muted-foreground flex size-14 place-items-center justify-center rounded-full border border-dashed"
|
||||
>
|
||||
<UploadIcon class="size-7" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5 text-center">
|
||||
<span class="text-muted-foreground font-medium">
|
||||
Drag 'n' drop files here, or click to select files
|
||||
</span>
|
||||
{#if maxFiles || maxFileSize}
|
||||
<span class="text-muted-foreground/75 text-sm">
|
||||
{#if maxFiles}
|
||||
<span>You can upload {maxFiles} files</span>
|
||||
{/if}
|
||||
{#if maxFiles && maxFileSize}
|
||||
<span>(up to {displaySize(maxFileSize)} each)</span>
|
||||
{/if}
|
||||
{#if maxFileSize && !maxFiles}
|
||||
<span>Maximum size {displaySize(maxFileSize)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<input
|
||||
{...rest}
|
||||
disabled={!canUploadFiles}
|
||||
{id}
|
||||
{accept}
|
||||
multiple={maxFiles === undefined || maxFiles - (fileCount ?? 0) > 1}
|
||||
type="file"
|
||||
onchange={change}
|
||||
class="hidden"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -6,13 +6,13 @@ import FileDropZone from "./file-drop-zone.svelte";
|
||||
import { type FileRejectedReason, type FileDropZoneProps } from "./types";
|
||||
|
||||
export const displaySize = (bytes: number): string => {
|
||||
if (bytes < KILOBYTE) return `${bytes.toFixed(0)} B`;
|
||||
if (bytes < KILOBYTE) return `${bytes.toFixed(0)} B`;
|
||||
|
||||
if (bytes < MEGABYTE) return `${(bytes / KILOBYTE).toFixed(0)} KB`;
|
||||
if (bytes < MEGABYTE) return `${(bytes / KILOBYTE).toFixed(0)} KB`;
|
||||
|
||||
if (bytes < GIGABYTE) return `${(bytes / MEGABYTE).toFixed(0)} MB`;
|
||||
if (bytes < GIGABYTE) return `${(bytes / MEGABYTE).toFixed(0)} MB`;
|
||||
|
||||
return `${(bytes / GIGABYTE).toFixed(0)} GB`;
|
||||
return `${(bytes / GIGABYTE).toFixed(0)} GB`;
|
||||
};
|
||||
|
||||
// Utilities for working with file sizes
|
||||
|
||||
@@ -6,46 +6,46 @@ import type { WithChildren } from "bits-ui";
|
||||
import type { HTMLInputAttributes } from "svelte/elements";
|
||||
|
||||
export type FileRejectedReason =
|
||||
| "Maximum file size exceeded"
|
||||
| "File type not allowed"
|
||||
| "Maximum files uploaded";
|
||||
| "Maximum file size exceeded"
|
||||
| "File type not allowed"
|
||||
| "Maximum files uploaded";
|
||||
|
||||
export type FileDropZonePropsWithoutHTML = WithChildren<{
|
||||
ref?: HTMLInputElement | null;
|
||||
/** Called with the uploaded files when the user drops or clicks and selects their files.
|
||||
*
|
||||
* @param files
|
||||
*/
|
||||
onUpload: (files: File[]) => Promise<void>;
|
||||
/** The maximum amount files allowed to be uploaded */
|
||||
maxFiles?: number;
|
||||
fileCount?: number;
|
||||
/** The maximum size of a file in bytes */
|
||||
maxFileSize?: number;
|
||||
/** Called when a file does not meet the upload criteria (size, or type) */
|
||||
onFileRejected?: (opts: { reason: FileRejectedReason; file: File }) => void;
|
||||
ref?: HTMLInputElement | null;
|
||||
/** Called with the uploaded files when the user drops or clicks and selects their files.
|
||||
*
|
||||
* @param files
|
||||
*/
|
||||
onUpload: (files: File[]) => Promise<void>;
|
||||
/** The maximum amount files allowed to be uploaded */
|
||||
maxFiles?: number;
|
||||
fileCount?: number;
|
||||
/** The maximum size of a file in bytes */
|
||||
maxFileSize?: number;
|
||||
/** Called when a file does not meet the upload criteria (size, or type) */
|
||||
onFileRejected?: (opts: { reason: FileRejectedReason; file: File }) => void;
|
||||
|
||||
// just for extra documentation
|
||||
/** Takes a comma separated list of one or more file types.
|
||||
*
|
||||
* [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept)
|
||||
*
|
||||
* ### Usage
|
||||
* ```svelte
|
||||
* <FileDropZone
|
||||
* accept=".doc,.docx,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* ### Common Values
|
||||
* ```svelte
|
||||
* <FileDropZone accept="audio/*"/>
|
||||
* <FileDropZone accept="image/*"/>
|
||||
* <FileDropZone accept="video/*"/>
|
||||
* ```
|
||||
*/
|
||||
accept?: string;
|
||||
// just for extra documentation
|
||||
/** Takes a comma separated list of one or more file types.
|
||||
*
|
||||
* [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept)
|
||||
*
|
||||
* ### Usage
|
||||
* ```svelte
|
||||
* <FileDropZone
|
||||
* accept=".doc,.docx,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* ### Common Values
|
||||
* ```svelte
|
||||
* <FileDropZone accept="audio/*"/>
|
||||
* <FileDropZone accept="image/*"/>
|
||||
* <FileDropZone accept="video/*"/>
|
||||
* ```
|
||||
*/
|
||||
accept?: string;
|
||||
}>;
|
||||
|
||||
export type FileDropZoneProps = FileDropZonePropsWithoutHTML &
|
||||
Omit<HTMLInputAttributes, "multiple" | "files">;
|
||||
Omit<HTMLInputAttributes, "multiple" | "files">;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Root from "./input.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Input,
|
||||
Root,
|
||||
//
|
||||
Root as Input,
|
||||
};
|
||||
|
||||
@@ -1,57 +1,51 @@
|
||||
<script lang="ts">
|
||||
import type {
|
||||
HTMLInputAttributes,
|
||||
HTMLInputTypeAttribute,
|
||||
} from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
|
||||
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
|
||||
|
||||
type Props = WithElementRef<
|
||||
Omit<HTMLInputAttributes, "type"> &
|
||||
(
|
||||
| { type: "file"; files?: FileList }
|
||||
| { type?: InputType; files?: undefined }
|
||||
)
|
||||
>;
|
||||
type Props = WithElementRef<
|
||||
Omit<HTMLInputAttributes, "type"> &
|
||||
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
|
||||
>;
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
type,
|
||||
files = $bindable(),
|
||||
class: className,
|
||||
...restProps
|
||||
}: Props = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
type,
|
||||
files = $bindable(),
|
||||
class: className,
|
||||
...restProps
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if type === "file"}
|
||||
<input
|
||||
bind:this={ref}
|
||||
data-slot="input"
|
||||
class={cn(
|
||||
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
type="file"
|
||||
bind:files
|
||||
bind:value
|
||||
{...restProps}
|
||||
/>
|
||||
<input
|
||||
bind:this={ref}
|
||||
data-slot="input"
|
||||
class={cn(
|
||||
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
type="file"
|
||||
bind:files
|
||||
bind:value
|
||||
{...restProps}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
bind:this={ref}
|
||||
data-slot="input"
|
||||
class={cn(
|
||||
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
{type}
|
||||
bind:value
|
||||
{...restProps}
|
||||
/>
|
||||
<input
|
||||
bind:this={ref}
|
||||
data-slot="input"
|
||||
class={cn(
|
||||
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
{type}
|
||||
bind:value
|
||||
{...restProps}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Root from "./label.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Label,
|
||||
Root,
|
||||
//
|
||||
Root as Label,
|
||||
};
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Label as LabelPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Label as LabelPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: LabelPrimitive.RootProps = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: LabelPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<LabelPrimitive.Root
|
||||
bind:ref
|
||||
data-slot="label"
|
||||
class={cn(
|
||||
"flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
bind:ref
|
||||
data-slot="label"
|
||||
class={cn(
|
||||
"flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
|
||||
@@ -13,25 +13,25 @@ import GroupHeading from "./select-group-heading.svelte";
|
||||
const Root = SelectPrimitive.Root;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Group,
|
||||
Label,
|
||||
Item,
|
||||
Content,
|
||||
Trigger,
|
||||
Separator,
|
||||
ScrollDownButton,
|
||||
ScrollUpButton,
|
||||
GroupHeading,
|
||||
//
|
||||
Root as Select,
|
||||
Group as SelectGroup,
|
||||
Label as SelectLabel,
|
||||
Item as SelectItem,
|
||||
Content as SelectContent,
|
||||
Trigger as SelectTrigger,
|
||||
Separator as SelectSeparator,
|
||||
ScrollDownButton as SelectScrollDownButton,
|
||||
ScrollUpButton as SelectScrollUpButton,
|
||||
GroupHeading as SelectGroupHeading,
|
||||
Root,
|
||||
Group,
|
||||
Label,
|
||||
Item,
|
||||
Content,
|
||||
Trigger,
|
||||
Separator,
|
||||
ScrollDownButton,
|
||||
ScrollUpButton,
|
||||
GroupHeading,
|
||||
//
|
||||
Root as Select,
|
||||
Group as SelectGroup,
|
||||
Label as SelectLabel,
|
||||
Item as SelectItem,
|
||||
Content as SelectContent,
|
||||
Trigger as SelectTrigger,
|
||||
Separator as SelectSeparator,
|
||||
ScrollDownButton as SelectScrollDownButton,
|
||||
ScrollUpButton as SelectScrollUpButton,
|
||||
GroupHeading as SelectGroupHeading,
|
||||
};
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
|
||||
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
|
||||
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
sideOffset = 4,
|
||||
portalProps,
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChild<SelectPrimitive.ContentProps> & {
|
||||
portalProps?: SelectPrimitive.PortalProps;
|
||||
} = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
sideOffset = 4,
|
||||
portalProps,
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChild<SelectPrimitive.ContentProps> & {
|
||||
portalProps?: SelectPrimitive.PortalProps;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Portal {...portalProps}>
|
||||
<SelectPrimitive.Content
|
||||
bind:ref
|
||||
{sideOffset}
|
||||
data-slot="select-content"
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--bits-select-content-available-height) origin-(--bits-select-content-transform-origin) relative z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
<SelectPrimitive.Content
|
||||
bind:ref
|
||||
{sideOffset}
|
||||
data-slot="select-content"
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--bits-select-content-available-height) origin-(--bits-select-content-transform-origin) relative z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
class={cn(
|
||||
"h-(--bits-select-anchor-height) min-w-(--bits-select-anchor-width) w-full scroll-my-1 p-1",
|
||||
)}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
class={cn(
|
||||
"h-(--bits-select-anchor-height) min-w-(--bits-select-anchor-width) w-full scroll-my-1 p-1",
|
||||
)}
|
||||
>
|
||||
{@render children?.()}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
{@render children?.()}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { ComponentProps } from "svelte";
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { ComponentProps } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.GroupHeading
|
||||
bind:ref
|
||||
data-slot="select-group-heading"
|
||||
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...restProps}
|
||||
bind:ref
|
||||
data-slot="select-group-heading"
|
||||
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{@render children?.()}
|
||||
</SelectPrimitive.GroupHeading>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
|
||||
// eslint-disable-next-line no-useless-assignment
|
||||
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps =
|
||||
$props();
|
||||
// eslint-disable-next-line no-useless-assignment
|
||||
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Group data-slot="select-group" {...restProps} />
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
<script lang="ts">
|
||||
import CheckIcon from "@lucide/svelte/icons/check";
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
import CheckIcon from "@lucide/svelte/icons/check";
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
value,
|
||||
label,
|
||||
children: childrenProp,
|
||||
...restProps
|
||||
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
value,
|
||||
label,
|
||||
children: childrenProp,
|
||||
...restProps
|
||||
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Item
|
||||
bind:ref
|
||||
{value}
|
||||
data-slot="select-item"
|
||||
class={cn(
|
||||
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
bind:ref
|
||||
{value}
|
||||
data-slot="select-item"
|
||||
class={cn(
|
||||
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ selected, highlighted })}
|
||||
<span class="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
{#if selected}
|
||||
<CheckIcon class="size-4" />
|
||||
{/if}
|
||||
</span>
|
||||
{#if childrenProp}
|
||||
{@render childrenProp({ selected, highlighted })}
|
||||
{:else}
|
||||
{label || value}
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet children({ selected, highlighted })}
|
||||
<span class="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
{#if selected}
|
||||
<CheckIcon class="size-4" />
|
||||
{/if}
|
||||
</span>
|
||||
{#if childrenProp}
|
||||
{@render childrenProp({ selected, highlighted })}
|
||||
{:else}
|
||||
{label || value}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SelectPrimitive.Item>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="select-label"
|
||||
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...restProps}
|
||||
bind:this={ref}
|
||||
data-slot="select-label"
|
||||
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script lang="ts">
|
||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
bind:ref
|
||||
data-slot="select-scroll-down-button"
|
||||
class={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...restProps}
|
||||
bind:ref
|
||||
data-slot="select-scroll-down-button"
|
||||
class={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...restProps}
|
||||
>
|
||||
<ChevronDownIcon class="size-4" />
|
||||
<ChevronDownIcon class="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script lang="ts">
|
||||
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
bind:ref
|
||||
data-slot="select-scroll-up-button"
|
||||
class={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...restProps}
|
||||
bind:ref
|
||||
data-slot="select-scroll-up-button"
|
||||
class={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...restProps}
|
||||
>
|
||||
<ChevronUpIcon class="size-4" />
|
||||
<ChevronUpIcon class="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<script lang="ts">
|
||||
import type { Separator as SeparatorPrimitive } from "bits-ui";
|
||||
import { Separator } from "$lib/components/ui/separator/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { Separator as SeparatorPrimitive } from "bits-ui";
|
||||
import { Separator } from "$lib/components/ui/separator/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: SeparatorPrimitive.RootProps = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: SeparatorPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<Separator
|
||||
bind:ref
|
||||
data-slot="select-separator"
|
||||
class={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...restProps}
|
||||
bind:ref
|
||||
data-slot="select-separator"
|
||||
class={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
size = "default",
|
||||
...restProps
|
||||
}: WithoutChild<SelectPrimitive.TriggerProps> & {
|
||||
size?: "sm" | "default";
|
||||
} = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
size = "default",
|
||||
...restProps
|
||||
}: WithoutChild<SelectPrimitive.TriggerProps> & {
|
||||
size?: "sm" | "default";
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Trigger
|
||||
bind:ref
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
class={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 shadow-xs flex w-fit select-none items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
bind:ref
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
class={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 shadow-xs flex w-fit select-none items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<ChevronDownIcon class="size-4 opacity-50" />
|
||||
{@render children?.()}
|
||||
<ChevronDownIcon class="size-4 opacity-50" />
|
||||
</SelectPrimitive.Trigger>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Root from "./separator.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Separator,
|
||||
Root,
|
||||
//
|
||||
Root as Separator,
|
||||
};
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Separator as SeparatorPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Separator as SeparatorPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: SeparatorPrimitive.RootProps = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: SeparatorPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<SeparatorPrimitive.Root
|
||||
bind:ref
|
||||
data-slot="separator"
|
||||
class={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
bind:ref
|
||||
data-slot="separator"
|
||||
class={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Root from "./slider.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Slider,
|
||||
Root,
|
||||
//
|
||||
Root as Slider,
|
||||
};
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { Slider as SliderPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import { Slider as SliderPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
orientation = "horizontal",
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
orientation = "horizontal",
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> = $props();
|
||||
</script>
|
||||
|
||||
<!--
|
||||
@@ -16,37 +16,37 @@ Discriminated Unions + Destructing (required for bindable) do not
|
||||
get along, so we shut typescript up by casting `value` to `never`.
|
||||
-->
|
||||
<SliderPrimitive.Root
|
||||
bind:ref
|
||||
bind:value={value as never}
|
||||
data-slot="slider"
|
||||
{orientation}
|
||||
class={cn(
|
||||
"relative flex w-full touch-none select-none items-center data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
bind:ref
|
||||
bind:value={value as never}
|
||||
data-slot="slider"
|
||||
{orientation}
|
||||
class={cn(
|
||||
"relative flex w-full touch-none select-none items-center data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ thumbs })}
|
||||
<span
|
||||
data-orientation={orientation}
|
||||
data-slot="slider-track"
|
||||
class={cn(
|
||||
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-1.5",
|
||||
)}
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
class={cn(
|
||||
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
{#each thumbs as thumb (thumb)}
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
index={thumb}
|
||||
class="border-primary bg-background ring-ring/50 focus-visible:outline-hidden block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
{/each}
|
||||
{/snippet}
|
||||
{#snippet children({ thumbs })}
|
||||
<span
|
||||
data-orientation={orientation}
|
||||
data-slot="slider-track"
|
||||
class={cn(
|
||||
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-1.5",
|
||||
)}
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
class={cn(
|
||||
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
{#each thumbs as thumb (thumb)}
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
index={thumb}
|
||||
class="border-primary bg-background ring-ring/50 focus-visible:outline-hidden block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
{/each}
|
||||
{/snippet}
|
||||
</SliderPrimitive.Root>
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Toaster as Sonner,
|
||||
type ToasterProps as SonnerProps,
|
||||
} from "svelte-sonner";
|
||||
import { mode } from "mode-watcher";
|
||||
import { Toaster as Sonner, type ToasterProps as SonnerProps } from "svelte-sonner";
|
||||
import { mode } from "mode-watcher";
|
||||
|
||||
let { ...restProps }: SonnerProps = $props();
|
||||
let { ...restProps }: SonnerProps = $props();
|
||||
</script>
|
||||
|
||||
<Sonner
|
||||
theme={mode.current}
|
||||
class="toaster group"
|
||||
style="--normal-bg: var(--color-popover); --normal-text: var(--color-popover-foreground); --normal-border: var(--color-border);"
|
||||
{...restProps}
|
||||
theme={mode.current}
|
||||
class="toaster group"
|
||||
style="--normal-bg: var(--color-popover); --normal-text: var(--color-popover-foreground); --normal-border: var(--color-border);"
|
||||
{...restProps}
|
||||
/>
|
||||
|
||||
@@ -4,13 +4,13 @@ import List from "./tabs-list.svelte";
|
||||
import Trigger from "./tabs-trigger.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
List,
|
||||
Trigger,
|
||||
//
|
||||
Root as Tabs,
|
||||
Content as TabsContent,
|
||||
List as TabsList,
|
||||
Trigger as TabsTrigger,
|
||||
Root,
|
||||
Content,
|
||||
List,
|
||||
Trigger,
|
||||
//
|
||||
Root as Tabs,
|
||||
Content as TabsContent,
|
||||
List as TabsList,
|
||||
Trigger as TabsTrigger,
|
||||
};
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: TabsPrimitive.ContentProps = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: TabsPrimitive.ContentProps = $props();
|
||||
</script>
|
||||
|
||||
<TabsPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="tabs-content"
|
||||
class={cn("flex-1 outline-none", className)}
|
||||
{...restProps}
|
||||
bind:ref
|
||||
data-slot="tabs-content"
|
||||
class={cn("flex-1 outline-none", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: TabsPrimitive.ListProps = $props();
|
||||
let { ref = $bindable(null), class: className, ...restProps }: TabsPrimitive.ListProps = $props();
|
||||
</script>
|
||||
|
||||
<TabsPrimitive.List
|
||||
bind:ref
|
||||
data-slot="tabs-list"
|
||||
class={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
bind:ref
|
||||
data-slot="tabs-list"
|
||||
class={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: TabsPrimitive.TriggerProps = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: TabsPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<TabsPrimitive.Trigger
|
||||
bind:ref
|
||||
data-slot="tabs-trigger"
|
||||
class={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 text-sm font-medium transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
bind:ref
|
||||
data-slot="tabs-trigger"
|
||||
class={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 text-sm font-medium transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(""),
|
||||
class: className,
|
||||
...restProps
|
||||
}: TabsPrimitive.RootProps = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(""),
|
||||
class: className,
|
||||
...restProps
|
||||
}: TabsPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<TabsPrimitive.Root
|
||||
bind:ref
|
||||
bind:value
|
||||
data-slot="tabs"
|
||||
class={cn("flex flex-col gap-2", className)}
|
||||
{...restProps}
|
||||
bind:ref
|
||||
bind:value
|
||||
data-slot="tabs"
|
||||
class={cn("flex flex-col gap-2", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
|
||||
@@ -3,26 +3,26 @@
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import XIcon from "@lucide/svelte/icons/x";
|
||||
import XIcon from "@lucide/svelte/icons/x";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
disabled: boolean | null;
|
||||
active: boolean;
|
||||
onDelete: (value: string) => void;
|
||||
};
|
||||
type Props = {
|
||||
value: string;
|
||||
disabled: boolean | null;
|
||||
active: boolean;
|
||||
onDelete: (value: string) => void;
|
||||
};
|
||||
|
||||
let { value, disabled, onDelete, active }: Props = $props();
|
||||
let { value, disabled, onDelete, active }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="bg-secondary ring-offset-background hover:bg-secondary/90 aria-selected:bg-secondary/90 aria-selected:ring-ring flex place-items-center gap-2 rounded-md px-2 py-0.5 transition-all hover:cursor-default aria-selected:ring-2 aria-selected:ring-offset-2"
|
||||
aria-selected={active}
|
||||
class="bg-secondary ring-offset-background hover:bg-secondary/90 aria-selected:bg-secondary/90 aria-selected:ring-ring flex place-items-center gap-2 rounded-md px-2 py-0.5 transition-all hover:cursor-default aria-selected:ring-2 aria-selected:ring-offset-2"
|
||||
aria-selected={active}
|
||||
>
|
||||
<span>
|
||||
{value}
|
||||
</span>
|
||||
<button type="button" {disabled} onclick={() => onDelete(value)}>
|
||||
<XIcon class="size-4" />
|
||||
</button>
|
||||
<span>
|
||||
{value}
|
||||
</span>
|
||||
<button type="button" {disabled} onclick={() => onDelete(value)}>
|
||||
<XIcon class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -3,209 +3,208 @@
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils/utils";
|
||||
import type { TagsInputProps } from "./types";
|
||||
import TagsInputTag from "./tags-input-tag.svelte";
|
||||
import { untrack } from "svelte";
|
||||
import { cn } from "$lib/utils/utils";
|
||||
import type { TagsInputProps } from "./types";
|
||||
import TagsInputTag from "./tags-input-tag.svelte";
|
||||
import { untrack } from "svelte";
|
||||
|
||||
const defaultValidate: TagsInputProps["validate"] = (val, tags) => {
|
||||
const transformed = val.trim();
|
||||
const defaultValidate: TagsInputProps["validate"] = (val, tags) => {
|
||||
const transformed = val.trim();
|
||||
|
||||
// disallow empties
|
||||
if (transformed.length === 0) return undefined;
|
||||
// disallow empties
|
||||
if (transformed.length === 0) return undefined;
|
||||
|
||||
// disallow duplicates
|
||||
if (tags.find((t) => transformed === t)) return undefined;
|
||||
// disallow duplicates
|
||||
if (tags.find((t) => transformed === t)) return undefined;
|
||||
|
||||
return transformed;
|
||||
};
|
||||
return transformed;
|
||||
};
|
||||
|
||||
let {
|
||||
value = $bindable([]),
|
||||
placeholder,
|
||||
class: className,
|
||||
disabled = false,
|
||||
validate = defaultValidate,
|
||||
...rest
|
||||
}: TagsInputProps = $props();
|
||||
let {
|
||||
value = $bindable([]),
|
||||
placeholder,
|
||||
class: className,
|
||||
disabled = false,
|
||||
validate = defaultValidate,
|
||||
...rest
|
||||
}: TagsInputProps = $props();
|
||||
|
||||
let inputValue = $state("");
|
||||
let tagIndex = $state<number>();
|
||||
let invalid = $state(false);
|
||||
let isComposing = $state(false);
|
||||
let inputValue = $state("");
|
||||
let tagIndex = $state<number>();
|
||||
let invalid = $state(false);
|
||||
let isComposing = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
// whenever input value changes reset invalid
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
inputValue;
|
||||
$effect(() => {
|
||||
// whenever input value changes reset invalid
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
inputValue;
|
||||
|
||||
untrack(() => {
|
||||
invalid = false;
|
||||
});
|
||||
});
|
||||
untrack(() => {
|
||||
invalid = false;
|
||||
});
|
||||
});
|
||||
|
||||
const enter = () => {
|
||||
if (isComposing) return;
|
||||
const enter = () => {
|
||||
if (isComposing) return;
|
||||
|
||||
const validated = validate(inputValue, value);
|
||||
const validated = validate(inputValue, value);
|
||||
|
||||
if (!validated) {
|
||||
invalid = true;
|
||||
return;
|
||||
}
|
||||
if (!validated) {
|
||||
invalid = true;
|
||||
return;
|
||||
}
|
||||
|
||||
value = [...value, validated];
|
||||
inputValue = "";
|
||||
};
|
||||
value = [...value, validated];
|
||||
inputValue = "";
|
||||
};
|
||||
|
||||
const compositionStart = () => {
|
||||
isComposing = true;
|
||||
};
|
||||
const compositionStart = () => {
|
||||
isComposing = true;
|
||||
};
|
||||
|
||||
const compositionEnd = () => {
|
||||
isComposing = false;
|
||||
};
|
||||
const compositionEnd = () => {
|
||||
isComposing = false;
|
||||
};
|
||||
|
||||
const keydown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const keydown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
|
||||
if (e.key === "Enter") {
|
||||
// prevent form submit
|
||||
e.preventDefault();
|
||||
if (e.key === "Enter") {
|
||||
// prevent form submit
|
||||
e.preventDefault();
|
||||
|
||||
if (isComposing) return;
|
||||
if (isComposing) return;
|
||||
|
||||
enter();
|
||||
return;
|
||||
}
|
||||
enter();
|
||||
return;
|
||||
}
|
||||
|
||||
const isAtBeginning =
|
||||
target.selectionStart === 0 && target.selectionEnd === 0;
|
||||
const isAtBeginning = target.selectionStart === 0 && target.selectionEnd === 0;
|
||||
|
||||
let shouldResetIndex = true;
|
||||
let shouldResetIndex = true;
|
||||
|
||||
if (e.key === "Backspace") {
|
||||
if (isAtBeginning) {
|
||||
e.preventDefault();
|
||||
if (e.key === "Backspace") {
|
||||
if (isAtBeginning) {
|
||||
e.preventDefault();
|
||||
|
||||
if (tagIndex !== undefined) {
|
||||
deleteIndex(tagIndex);
|
||||
if (tagIndex !== undefined) {
|
||||
deleteIndex(tagIndex);
|
||||
|
||||
// focus previous
|
||||
const prev = tagIndex - 1;
|
||||
// focus previous
|
||||
const prev = tagIndex - 1;
|
||||
|
||||
if (prev < 0) {
|
||||
tagIndex = undefined;
|
||||
} else {
|
||||
tagIndex = prev;
|
||||
}
|
||||
} else {
|
||||
tagIndex = value.length - 1;
|
||||
}
|
||||
if (prev < 0) {
|
||||
tagIndex = undefined;
|
||||
} else {
|
||||
tagIndex = prev;
|
||||
}
|
||||
} else {
|
||||
tagIndex = value.length - 1;
|
||||
}
|
||||
|
||||
shouldResetIndex = false;
|
||||
}
|
||||
}
|
||||
shouldResetIndex = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Delete") {
|
||||
if (isAtBeginning) {
|
||||
if (inputValue.length === 0) {
|
||||
if (tagIndex !== undefined) {
|
||||
e.preventDefault();
|
||||
if (e.key === "Delete") {
|
||||
if (isAtBeginning) {
|
||||
if (inputValue.length === 0) {
|
||||
if (tagIndex !== undefined) {
|
||||
e.preventDefault();
|
||||
|
||||
deleteIndex(tagIndex);
|
||||
deleteIndex(tagIndex);
|
||||
|
||||
// stay focused on the same index unless value.length === 0
|
||||
if (value.length === 0) tagIndex = undefined;
|
||||
// stay focused on the same index unless value.length === 0
|
||||
if (value.length === 0) tagIndex = undefined;
|
||||
|
||||
shouldResetIndex = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
shouldResetIndex = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// controls for tag selection
|
||||
if (isAtBeginning) {
|
||||
// left
|
||||
if (e.key === "ArrowLeft") {
|
||||
if (tagIndex !== undefined) {
|
||||
const prev = tagIndex - 1;
|
||||
// controls for tag selection
|
||||
if (isAtBeginning) {
|
||||
// left
|
||||
if (e.key === "ArrowLeft") {
|
||||
if (tagIndex !== undefined) {
|
||||
const prev = tagIndex - 1;
|
||||
|
||||
if (prev < 0) {
|
||||
tagIndex = 0;
|
||||
} else {
|
||||
tagIndex = prev;
|
||||
}
|
||||
} else {
|
||||
// set initial index
|
||||
tagIndex = value.length - 1;
|
||||
}
|
||||
if (prev < 0) {
|
||||
tagIndex = 0;
|
||||
} else {
|
||||
tagIndex = prev;
|
||||
}
|
||||
} else {
|
||||
// set initial index
|
||||
tagIndex = value.length - 1;
|
||||
}
|
||||
|
||||
shouldResetIndex = false;
|
||||
}
|
||||
shouldResetIndex = false;
|
||||
}
|
||||
|
||||
// right
|
||||
// we can only move right if the value is empty
|
||||
if (inputValue.length === 0) {
|
||||
if (e.key === "ArrowRight") {
|
||||
if (tagIndex !== undefined) {
|
||||
const next = tagIndex + 1;
|
||||
// right
|
||||
// we can only move right if the value is empty
|
||||
if (inputValue.length === 0) {
|
||||
if (e.key === "ArrowRight") {
|
||||
if (tagIndex !== undefined) {
|
||||
const next = tagIndex + 1;
|
||||
|
||||
if (next > value.length - 1) {
|
||||
tagIndex = undefined;
|
||||
} else {
|
||||
tagIndex = next;
|
||||
}
|
||||
if (next > value.length - 1) {
|
||||
tagIndex = undefined;
|
||||
} else {
|
||||
tagIndex = next;
|
||||
}
|
||||
|
||||
shouldResetIndex = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
shouldResetIndex = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reset the tag index to undefined
|
||||
if (shouldResetIndex) {
|
||||
tagIndex = undefined;
|
||||
}
|
||||
};
|
||||
// reset the tag index to undefined
|
||||
if (shouldResetIndex) {
|
||||
tagIndex = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteValue = (val: string) => {
|
||||
const index = value.findIndex((v) => val === v);
|
||||
const deleteValue = (val: string) => {
|
||||
const index = value.findIndex((v) => val === v);
|
||||
|
||||
if (index === -1) return;
|
||||
if (index === -1) return;
|
||||
|
||||
deleteIndex(index);
|
||||
};
|
||||
deleteIndex(index);
|
||||
};
|
||||
|
||||
const deleteIndex = (index: number) => {
|
||||
value = [...value.slice(0, index), ...value.slice(index + 1)];
|
||||
};
|
||||
const deleteIndex = (index: number) => {
|
||||
value = [...value.slice(0, index), ...value.slice(index + 1)];
|
||||
};
|
||||
|
||||
const blur = () => {
|
||||
tagIndex = undefined;
|
||||
};
|
||||
const blur = () => {
|
||||
tagIndex = undefined;
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
"border-input bg-background selection:bg-primary dark:bg-input/30 flex min-h-[36px] w-full flex-wrap place-items-center gap-1 rounded-md border py-0.5 pr-1 pl-1 disabled:opacity-50 aria-disabled:cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
aria-disabled={disabled}
|
||||
class={cn(
|
||||
"border-input bg-background selection:bg-primary dark:bg-input/30 flex min-h-[36px] w-full flex-wrap place-items-center gap-1 rounded-md border py-0.5 pr-1 pl-1 disabled:opacity-50 aria-disabled:cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
{#each value as tag, i (tag)}
|
||||
<TagsInputTag value={tag} {disabled} onDelete={deleteValue} active={i === tagIndex} />
|
||||
{/each}
|
||||
<input
|
||||
{...rest}
|
||||
bind:value={inputValue}
|
||||
onblur={blur}
|
||||
oncompositionstart={compositionStart}
|
||||
oncompositionend={compositionEnd}
|
||||
{disabled}
|
||||
{placeholder}
|
||||
data-invalid={invalid}
|
||||
onkeydown={keydown}
|
||||
class="placeholder:text-muted-foreground min-w-16 shrink grow basis-0 border-none bg-transparent px-2 outline-hidden focus:outline-hidden disabled:cursor-not-allowed data-[invalid=true]:text-red-500 md:text-sm"
|
||||
/>
|
||||
{#each value as tag, i (tag)}
|
||||
<TagsInputTag value={tag} {disabled} onDelete={deleteValue} active={i === tagIndex} />
|
||||
{/each}
|
||||
<input
|
||||
{...rest}
|
||||
bind:value={inputValue}
|
||||
onblur={blur}
|
||||
oncompositionstart={compositionStart}
|
||||
oncompositionend={compositionEnd}
|
||||
{disabled}
|
||||
{placeholder}
|
||||
data-invalid={invalid}
|
||||
onkeydown={keydown}
|
||||
class="placeholder:text-muted-foreground min-w-16 shrink grow basis-0 border-none bg-transparent px-2 outline-hidden focus:outline-hidden disabled:cursor-not-allowed data-[invalid=true]:text-red-500 md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
import type { HTMLInputAttributes } from "svelte/elements";
|
||||
|
||||
export type TagsInputPropsWithoutHTML = {
|
||||
value?: string[];
|
||||
validate?: (val: string, tags: string[]) => string | undefined;
|
||||
value?: string[];
|
||||
validate?: (val: string, tags: string[]) => string | undefined;
|
||||
};
|
||||
|
||||
export type TagsInputProps = TagsInputPropsWithoutHTML &
|
||||
Omit<HTMLInputAttributes, "value">;
|
||||
export type TagsInputProps = TagsInputPropsWithoutHTML & Omit<HTMLInputAttributes, "value">;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Root from "./textarea.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Textarea,
|
||||
Root,
|
||||
//
|
||||
Root as Textarea,
|
||||
};
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
|
||||
import type { HTMLTextareaAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
|
||||
import type { HTMLTextareaAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props();
|
||||
</script>
|
||||
|
||||
<textarea
|
||||
bind:this={ref}
|
||||
data-slot="textarea"
|
||||
class={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 field-sizing-content shadow-xs flex min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
bind:value
|
||||
{...restProps}
|
||||
bind:this={ref}
|
||||
data-slot="textarea"
|
||||
class={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 field-sizing-content shadow-xs flex min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
bind:value
|
||||
{...restProps}
|
||||
></textarea>
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// Re-export from api.ts for backwards compatibility
|
||||
// All components that import from $lib/directus continue to work
|
||||
export { apiUrl as directusApiUrl, getAssetUrl, isModel, getGraphQLClient as getDirectusInstance } from "./api.js";
|
||||
export {
|
||||
apiUrl as directusApiUrl,
|
||||
getAssetUrl,
|
||||
isModel,
|
||||
getGraphQLClient as getDirectusInstance,
|
||||
} from "./api.js";
|
||||
|
||||
@@ -6,6 +6,6 @@ const defaultLocale = "en";
|
||||
addMessages("en", en);
|
||||
|
||||
init({
|
||||
fallbackLocale: defaultLocale,
|
||||
initialLocale: defaultLocale,
|
||||
fallbackLocale: defaultLocale,
|
||||
initialLocale: defaultLocale,
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,140 +3,137 @@
|
||||
* Provides structured logging with context and request tracing
|
||||
*/
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
interface LogContext {
|
||||
timestamp: string;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
context?: Record<string, unknown>;
|
||||
requestId?: string;
|
||||
userId?: string;
|
||||
path?: string;
|
||||
method?: string;
|
||||
duration?: number;
|
||||
error?: Error;
|
||||
timestamp: string;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
context?: Record<string, unknown>;
|
||||
requestId?: string;
|
||||
userId?: string;
|
||||
path?: string;
|
||||
method?: string;
|
||||
duration?: number;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
class Logger {
|
||||
private isDev = process.env.NODE_ENV === 'development';
|
||||
private serviceName = 'sexy.pivoine.art';
|
||||
private isDev = process.env.NODE_ENV === "development";
|
||||
private serviceName = "sexy.pivoine.art";
|
||||
|
||||
private formatLog(ctx: LogContext): string {
|
||||
const { timestamp, level, message, context, requestId, userId, path, method, duration, error } = ctx;
|
||||
private formatLog(ctx: LogContext): string {
|
||||
const { timestamp, level, message, context, requestId, userId, path, method, duration, error } =
|
||||
ctx;
|
||||
|
||||
const parts = [
|
||||
`[${timestamp}]`,
|
||||
`[${level.toUpperCase()}]`,
|
||||
requestId ? `[${requestId}]` : null,
|
||||
method && path ? `${method} ${path}` : null,
|
||||
message,
|
||||
userId ? `user=${userId}` : null,
|
||||
duration !== undefined ? `${duration}ms` : null,
|
||||
].filter(Boolean);
|
||||
const parts = [
|
||||
`[${timestamp}]`,
|
||||
`[${level.toUpperCase()}]`,
|
||||
requestId ? `[${requestId}]` : null,
|
||||
method && path ? `${method} ${path}` : null,
|
||||
message,
|
||||
userId ? `user=${userId}` : null,
|
||||
duration !== undefined ? `${duration}ms` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
let logString = parts.join(' ');
|
||||
let logString = parts.join(" ");
|
||||
|
||||
if (context && Object.keys(context).length > 0) {
|
||||
logString += ' ' + JSON.stringify(context);
|
||||
}
|
||||
if (context && Object.keys(context).length > 0) {
|
||||
logString += " " + JSON.stringify(context);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
logString += `\n Error: ${error.message}\n Stack: ${error.stack}`;
|
||||
}
|
||||
if (error) {
|
||||
logString += `\n Error: ${error.message}\n Stack: ${error.stack}`;
|
||||
}
|
||||
|
||||
return logString;
|
||||
}
|
||||
return logString;
|
||||
}
|
||||
|
||||
private log(level: LogLevel, message: string, meta: Partial<LogContext> = {}) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const logContext: LogContext = {
|
||||
timestamp,
|
||||
level,
|
||||
message,
|
||||
...meta,
|
||||
};
|
||||
private log(level: LogLevel, message: string, meta: Partial<LogContext> = {}) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const logContext: LogContext = {
|
||||
timestamp,
|
||||
level,
|
||||
message,
|
||||
...meta,
|
||||
};
|
||||
|
||||
const formattedLog = this.formatLog(logContext);
|
||||
const formattedLog = this.formatLog(logContext);
|
||||
|
||||
switch (level) {
|
||||
case 'debug':
|
||||
if (this.isDev) console.debug(formattedLog);
|
||||
break;
|
||||
case 'info':
|
||||
console.info(formattedLog);
|
||||
break;
|
||||
case 'warn':
|
||||
console.warn(formattedLog);
|
||||
break;
|
||||
case 'error':
|
||||
console.error(formattedLog);
|
||||
break;
|
||||
}
|
||||
}
|
||||
switch (level) {
|
||||
case "debug":
|
||||
if (this.isDev) console.debug(formattedLog);
|
||||
break;
|
||||
case "info":
|
||||
console.info(formattedLog);
|
||||
break;
|
||||
case "warn":
|
||||
console.warn(formattedLog);
|
||||
break;
|
||||
case "error":
|
||||
console.error(formattedLog);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
debug(message: string, meta?: Partial<LogContext>) {
|
||||
this.log('debug', message, meta);
|
||||
}
|
||||
debug(message: string, meta?: Partial<LogContext>) {
|
||||
this.log("debug", message, meta);
|
||||
}
|
||||
|
||||
info(message: string, meta?: Partial<LogContext>) {
|
||||
this.log('info', message, meta);
|
||||
}
|
||||
info(message: string, meta?: Partial<LogContext>) {
|
||||
this.log("info", message, meta);
|
||||
}
|
||||
|
||||
warn(message: string, meta?: Partial<LogContext>) {
|
||||
this.log('warn', message, meta);
|
||||
}
|
||||
warn(message: string, meta?: Partial<LogContext>) {
|
||||
this.log("warn", message, meta);
|
||||
}
|
||||
|
||||
error(message: string, meta?: Partial<LogContext>) {
|
||||
this.log('error', message, meta);
|
||||
}
|
||||
error(message: string, meta?: Partial<LogContext>) {
|
||||
this.log("error", message, meta);
|
||||
}
|
||||
|
||||
// Request logging helper
|
||||
request(
|
||||
method: string,
|
||||
path: string,
|
||||
meta: Partial<LogContext> = {}
|
||||
) {
|
||||
this.info('→ Request received', { method, path, ...meta });
|
||||
}
|
||||
// Request logging helper
|
||||
request(method: string, path: string, meta: Partial<LogContext> = {}) {
|
||||
this.info("→ Request received", { method, path, ...meta });
|
||||
}
|
||||
|
||||
response(
|
||||
method: string,
|
||||
path: string,
|
||||
status: number,
|
||||
duration: number,
|
||||
meta: Partial<LogContext> = {}
|
||||
) {
|
||||
const level = status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info';
|
||||
this.log(level, `← Response ${status}`, { method, path, duration, ...meta });
|
||||
}
|
||||
response(
|
||||
method: string,
|
||||
path: string,
|
||||
status: number,
|
||||
duration: number,
|
||||
meta: Partial<LogContext> = {},
|
||||
) {
|
||||
const level = status >= 500 ? "error" : status >= 400 ? "warn" : "info";
|
||||
this.log(level, `← Response ${status}`, { method, path, duration, ...meta });
|
||||
}
|
||||
|
||||
// Authentication logging
|
||||
auth(action: string, success: boolean, meta: Partial<LogContext> = {}) {
|
||||
this.info(`🔐 Auth: ${action} ${success ? 'success' : 'failed'}`, meta);
|
||||
}
|
||||
// Authentication logging
|
||||
auth(action: string, success: boolean, meta: Partial<LogContext> = {}) {
|
||||
this.info(`🔐 Auth: ${action} ${success ? "success" : "failed"}`, meta);
|
||||
}
|
||||
|
||||
// Startup logging
|
||||
startup() {
|
||||
const env = {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
PUBLIC_API_URL: process.env.PUBLIC_API_URL,
|
||||
PUBLIC_URL: process.env.PUBLIC_URL,
|
||||
PUBLIC_UMAMI_ID: process.env.PUBLIC_UMAMI_ID ? '***set***' : 'not set',
|
||||
PUBLIC_UMAMI_SCRIPT: process.env.PUBLIC_UMAMI_SCRIPT || 'not set',
|
||||
PORT: process.env.PORT || '3000',
|
||||
HOST: process.env.HOST || '0.0.0.0',
|
||||
};
|
||||
// Startup logging
|
||||
startup() {
|
||||
const env = {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
PUBLIC_API_URL: process.env.PUBLIC_API_URL,
|
||||
PUBLIC_URL: process.env.PUBLIC_URL,
|
||||
PUBLIC_UMAMI_ID: process.env.PUBLIC_UMAMI_ID ? "***set***" : "not set",
|
||||
PUBLIC_UMAMI_SCRIPT: process.env.PUBLIC_UMAMI_SCRIPT || "not set",
|
||||
PORT: process.env.PORT || "3000",
|
||||
HOST: process.env.HOST || "0.0.0.0",
|
||||
};
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('🍑 sexy.pivoine.art - Server Starting 💜');
|
||||
console.log('='.repeat(60));
|
||||
console.log('\n📋 Environment Configuration:');
|
||||
Object.entries(env).forEach(([key, value]) => {
|
||||
console.log(` ${key}: ${value}`);
|
||||
});
|
||||
console.log('\n' + '='.repeat(60) + '\n');
|
||||
}
|
||||
console.log("\n" + "=".repeat(60));
|
||||
console.log("🍑 sexy.pivoine.art - Server Starting 💜");
|
||||
console.log("=".repeat(60));
|
||||
console.log("\n📋 Environment Configuration:");
|
||||
Object.entries(env).forEach(([key, value]) => {
|
||||
console.log(` ${key}: ${value}`);
|
||||
});
|
||||
console.log("\n" + "=".repeat(60) + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
@@ -144,5 +141,5 @@ export const logger = new Logger();
|
||||
|
||||
// Generate request ID
|
||||
export function generateRequestId(): string {
|
||||
return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,205 +1,205 @@
|
||||
import { type ButtplugClientDevice } from "@sexy.pivoine.art/buttplug";
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
artist_name: string;
|
||||
slug: string;
|
||||
email: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
avatar: string | File;
|
||||
password: string;
|
||||
directus_users_id?: User;
|
||||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
artist_name: string;
|
||||
slug: string;
|
||||
email: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
avatar: string | File;
|
||||
password: string;
|
||||
directus_users_id?: User;
|
||||
}
|
||||
|
||||
export interface CurrentUser extends User {
|
||||
avatar: File;
|
||||
role: "model" | "viewer" | "admin";
|
||||
policies: string[];
|
||||
avatar: File;
|
||||
role: "model" | "viewer" | "admin";
|
||||
policies: string[];
|
||||
}
|
||||
|
||||
export interface AuthStatus {
|
||||
authenticated: boolean;
|
||||
user?: CurrentUser;
|
||||
data?: {
|
||||
refresh_token: string | null;
|
||||
};
|
||||
authenticated: boolean;
|
||||
user?: CurrentUser;
|
||||
data?: {
|
||||
refresh_token: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface File {
|
||||
id: string;
|
||||
filesize: number;
|
||||
title: string;
|
||||
description: string;
|
||||
duration: number;
|
||||
directus_files_id?: File;
|
||||
id: string;
|
||||
filesize: number;
|
||||
title: string;
|
||||
description: string;
|
||||
duration: number;
|
||||
directus_files_id?: File;
|
||||
}
|
||||
|
||||
export interface Article {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
content: string;
|
||||
image: string;
|
||||
tags: string[];
|
||||
publish_date: Date;
|
||||
author: {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
avatar: string;
|
||||
description?: string;
|
||||
website?: string;
|
||||
};
|
||||
category: string;
|
||||
featured?: boolean;
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
content: string;
|
||||
image: string;
|
||||
tags: string[];
|
||||
publish_date: Date;
|
||||
author: {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
avatar: string;
|
||||
description?: string;
|
||||
website?: string;
|
||||
};
|
||||
category: string;
|
||||
featured?: boolean;
|
||||
}
|
||||
|
||||
export interface Model {
|
||||
id: string;
|
||||
slug: string;
|
||||
artist_name: string;
|
||||
description: string;
|
||||
avatar: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
join_date: Date;
|
||||
featured?: boolean;
|
||||
photos: File[];
|
||||
banner?: File;
|
||||
id: string;
|
||||
slug: string;
|
||||
artist_name: string;
|
||||
description: string;
|
||||
avatar: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
join_date: Date;
|
||||
featured?: boolean;
|
||||
photos: File[];
|
||||
banner?: File;
|
||||
}
|
||||
|
||||
export interface Video {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
movie: File;
|
||||
models: User[];
|
||||
tags: string[];
|
||||
upload_date: Date;
|
||||
premium?: boolean;
|
||||
featured?: boolean;
|
||||
likes_count?: number;
|
||||
plays_count?: number;
|
||||
views_count?: number;
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
movie: File;
|
||||
models: User[];
|
||||
tags: string[];
|
||||
upload_date: Date;
|
||||
premium?: boolean;
|
||||
featured?: boolean;
|
||||
likes_count?: number;
|
||||
plays_count?: number;
|
||||
views_count?: number;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string;
|
||||
comment: string;
|
||||
item: string;
|
||||
user_created: User;
|
||||
date_created: Date;
|
||||
id: string;
|
||||
comment: string;
|
||||
item: string;
|
||||
user_created: User;
|
||||
date_created: Date;
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
videos_count: number;
|
||||
models_count: number;
|
||||
viewers_count: number;
|
||||
videos_count: number;
|
||||
models_count: number;
|
||||
viewers_count: number;
|
||||
}
|
||||
|
||||
export interface DeviceActuator {
|
||||
featureIndex: number;
|
||||
outputType: string;
|
||||
maxSteps: number;
|
||||
descriptor: string;
|
||||
value: number;
|
||||
featureIndex: number;
|
||||
outputType: string;
|
||||
maxSteps: number;
|
||||
descriptor: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface BluetoothDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
actuators: DeviceActuator[];
|
||||
batteryLevel: number;
|
||||
hasBattery: boolean;
|
||||
isConnected: boolean;
|
||||
lastSeen: Date;
|
||||
info: ButtplugClientDevice;
|
||||
id: string;
|
||||
name: string;
|
||||
actuators: DeviceActuator[];
|
||||
batteryLevel: number;
|
||||
hasBattery: boolean;
|
||||
isConnected: boolean;
|
||||
lastSeen: Date;
|
||||
info: ButtplugClientDevice;
|
||||
}
|
||||
|
||||
export interface ShareContent {
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
type: "video" | "model" | "article" | "link";
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
type: "video" | "model" | "article" | "link";
|
||||
}
|
||||
|
||||
export interface RecordedEvent {
|
||||
timestamp: number;
|
||||
deviceIndex: number;
|
||||
deviceName: string;
|
||||
actuatorIndex: number;
|
||||
actuatorType: string;
|
||||
value: number;
|
||||
timestamp: number;
|
||||
deviceIndex: number;
|
||||
deviceName: string;
|
||||
actuatorIndex: number;
|
||||
actuatorType: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface DeviceInfo {
|
||||
name: string;
|
||||
index: number;
|
||||
capabilities: string[];
|
||||
name: string;
|
||||
index: number;
|
||||
capabilities: string[];
|
||||
}
|
||||
|
||||
export interface Recording {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
slug: string;
|
||||
duration: number;
|
||||
events: RecordedEvent[];
|
||||
device_info: DeviceInfo[];
|
||||
user_created: string | User;
|
||||
date_created: Date;
|
||||
date_updated?: Date;
|
||||
status: "draft" | "published" | "archived";
|
||||
tags?: string[];
|
||||
linked_video?: string | Video;
|
||||
featured?: boolean;
|
||||
public?: boolean;
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
slug: string;
|
||||
duration: number;
|
||||
events: RecordedEvent[];
|
||||
device_info: DeviceInfo[];
|
||||
user_created: string | User;
|
||||
date_created: Date;
|
||||
date_updated?: Date;
|
||||
status: "draft" | "published" | "archived";
|
||||
tags?: string[];
|
||||
linked_video?: string | Video;
|
||||
featured?: boolean;
|
||||
public?: boolean;
|
||||
}
|
||||
|
||||
export interface VideoLikeStatus {
|
||||
liked: boolean;
|
||||
liked: boolean;
|
||||
}
|
||||
|
||||
export interface VideoPlayRecord {
|
||||
id: string;
|
||||
video_id: string;
|
||||
duration_watched?: number;
|
||||
completed: boolean;
|
||||
id: string;
|
||||
video_id: string;
|
||||
duration_watched?: number;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
export interface VideoLikeResponse {
|
||||
liked: boolean;
|
||||
likes_count: number;
|
||||
liked: boolean;
|
||||
likes_count: number;
|
||||
}
|
||||
|
||||
export interface VideoPlayResponse {
|
||||
success: boolean;
|
||||
play_id: string;
|
||||
plays_count: number;
|
||||
success: boolean;
|
||||
play_id: string;
|
||||
plays_count: number;
|
||||
}
|
||||
|
||||
export interface VideoAnalytics {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
upload_date: Date;
|
||||
likes: number;
|
||||
plays: number;
|
||||
completed_plays: number;
|
||||
completion_rate: number;
|
||||
avg_watch_time: number;
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
upload_date: Date;
|
||||
likes: number;
|
||||
plays: number;
|
||||
completed_plays: number;
|
||||
completion_rate: number;
|
||||
avg_watch_time: number;
|
||||
}
|
||||
|
||||
export interface Analytics {
|
||||
total_videos: number;
|
||||
total_likes: number;
|
||||
total_plays: number;
|
||||
plays_by_date: Record<string, number>;
|
||||
likes_by_date: Record<string, number>;
|
||||
videos: VideoAnalytics[];
|
||||
total_videos: number;
|
||||
total_likes: number;
|
||||
total_plays: number;
|
||||
plays_by_date: Record<string, number>;
|
||||
likes_by_date: Record<string, number>;
|
||||
videos: VideoAnalytics[];
|
||||
}
|
||||
|
||||
@@ -2,43 +2,41 @@ import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type WithoutChildren<T> = T extends { children?: any }
|
||||
? Omit<T, "children">
|
||||
: T;
|
||||
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
|
||||
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
||||
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & {
|
||||
ref?: U | null;
|
||||
ref?: U | null;
|
||||
};
|
||||
|
||||
export const calcReadingTime = (text: string) => {
|
||||
const wordsPerMinute = 200; // Average case.
|
||||
const textLength = text.split(" ").length; // Split by words
|
||||
if (textLength > 0) {
|
||||
return Math.ceil(textLength / wordsPerMinute);
|
||||
}
|
||||
return 0;
|
||||
const wordsPerMinute = 200; // Average case.
|
||||
const textLength = text.split(" ").length; // Split by words
|
||||
if (textLength > 0) {
|
||||
return Math.ceil(textLength / wordsPerMinute);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const getUserInitials = (name: string) => {
|
||||
if (!name) return "??";
|
||||
return name
|
||||
.split(" ")
|
||||
.map((word) => word.charAt(0))
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
if (!name) return "??";
|
||||
return name
|
||||
.split(" ")
|
||||
.map((word) => word.charAt(0))
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
export const formatVideoDuration = (duration: number) => {
|
||||
const hours = Math.floor(duration / 3600);
|
||||
const minutes = Math.floor((duration - hours * 3600) / 60);
|
||||
const seconds = duration - hours * 3600 - minutes * 60;
|
||||
const hours = Math.floor(duration / 3600);
|
||||
const minutes = Math.floor((duration - hours * 3600) / 60);
|
||||
const seconds = duration - hours * 3600 - minutes * 60;
|
||||
|
||||
return `${hours < 10 ? "0" + hours : hours}:${minutes < 10 ? "0" + minutes : minutes}:${seconds < 10 ? "0" + seconds : seconds}`;
|
||||
return `${hours < 10 ? "0" + hours : hours}:${minutes < 10 ? "0" + minutes : minutes}:${seconds < 10 ? "0" + seconds : seconds}`;
|
||||
};
|
||||
|
||||
@@ -6,16 +6,14 @@ import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type WithoutChildren<T> = T extends { children?: any }
|
||||
? Omit<T, "children">
|
||||
: T;
|
||||
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
|
||||
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
||||
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & {
|
||||
ref?: U | null;
|
||||
ref?: U | null;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user