style: apply prettier formatting to all files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,123 +1,119 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import { page } from "$app/state";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card, CardContent } from "$lib/components/ui/card";
|
||||
import PeonyIcon from "$lib/components/icon/peony-icon.svelte";
|
||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { page } from "$app/state";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card, CardContent } from "$lib/components/ui/card";
|
||||
import PeonyIcon from "$lib/components/icon/peony-icon.svelte";
|
||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
</script>
|
||||
|
||||
<Meta
|
||||
title={page.status === 404 ? $_("error.not_found") : $_("error.common")}
|
||||
description={$_("error.description")}
|
||||
title={page.status === 404 ? $_("error.not_found") : $_("error.common")}
|
||||
description={$_("error.description")}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="relative min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
||||
class="relative min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
||||
>
|
||||
<PeonyBackground />
|
||||
<PeonyBackground />
|
||||
|
||||
<!-- Content -->
|
||||
<div class="relative z-10 container mx-auto px-4 text-center">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<!-- Premium Glassmorphism Card -->
|
||||
<Card
|
||||
class="bg-gradient-to-br from-card/25 via-card/30 to-card/20 backdrop-blur-2xl shadow-2xl shadow-primary/20"
|
||||
<!-- Content -->
|
||||
<div class="relative z-10 container mx-auto px-4 text-center">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<!-- Premium Glassmorphism Card -->
|
||||
<Card
|
||||
class="bg-gradient-to-br from-card/25 via-card/30 to-card/20 backdrop-blur-2xl shadow-2xl shadow-primary/20"
|
||||
>
|
||||
<CardContent class="p-12">
|
||||
<!-- 404 Animation -->
|
||||
<div class="mb-8">
|
||||
<div
|
||||
class="text-8xl md:text-9xl font-bold bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent animate-pulse"
|
||||
>
|
||||
<CardContent class="p-12">
|
||||
<!-- 404 Animation -->
|
||||
<div class="mb-8">
|
||||
<div
|
||||
class="text-8xl md:text-9xl font-bold bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent animate-pulse"
|
||||
>
|
||||
{page.status}
|
||||
</div>
|
||||
<div class="flex justify-center mt-4">
|
||||
<PeonyIcon class="w-16 h-16 text-primary/60 animate-bounce" />
|
||||
</div>
|
||||
</div>
|
||||
{page.status}
|
||||
</div>
|
||||
<div class="flex justify-center mt-4">
|
||||
<PeonyIcon class="w-16 h-16 text-primary/60 animate-bounce" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div class="space-y-6 mb-10">
|
||||
<h1 class="text-4xl md:text-5xl font-bold text-foreground">
|
||||
{page.status === 404 ? $_("error.not_found") : $_("error.common")}
|
||||
</h1>
|
||||
<p class="text-xl text-muted-foreground leading-relaxed max-w-2xl mx-auto">
|
||||
{$_("error.description")}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Error Message -->
|
||||
<div class="space-y-6 mb-10">
|
||||
<h1 class="text-4xl md:text-5xl font-bold text-foreground">
|
||||
{page.status === 404 ? $_("error.not_found") : $_("error.common")}
|
||||
</h1>
|
||||
<p class="text-xl text-muted-foreground leading-relaxed max-w-2xl mx-auto">
|
||||
{$_("error.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
<Button
|
||||
size="lg"
|
||||
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 text-lg px-8 py-6"
|
||||
href="/"
|
||||
>
|
||||
<span class="icon-[ri--home-2-line]"></span>
|
||||
{$_("error.go_home")}
|
||||
</Button>
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
<Button
|
||||
size="lg"
|
||||
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 text-lg px-8 py-6"
|
||||
href="/"
|
||||
>
|
||||
<span class="icon-[ri--home-2-line]"></span>
|
||||
{$_("error.go_home")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="text-lg px-8 py-6 border-primary/50 hover:bg-primary/10"
|
||||
href="/videos"
|
||||
>
|
||||
<span class="icon-[ri--search-line]"></span>
|
||||
{$_("error.explore_videos")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="text-lg px-8 py-6 border-primary/50 hover:bg-primary/10"
|
||||
href="/videos"
|
||||
>
|
||||
<span class="icon-[ri--search-line]"></span>
|
||||
{$_("error.explore_videos")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Quick Links -->
|
||||
<div class="mt-8 pt-8 border-t border-border/50">
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
{$_("error.quick_links")}
|
||||
</p>
|
||||
<div class="flex flex-wrap justify-center gap-3">
|
||||
<button
|
||||
class="text-sm text-primary hover:text-accent transition-colors hover:underline"
|
||||
><a href="/models">{$_("error.featured_models")}</a></button
|
||||
>
|
||||
<span class="text-muted-foreground">•</span>
|
||||
<button
|
||||
class="text-sm text-primary hover:text-accent transition-colors hover:underline"
|
||||
><a href="/magazine">{$_("error.magazine")}</a></button
|
||||
>
|
||||
<span class="text-muted-foreground">•</span>
|
||||
<button
|
||||
class="text-sm text-primary hover:text-accent transition-colors hover:underline"
|
||||
><a href="/about">{$_("error.about_us")}</a></button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<!-- Quick Links -->
|
||||
<div class="mt-8 pt-8 border-t border-border/50">
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
{$_("error.quick_links")}
|
||||
</p>
|
||||
<div class="flex flex-wrap justify-center gap-3">
|
||||
<button
|
||||
class="text-sm text-primary hover:text-accent transition-colors hover:underline"
|
||||
><a href="/models">{$_("error.featured_models")}</a></button
|
||||
>
|
||||
<span class="text-muted-foreground">•</span>
|
||||
<button
|
||||
class="text-sm text-primary hover:text-accent transition-colors hover:underline"
|
||||
><a href="/magazine">{$_("error.magazine")}</a></button
|
||||
>
|
||||
<span class="text-muted-foreground">•</span>
|
||||
<button
|
||||
class="text-sm text-primary hover:text-accent transition-colors hover:underline"
|
||||
><a href="/about">{$_("error.about_us")}</a></button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Hearts Animation -->
|
||||
<div class="absolute inset-0 pointer-events-none">
|
||||
<div class="absolute top-1/4 left-1/4 opacity-20">
|
||||
<span
|
||||
class="icon-[ri--heart-3-line] w-6 h-6 text-primary animate-float animation-delay-1000"
|
||||
></span>
|
||||
</div>
|
||||
<div class="absolute top-1/3 right-1/3 opacity-15">
|
||||
<span
|
||||
class="icon-[ri--heart-3-line] w-4 h-4 text-accent animate-float animation-delay-3000"
|
||||
></span>
|
||||
</div>
|
||||
<div class="absolute bottom-1/4 left-1/3 opacity-25">
|
||||
<span
|
||||
class="icon-[ri--heart-3-line] w-5 h-5 text-primary animate-float animation-delay-5000"
|
||||
></span>
|
||||
</div>
|
||||
<div class="absolute bottom-1/3 right-1/4 opacity-20">
|
||||
<span
|
||||
class="icon-[ri--heart-3-line] w-3 h-3 text-accent animate-float animation-delay-7000"
|
||||
></span>
|
||||
</div>
|
||||
<!-- Floating Hearts Animation -->
|
||||
<div class="absolute inset-0 pointer-events-none">
|
||||
<div class="absolute top-1/4 left-1/4 opacity-20">
|
||||
<span class="icon-[ri--heart-3-line] w-6 h-6 text-primary animate-float animation-delay-1000"
|
||||
></span>
|
||||
</div>
|
||||
<div class="absolute top-1/3 right-1/3 opacity-15">
|
||||
<span class="icon-[ri--heart-3-line] w-4 h-4 text-accent animate-float animation-delay-3000"
|
||||
></span>
|
||||
</div>
|
||||
<div class="absolute bottom-1/4 left-1/3 opacity-25">
|
||||
<span class="icon-[ri--heart-3-line] w-5 h-5 text-primary animate-float animation-delay-5000"
|
||||
></span>
|
||||
</div>
|
||||
<div class="absolute bottom-1/3 right-1/4 opacity-20">
|
||||
<span class="icon-[ri--heart-3-line] w-3 h-3 text-accent animate-float animation-delay-7000"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export async function load({ locals }) {
|
||||
return {
|
||||
authStatus: locals.authStatus,
|
||||
};
|
||||
return {
|
||||
authStatus: locals.authStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,29 +1,25 @@
|
||||
<script lang="ts">
|
||||
import "../app.css";
|
||||
import { onMount } from "svelte";
|
||||
import { waitLocale } from "svelte-i18n";
|
||||
import "$lib/i18n";
|
||||
import Footer from "$lib/components/footer/footer.svelte";
|
||||
import { Toaster } from "$lib/components/ui/sonner";
|
||||
import Header from "$lib/components/header/header.svelte";
|
||||
import AgeVerificationDialog from "$lib/components/age-verification-dialog/age-verification-dialog.svelte";
|
||||
import { env } from "$env/dynamic/public";
|
||||
import "../app.css";
|
||||
import { onMount } from "svelte";
|
||||
import { waitLocale } from "svelte-i18n";
|
||||
import "$lib/i18n";
|
||||
import Footer from "$lib/components/footer/footer.svelte";
|
||||
import { Toaster } from "$lib/components/ui/sonner";
|
||||
import Header from "$lib/components/header/header.svelte";
|
||||
import AgeVerificationDialog from "$lib/components/age-verification-dialog/age-verification-dialog.svelte";
|
||||
import { env } from "$env/dynamic/public";
|
||||
|
||||
onMount(async () => {
|
||||
await waitLocale();
|
||||
});
|
||||
onMount(async () => {
|
||||
await waitLocale();
|
||||
});
|
||||
|
||||
let { children, data } = $props();
|
||||
let { children, data } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{#if import.meta.env.PROD && env.PUBLIC_UMAMI_ID && env.PUBLIC_UMAMI_SCRIPT}
|
||||
<script
|
||||
defer
|
||||
src={env.PUBLIC_UMAMI_SCRIPT}
|
||||
data-website-id={env.PUBLIC_UMAMI_ID}
|
||||
></script>
|
||||
{/if}
|
||||
{#if import.meta.env.PROD && env.PUBLIC_UMAMI_ID && env.PUBLIC_UMAMI_SCRIPT}
|
||||
<script defer src={env.PUBLIC_UMAMI_SCRIPT} data-website-id={env.PUBLIC_UMAMI_ID}></script>
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<AgeVerificationDialog />
|
||||
@@ -31,48 +27,48 @@ let { children, data } = $props();
|
||||
<Toaster />
|
||||
|
||||
<div class="bg-background text-foreground min-h-screen">
|
||||
<!-- Advanced Global Plasma Background -->
|
||||
<div class="fixed inset-0 pointer-events-none overflow-hidden">
|
||||
<!-- Large primary blobs -->
|
||||
<div
|
||||
class="absolute -top-40 -left-40 w-80 h-80 bg-gradient-to-r from-primary/12 via-accent/18 to-primary/8 rounded-full blur-3xl animate-blob-ultra-slow"
|
||||
></div>
|
||||
<div
|
||||
class="absolute -bottom-40 -right-40 w-96 h-96 bg-gradient-to-r from-accent/12 via-primary/18 to-accent/8 rounded-full blur-3xl animate-blob-ultra-slow animation-delay-5000"
|
||||
></div>
|
||||
<!-- Advanced Global Plasma Background -->
|
||||
<div class="fixed inset-0 pointer-events-none overflow-hidden">
|
||||
<!-- Large primary blobs -->
|
||||
<div
|
||||
class="absolute -top-40 -left-40 w-80 h-80 bg-gradient-to-r from-primary/12 via-accent/18 to-primary/8 rounded-full blur-3xl animate-blob-ultra-slow"
|
||||
></div>
|
||||
<div
|
||||
class="absolute -bottom-40 -right-40 w-96 h-96 bg-gradient-to-r from-accent/12 via-primary/18 to-accent/8 rounded-full blur-3xl animate-blob-ultra-slow animation-delay-5000"
|
||||
></div>
|
||||
|
||||
<!-- Medium floating elements -->
|
||||
<div
|
||||
class="absolute top-1/2 -left-20 w-64 h-64 bg-gradient-to-r from-primary/10 via-accent/15 to-primary/6 rounded-full blur-2xl animate-blob-ultra-slow animation-delay-8000"
|
||||
></div>
|
||||
<div
|
||||
class="absolute top-1/4 -right-20 w-72 h-72 bg-gradient-to-r from-accent/10 via-primary/15 to-accent/6 rounded-full blur-2xl animate-blob-ultra-slow animation-delay-10000"
|
||||
></div>
|
||||
<!-- Medium floating elements -->
|
||||
<div
|
||||
class="absolute top-1/2 -left-20 w-64 h-64 bg-gradient-to-r from-primary/10 via-accent/15 to-primary/6 rounded-full blur-2xl animate-blob-ultra-slow animation-delay-8000"
|
||||
></div>
|
||||
<div
|
||||
class="absolute top-1/4 -right-20 w-72 h-72 bg-gradient-to-r from-accent/10 via-primary/15 to-accent/6 rounded-full blur-2xl animate-blob-ultra-slow animation-delay-10000"
|
||||
></div>
|
||||
|
||||
<!-- Small particle-like elements -->
|
||||
<div
|
||||
class="absolute top-1/3 left-1/4 w-32 h-32 bg-gradient-to-r from-primary/15 to-accent/12 rounded-full blur-xl animate-float animation-delay-2000"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-1/3 right-1/3 w-40 h-40 bg-gradient-to-r from-accent/15 to-primary/12 rounded-full blur-xl animate-float animation-delay-6000"
|
||||
></div>
|
||||
<div
|
||||
class="absolute top-2/3 left-1/2 w-24 h-24 bg-gradient-to-r from-primary/20 to-accent/15 rounded-full blur-lg animate-pulse-slow animation-delay-4000"
|
||||
></div>
|
||||
<!-- Small particle-like elements -->
|
||||
<div
|
||||
class="absolute top-1/3 left-1/4 w-32 h-32 bg-gradient-to-r from-primary/15 to-accent/12 rounded-full blur-xl animate-float animation-delay-2000"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-1/3 right-1/3 w-40 h-40 bg-gradient-to-r from-accent/15 to-primary/12 rounded-full blur-xl animate-float animation-delay-6000"
|
||||
></div>
|
||||
<div
|
||||
class="absolute top-2/3 left-1/2 w-24 h-24 bg-gradient-to-r from-primary/20 to-accent/15 rounded-full blur-lg animate-pulse-slow animation-delay-4000"
|
||||
></div>
|
||||
|
||||
<!-- Glassmorphism overlay -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-primary/2 via-transparent to-accent/2 backdrop-blur-[0.5px]"
|
||||
></div>
|
||||
</div>
|
||||
<!-- Header -->
|
||||
<Header authStatus={data.authStatus} />
|
||||
<!-- Glassmorphism overlay -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-primary/2 via-transparent to-accent/2 backdrop-blur-[0.5px]"
|
||||
></div>
|
||||
</div>
|
||||
<!-- Header -->
|
||||
<Header authStatus={data.authStatus} />
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="min-h-screen">
|
||||
{@render children()}
|
||||
</main>
|
||||
<!-- Main Content -->
|
||||
<main class="min-h-screen">
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<Footer />
|
||||
<!-- Footer -->
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getFeaturedModels, getFeaturedVideos } from "$lib/services";
|
||||
export async function load({ fetch }) {
|
||||
return {
|
||||
models: await getFeaturedModels(3, fetch),
|
||||
videos: await getFeaturedVideos(3, fetch),
|
||||
};
|
||||
return {
|
||||
models: await getFeaturedModels(3, fetch),
|
||||
videos: await getFeaturedVideos(3, fetch),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card, CardContent } from "$lib/components/ui/card";
|
||||
import { getAssetUrl } from "$lib/directus";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import { formatVideoDuration } from "$lib/utils.js";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card, CardContent } from "$lib/components/ui/card";
|
||||
import { getAssetUrl } from "$lib/directus";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import { formatVideoDuration } from "$lib/utils.js";
|
||||
|
||||
const { data } = $props();
|
||||
const { data } = $props();
|
||||
</script>
|
||||
|
||||
<Meta title={$_('home.hero.title')} description={$_('home.hero.description')} />
|
||||
<Meta title={$_("home.hero.title")} description={$_("home.hero.description")} />
|
||||
|
||||
<!-- Hero Section -->
|
||||
<section
|
||||
class="relative min-h-screen flex items-center justify-center overflow-hidden"
|
||||
>
|
||||
<section class="relative min-h-screen flex items-center justify-center overflow-hidden">
|
||||
<!-- Background Gradient -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-primary/20 via-accent/10 to-background"
|
||||
></div>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-primary/20 via-accent/10 to-background"></div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="relative z-10 container mx-auto px-4 text-center">
|
||||
@@ -26,13 +22,11 @@ const { data } = $props();
|
||||
<h1
|
||||
class="text-6xl md:text-8xl font-bold bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent leading-tight mb-8"
|
||||
>
|
||||
{$_('home.hero.title')}
|
||||
{$_("home.hero.title")}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="text-xl md:text-2xl text-muted-foreground max-w-3xl mx-auto leading-relaxed"
|
||||
>
|
||||
{$_('home.hero.description')}
|
||||
<p class="text-xl md:text-2xl text-muted-foreground max-w-3xl mx-auto leading-relaxed">
|
||||
{$_("home.hero.description")}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
@@ -42,13 +36,13 @@ const { data } = $props();
|
||||
href="/videos"
|
||||
>
|
||||
<span class="icon-[ri--play-large-fill]"></span>
|
||||
{$_('home.hero.cta_videos')}
|
||||
{$_("home.hero.cta_videos")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="text-lg px-8 py-6 border-primary/50 hover:bg-primary/10"
|
||||
href="/models">{$_('home.hero.cta_models')}</Button
|
||||
href="/models">{$_("home.hero.cta_models")}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,10 +62,10 @@ const { data } = $props();
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl md:text-4xl font-bold mb-4">
|
||||
{$_('home.featured_models.title')}
|
||||
{$_("home.featured_models.title")}
|
||||
</h2>
|
||||
<p class="text-muted-foreground text-lg">
|
||||
{$_('home.featured_models.description')}
|
||||
{$_("home.featured_models.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -83,7 +77,7 @@ const { data } = $props();
|
||||
<CardContent class="p-6 text-center">
|
||||
<div class="relative mb-4">
|
||||
<img
|
||||
src={getAssetUrl(model.avatar, 'mini')}
|
||||
src={getAssetUrl(model.avatar, "mini")}
|
||||
alt={model.artist_name}
|
||||
class="w-24 h-24 rounded-full mx-auto object-cover ring-4 ring-primary/20 group-hover:ring-primary/40 transition-all"
|
||||
/>
|
||||
@@ -107,8 +101,7 @@ const { data } = $props();
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="mt-4 w-full group-hover:bg-primary/10"
|
||||
href="/models/{model.slug}"
|
||||
>{$_('home.featured_models.view_profile')}</Button
|
||||
href="/models/{model.slug}">{$_("home.featured_models.view_profile")}</Button
|
||||
>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -122,7 +115,7 @@ const { data } = $props();
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl md:text-4xl font-bold mb-4">
|
||||
{$_('home.trending.title')}
|
||||
{$_("home.trending.title")}
|
||||
</h2>
|
||||
<!-- <p class="text-muted-foreground text-lg">Most watched romantic content</p> -->
|
||||
</div>
|
||||
@@ -134,16 +127,14 @@ const { data } = $props();
|
||||
>
|
||||
<div class="relative">
|
||||
<img
|
||||
src={getAssetUrl(video.image, 'preview')}
|
||||
src={getAssetUrl(video.image, "preview")}
|
||||
alt={video.title}
|
||||
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent group-hover:scale-105 transition-transform duration-300"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-2 left-2 text-white text-sm font-medium"
|
||||
>
|
||||
<div class="absolute bottom-2 left-2 text-white text-sm font-medium">
|
||||
{#if video.movie_file?.duration}{formatVideoDuration(video.movie_file.duration)}{/if}
|
||||
</div>
|
||||
<!-- <div
|
||||
@@ -160,21 +151,18 @@ const { data } = $props();
|
||||
href="/videos/{video.slug}"
|
||||
aria-label={video.title}
|
||||
>
|
||||
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white"
|
||||
></span>
|
||||
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white"></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<CardContent class="px-4 pb-4 pt-0">
|
||||
<h3
|
||||
class="font-semibold mb-2 group-hover:text-primary transition-colors"
|
||||
>
|
||||
<h3 class="font-semibold mb-2 group-hover:text-primary transition-colors">
|
||||
{video.title}
|
||||
</h3>
|
||||
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span class="icon-[ri--fire-line] w-4 h-4"></span>
|
||||
{$_('home.trending.trending')}
|
||||
{$_("home.trending.trending")}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -184,29 +172,27 @@ const { data } = $props();
|
||||
</section>
|
||||
|
||||
<!-- CTA Section -->
|
||||
<section
|
||||
class="py-20 bg-gradient-to-r from-primary/10 via-accent/10 to-primary/10"
|
||||
>
|
||||
<section class="py-20 bg-gradient-to-r from-primary/10 via-accent/10 to-primary/10">
|
||||
<div class="container mx-auto px-4 text-center">
|
||||
<div class="max-w-3xl mx-auto space-y-8">
|
||||
<h2 class="text-3xl md:text-4xl font-bold">
|
||||
{$_('home.featured_models.join_community')}
|
||||
{$_("home.featured_models.join_community")}
|
||||
</h2>
|
||||
<p class="text-lg text-muted-foreground">
|
||||
{$_('home.featured_models.join_community_description')}
|
||||
{$_("home.featured_models.join_community_description")}
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button
|
||||
href="/signup"
|
||||
size="lg"
|
||||
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 text-lg px-8 py-6"
|
||||
>{$_('home.community.cta_join')}</Button
|
||||
>{$_("home.community.cta_join")}</Button
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="text-lg px-8 py-6 border-primary/50 hover:bg-primary/10"
|
||||
href="/magazine">{$_('home.community.cta_magazine')}</Button
|
||||
href="/magazine">{$_("home.community.cta_magazine")}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getStats } from "$lib/services";
|
||||
export async function load({ fetch }) {
|
||||
return {
|
||||
stats: await getStats(fetch),
|
||||
};
|
||||
return {
|
||||
stats: await getStats(fetch),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,312 +1,310 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card, CardContent } from "$lib/components/ui/card";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card, CardContent } from "$lib/components/ui/card";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||
|
||||
const { data } = $props();
|
||||
const { data } = $props();
|
||||
|
||||
const stats = [
|
||||
{
|
||||
icon: "icon-[ri--user-heart-line]",
|
||||
value: data.stats.viewers_count,
|
||||
label: $_("about.stats.members"),
|
||||
},
|
||||
{
|
||||
icon: "icon-[ri--video-on-line]",
|
||||
value: data.stats.videos_count,
|
||||
label: $_("about.stats.videos"),
|
||||
},
|
||||
{
|
||||
icon: "icon-[ri--star-line]",
|
||||
value: data.stats.models_count,
|
||||
label: $_("about.stats.models"),
|
||||
},
|
||||
{
|
||||
icon: "icon-[ri--award-line]",
|
||||
value: $_("about.stats.yearsFormatted", { values: { years: 5 } }),
|
||||
label: $_("about.stats.experience"),
|
||||
},
|
||||
];
|
||||
const stats = [
|
||||
{
|
||||
icon: "icon-[ri--user-heart-line]",
|
||||
value: data.stats.viewers_count,
|
||||
label: $_("about.stats.members"),
|
||||
},
|
||||
{
|
||||
icon: "icon-[ri--video-on-line]",
|
||||
value: data.stats.videos_count,
|
||||
label: $_("about.stats.videos"),
|
||||
},
|
||||
{
|
||||
icon: "icon-[ri--star-line]",
|
||||
value: data.stats.models_count,
|
||||
label: $_("about.stats.models"),
|
||||
},
|
||||
{
|
||||
icon: "icon-[ri--award-line]",
|
||||
value: $_("about.stats.yearsFormatted", { values: { years: 5 } }),
|
||||
label: $_("about.stats.experience"),
|
||||
},
|
||||
];
|
||||
|
||||
const team = [
|
||||
{
|
||||
name: $_("about.team.sebastian.name"),
|
||||
role: $_("about.team.sebastian.role"),
|
||||
image: $_("about.team.sebastian.image"),
|
||||
bio: $_("about.team.sebastian.bio"),
|
||||
},
|
||||
{
|
||||
name: $_("about.team.valknar.name"),
|
||||
role: $_("about.team.valknar.role"),
|
||||
image: $_("about.team.valknar.image"),
|
||||
bio: $_("about.team.valknar.bio"),
|
||||
},
|
||||
];
|
||||
const team = [
|
||||
{
|
||||
name: $_("about.team.sebastian.name"),
|
||||
role: $_("about.team.sebastian.role"),
|
||||
image: $_("about.team.sebastian.image"),
|
||||
bio: $_("about.team.sebastian.bio"),
|
||||
},
|
||||
{
|
||||
name: $_("about.team.valknar.name"),
|
||||
role: $_("about.team.valknar.role"),
|
||||
image: $_("about.team.valknar.image"),
|
||||
bio: $_("about.team.valknar.bio"),
|
||||
},
|
||||
];
|
||||
|
||||
const values = [
|
||||
{
|
||||
icon: "icon-[ri--heart-line]",
|
||||
title: $_("about.values.authentic_expression.title"),
|
||||
description: $_("about.values.authentic_expression.description"),
|
||||
},
|
||||
{
|
||||
icon: "icon-[ri--shield-line]",
|
||||
title: $_("about.values.safety_respect.title"),
|
||||
description: $_("about.values.safety_respect.description"),
|
||||
},
|
||||
{
|
||||
icon: "icon-[ri--star-line]",
|
||||
title: $_("about.values.artistic_excellence.title"),
|
||||
description: $_("about.values.artistic_excellence.description"),
|
||||
},
|
||||
{
|
||||
icon: "icon-[ri--user-heart-line]",
|
||||
title: $_("about.values.community_first.title"),
|
||||
description: $_("about.values.community_first.description"),
|
||||
},
|
||||
];
|
||||
const values = [
|
||||
{
|
||||
icon: "icon-[ri--heart-line]",
|
||||
title: $_("about.values.authentic_expression.title"),
|
||||
description: $_("about.values.authentic_expression.description"),
|
||||
},
|
||||
{
|
||||
icon: "icon-[ri--shield-line]",
|
||||
title: $_("about.values.safety_respect.title"),
|
||||
description: $_("about.values.safety_respect.description"),
|
||||
},
|
||||
{
|
||||
icon: "icon-[ri--star-line]",
|
||||
title: $_("about.values.artistic_excellence.title"),
|
||||
description: $_("about.values.artistic_excellence.description"),
|
||||
},
|
||||
{
|
||||
icon: "icon-[ri--user-heart-line]",
|
||||
title: $_("about.values.community_first.title"),
|
||||
description: $_("about.values.community_first.description"),
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<Meta title={$_("about.title")} description={$_("about.subtitle")} />
|
||||
|
||||
<div
|
||||
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
||||
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
||||
>
|
||||
<PeonyBackground />
|
||||
<PeonyBackground />
|
||||
|
||||
<!-- Hero Section -->
|
||||
<section class="relative py-20 overflow-hidden">
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-primary/10 via-accent/5 to-background"
|
||||
></div>
|
||||
<div class="relative container mx-auto px-4 text-center">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<h1
|
||||
class="text-5xl md:text-7xl font-bold mb-8 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
|
||||
<!-- Hero Section -->
|
||||
<section class="relative py-20 overflow-hidden">
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-primary/10 via-accent/5 to-background"
|
||||
></div>
|
||||
<div class="relative container mx-auto px-4 text-center">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<h1
|
||||
class="text-5xl md:text-7xl font-bold mb-8 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
|
||||
>
|
||||
{$_("about.title")}
|
||||
</h1>
|
||||
<p
|
||||
class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto"
|
||||
>
|
||||
{$_("about.subtitle")}
|
||||
</p>
|
||||
<div class="flex justify-center">
|
||||
<Button
|
||||
size="lg"
|
||||
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
href="/signup">{$_("about.join_community")}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Stats Section -->
|
||||
<section class="py-16 bg-card/30">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-8">
|
||||
{#each stats as stat (stat.icon)}
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="w-16 h-16 bg-gradient-to-br from-primary/20 to-accent/20 rounded-full flex items-center justify-center mx-auto mb-4"
|
||||
>
|
||||
<span class={stat.icon + " w-8 h-8 text-primary"}></span>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-primary mb-2">{stat.value}</div>
|
||||
<div class="text-muted-foreground">{stat.label}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Our Story Section -->
|
||||
<section class="py-20">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl md:text-4xl font-bold mb-4">
|
||||
{$_("about.story.title")}
|
||||
</h2>
|
||||
<p class="text-lg text-muted-foreground">
|
||||
{$_("about.story.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||
<div class="space-y-6">
|
||||
<p class="text-muted-foreground leading-relaxed text-lg">
|
||||
{$_("about.story.description_part1")}
|
||||
</p>
|
||||
<p class="text-muted-foreground leading-relaxed text-lg">
|
||||
{$_("about.story.description_part2")}
|
||||
</p>
|
||||
<p class="text-muted-foreground leading-relaxed text-lg">
|
||||
{$_("about.story.description_part3")}
|
||||
</p>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<img
|
||||
src="/img/babes.jpg"
|
||||
alt="Our story"
|
||||
class="w-full object-cover rounded-2xl shadow-2xl"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-t from-primary/20 to-transparent rounded-2xl"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Values Section -->
|
||||
<section class="py-20 bg-card/30">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl md:text-4xl font-bold mb-4">
|
||||
{$_("about.values.title")}
|
||||
</h2>
|
||||
<p class="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
{$_("about.values.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-4xl mx-auto">
|
||||
{#each values as value (value.title)}
|
||||
<Card
|
||||
class="bg-gradient-to-br from-card to-card/50 border-primary/20 hover:shadow-xl hover:shadow-primary/10 transition-all duration-300"
|
||||
>
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="w-12 h-12 bg-gradient-to-br from-primary/20 to-accent/20 rounded-full flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
{$_("about.title")}
|
||||
</h1>
|
||||
<p
|
||||
class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto"
|
||||
>
|
||||
{$_("about.subtitle")}
|
||||
</p>
|
||||
<div class="flex justify-center">
|
||||
<Button
|
||||
size="lg"
|
||||
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
href="/signup">{$_("about.join_community")}</Button
|
||||
>
|
||||
<span class={value.icon + " w-6 h-6 text-primary"}></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Stats Section -->
|
||||
<section class="py-16 bg-card/30">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-8">
|
||||
{#each stats as stat (stat.icon)}
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="w-16 h-16 bg-gradient-to-br from-primary/20 to-accent/20 rounded-full flex items-center justify-center mx-auto mb-4"
|
||||
>
|
||||
<span class={stat.icon + " w-8 h-8 text-primary"}></span>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-primary mb-2">{stat.value}</div>
|
||||
<div class="text-muted-foreground">{stat.label}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Our Story Section -->
|
||||
<section class="py-20">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl md:text-4xl font-bold mb-4">
|
||||
{$_("about.story.title")}
|
||||
</h2>
|
||||
<p class="text-lg text-muted-foreground">
|
||||
{$_("about.story.subtitle")}
|
||||
</p>
|
||||
<div>
|
||||
<h3 class="font-semibold text-lg mb-2">{value.title}</h3>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
{value.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||
<div class="space-y-6">
|
||||
<p class="text-muted-foreground leading-relaxed text-lg">
|
||||
{$_("about.story.description_part1")}
|
||||
</p>
|
||||
<p class="text-muted-foreground leading-relaxed text-lg">
|
||||
{$_("about.story.description_part2")}
|
||||
</p>
|
||||
<p class="text-muted-foreground leading-relaxed text-lg">
|
||||
{$_("about.story.description_part3")}
|
||||
</p>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<img
|
||||
src="/img/babes.jpg"
|
||||
alt="Our story"
|
||||
class="w-full object-cover rounded-2xl shadow-2xl"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-t from-primary/20 to-transparent rounded-2xl"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Team Section -->
|
||||
<section class="py-20">
|
||||
<div class="container mx-auto px-4 max-w-xl">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl md:text-4xl font-bold mb-4">
|
||||
{$_("about.team.title")}
|
||||
</h2>
|
||||
<p class="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
{$_("about.team.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{#each team as member (member.name)}
|
||||
<Card
|
||||
class="bg-gradient-to-br from-card to-card/50 border-primary/20 hover:shadow-xl hover:shadow-primary/10 transition-all duration-300 hover:-translate-y-2"
|
||||
>
|
||||
<CardContent class="p-6 text-center">
|
||||
<img
|
||||
src={member.image}
|
||||
alt={member.name}
|
||||
class="w-24 h-24 rounded-full mx-auto mb-4 object-cover ring-4 ring-primary/20"
|
||||
/>
|
||||
<h3 class="font-semibold text-lg mb-1">{member.name}</h3>
|
||||
<p class="text-primary text-sm mb-3">{member.role}</p>
|
||||
<p class="text-muted-foreground text-sm leading-relaxed">
|
||||
{member.bio}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Mission Section -->
|
||||
<section class="py-20 bg-gradient-to-r from-primary/10 via-accent/10 to-primary/10">
|
||||
<div class="container mx-auto px-4 text-center">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h2 class="text-3xl md:text-4xl font-bold mb-6">
|
||||
{$_("about.mission.title")}
|
||||
</h2>
|
||||
<p class="text-xl text-muted-foreground mb-8 leading-relaxed">
|
||||
{$_("about.mission.description")}
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button
|
||||
size="lg"
|
||||
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
href="/signup">{$_("about.mission.cta_creator")}</Button
|
||||
>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="border-primary/50 hover:bg-primary/10"
|
||||
href="/signup">{$_("about.mission.cta_community")}</Button
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Values Section -->
|
||||
<section class="py-20 bg-card/30">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl md:text-4xl font-bold mb-4">
|
||||
{$_("about.values.title")}
|
||||
</h2>
|
||||
<p class="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
{$_("about.values.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-4xl mx-auto">
|
||||
{#each values as value (value.title)}
|
||||
<Card
|
||||
class="bg-gradient-to-br from-card to-card/50 border-primary/20 hover:shadow-xl hover:shadow-primary/10 transition-all duration-300"
|
||||
>
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="w-12 h-12 bg-gradient-to-br from-primary/20 to-accent/20 rounded-full flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<span class={value.icon + " w-6 h-6 text-primary"}></span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-lg mb-2">{value.title}</h3>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
{value.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- Contact Section -->
|
||||
<section class="py-20">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="max-w-2xl mx-auto text-center">
|
||||
<h2 class="text-3xl md:text-4xl font-bold mb-6">
|
||||
{$_("about.contact.title")}
|
||||
</h2>
|
||||
<p class="text-lg text-muted-foreground mb-8">
|
||||
{$_("about.contact.description")}
|
||||
</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card class="bg-gradient-to-br from-card to-card/50 border-primary/20">
|
||||
<CardContent class="p-6 text-center">
|
||||
<h3 class="font-semibold mb-2">
|
||||
{$_("about.contact.general.title")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground text-sm mb-4">
|
||||
{$_("about.contact.general.description")}
|
||||
</p>
|
||||
<a
|
||||
href="mailto:{$_('about.contact.general.mailto')}"
|
||||
class="text-primary hover:underline">{$_("about.contact.general.mailto")}</a
|
||||
>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card class="bg-gradient-to-br from-card to-card/50 border-primary/20">
|
||||
<CardContent class="p-6 text-center">
|
||||
<h3 class="font-semibold mb-2">
|
||||
{$_("about.contact.creators.title")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground text-sm mb-4">
|
||||
{$_("about.contact.creators.description")}
|
||||
</p>
|
||||
<a
|
||||
href="mailto:{$_('about.contact.creators.mailto')}"
|
||||
class="text-primary hover:underline">{$_("about.contact.creators.mailto")}</a
|
||||
>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Team Section -->
|
||||
<section class="py-20">
|
||||
<div class="container mx-auto px-4 max-w-xl">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl md:text-4xl font-bold mb-4">
|
||||
{$_("about.team.title")}
|
||||
</h2>
|
||||
<p class="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
{$_("about.team.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{#each team as member (member.name)}
|
||||
<Card
|
||||
class="bg-gradient-to-br from-card to-card/50 border-primary/20 hover:shadow-xl hover:shadow-primary/10 transition-all duration-300 hover:-translate-y-2"
|
||||
>
|
||||
<CardContent class="p-6 text-center">
|
||||
<img
|
||||
src={member.image}
|
||||
alt={member.name}
|
||||
class="w-24 h-24 rounded-full mx-auto mb-4 object-cover ring-4 ring-primary/20"
|
||||
/>
|
||||
<h3 class="font-semibold text-lg mb-1">{member.name}</h3>
|
||||
<p class="text-primary text-sm mb-3">{member.role}</p>
|
||||
<p class="text-muted-foreground text-sm leading-relaxed">
|
||||
{member.bio}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Mission Section -->
|
||||
<section class="py-20 bg-gradient-to-r from-primary/10 via-accent/10 to-primary/10">
|
||||
<div class="container mx-auto px-4 text-center">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h2 class="text-3xl md:text-4xl font-bold mb-6">
|
||||
{$_("about.mission.title")}
|
||||
</h2>
|
||||
<p class="text-xl text-muted-foreground mb-8 leading-relaxed">
|
||||
{$_("about.mission.description")}
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button
|
||||
size="lg"
|
||||
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
href="/signup">{$_("about.mission.cta_creator")}</Button
|
||||
>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="border-primary/50 hover:bg-primary/10"
|
||||
href="/signup">{$_("about.mission.cta_community")}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Contact Section -->
|
||||
<section class="py-20">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="max-w-2xl mx-auto text-center">
|
||||
<h2 class="text-3xl md:text-4xl font-bold mb-6">
|
||||
{$_("about.contact.title")}
|
||||
</h2>
|
||||
<p class="text-lg text-muted-foreground mb-8">
|
||||
{$_("about.contact.description")}
|
||||
</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card class="bg-gradient-to-br from-card to-card/50 border-primary/20">
|
||||
<CardContent class="p-6 text-center">
|
||||
<h3 class="font-semibold mb-2">
|
||||
{$_("about.contact.general.title")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground text-sm mb-4">
|
||||
{$_("about.contact.general.description")}
|
||||
</p>
|
||||
<a
|
||||
href="mailto:{$_('about.contact.general.mailto')}"
|
||||
class="text-primary hover:underline"
|
||||
>{$_("about.contact.general.mailto")}</a
|
||||
>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card class="bg-gradient-to-br from-card to-card/50 border-primary/20">
|
||||
<CardContent class="p-6 text-center">
|
||||
<h3 class="font-semibold mb-2">
|
||||
{$_("about.contact.creators.title")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground text-sm mb-4">
|
||||
{$_("about.contact.creators.description")}
|
||||
</p>
|
||||
<a
|
||||
href="mailto:{$_('about.contact.creators.mailto')}"
|
||||
class="text-primary hover:underline"
|
||||
>{$_("about.contact.creators.mailto")}</a
|
||||
>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,358 +1,346 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "$lib/components/ui/card";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "$lib/components/ui/card";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
|
||||
let searchQuery = $state("");
|
||||
let expandedItems = new SvelteSet<number>();
|
||||
let searchQuery = $state("");
|
||||
let expandedItems = new SvelteSet<number>();
|
||||
|
||||
const faqCategories = [
|
||||
{
|
||||
id: 1,
|
||||
title: $_("faq.getting_started.title"),
|
||||
icon: "icon-[ri--home-heart-line]",
|
||||
questions: [
|
||||
{
|
||||
id: 1,
|
||||
question: $_("faq.getting_started.questions.0.question"),
|
||||
answer: $_("faq.getting_started.questions.0.answer"),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
question: $_("faq.getting_started.questions.1.question"),
|
||||
answer: $_("faq.getting_started.questions.1.answer"),
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
question: $_("faq.getting_started.questions.2.question"),
|
||||
answer: $_("faq.getting_started.questions.2.answer"),
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
question: $_("faq.getting_started.questions.3.question"),
|
||||
answer: $_("faq.getting_started.questions.3.answer"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: $_("faq.creators.title"),
|
||||
icon: "icon-[ri--user-heart-line]",
|
||||
questions: [
|
||||
{
|
||||
id: 5,
|
||||
question: $_("faq.creators.questions.0.question"),
|
||||
answer: $_("faq.creators.questions.0.answer"),
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
question: $_("faq.creators.questions.1.question"),
|
||||
answer: $_("faq.creators.questions.1.answer"),
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
question: $_("faq.creators.questions.2.question"),
|
||||
answer: $_("faq.creators.questions.2.answer"),
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
question: $_("faq.creators.questions.3.question"),
|
||||
answer: $_("faq.creators.questions.3.answer"),
|
||||
},
|
||||
],
|
||||
},
|
||||
// {
|
||||
// id: 3,
|
||||
// title: $_("faq.categories.payments"),
|
||||
// icon: CreditCardIcon,
|
||||
// questions: [
|
||||
// {
|
||||
// id: 9,
|
||||
// question: "What payment methods do you accept?",
|
||||
// answer:
|
||||
// "We accept all major credit cards (Visa, MasterCard, American Express), PayPal, and various digital payment methods. All transactions are processed securely through encrypted payment gateways.",
|
||||
// },
|
||||
// {
|
||||
// id: 10,
|
||||
// question: "How does billing work?",
|
||||
// answer:
|
||||
// "Subscriptions are billed monthly or annually depending on your chosen plan. You'll be charged on the same date each billing cycle. We send email notifications before each billing date, and you can cancel anytime from your account settings.",
|
||||
// },
|
||||
// {
|
||||
// id: 11,
|
||||
// question: "Can I get a refund?",
|
||||
// answer:
|
||||
// "We offer refunds within 7 days of purchase if you haven't accessed premium content. For technical issues or billing errors, contact our support team. Refunds are processed within 5-10 business days to your original payment method.",
|
||||
// },
|
||||
// {
|
||||
// id: 12,
|
||||
// question: "How do creator payouts work?",
|
||||
// answer:
|
||||
// "Creators are paid weekly via direct deposit, PayPal, or wire transfer. Minimum payout is $50. Earnings are calculated after a 7-day processing period to account for potential chargebacks or refunds.",
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
{
|
||||
id: 4,
|
||||
title: $_("faq.privacy.title"),
|
||||
icon: "icon-[ri--git-repository-private-line]",
|
||||
questions: [
|
||||
{
|
||||
id: 13,
|
||||
question: $_("faq.privacy.questions.0.question"),
|
||||
answer: $_("faq.privacy.questions.0.answer"),
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
question: $_("faq.privacy.questions.1.question"),
|
||||
answer: $_("faq.privacy.questions.1.answer"),
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
question: $_("faq.privacy.questions.2.question"),
|
||||
answer: $_("faq.privacy.questions.2.answer"),
|
||||
},
|
||||
{
|
||||
id: 16,
|
||||
question: $_("faq.privacy.questions.3.question"),
|
||||
answer: $_("faq.privacy.questions.3.answer"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: $_("faq.technical.title"),
|
||||
icon: "icon-[ri--settings-3-line]",
|
||||
questions: [
|
||||
{
|
||||
id: 17,
|
||||
question: $_("faq.technical.questions.0.question"),
|
||||
answer: $_("faq.technical.questions.0.answer"),
|
||||
},
|
||||
{
|
||||
id: 18,
|
||||
question: $_("faq.technical.questions.1.question"),
|
||||
answer: $_("faq.technical.questions.1.answer"),
|
||||
},
|
||||
{
|
||||
id: 19,
|
||||
question: $_("faq.technical.questions.2.question"),
|
||||
answer: $_("faq.technical.questions.2.answer"),
|
||||
},
|
||||
{
|
||||
id: 20,
|
||||
question: $_("faq.technical.questions.3.question"),
|
||||
answer: $_("faq.technical.questions.3.answer"),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const faqCategories = [
|
||||
{
|
||||
id: 1,
|
||||
title: $_("faq.getting_started.title"),
|
||||
icon: "icon-[ri--home-heart-line]",
|
||||
questions: [
|
||||
{
|
||||
id: 1,
|
||||
question: $_("faq.getting_started.questions.0.question"),
|
||||
answer: $_("faq.getting_started.questions.0.answer"),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
question: $_("faq.getting_started.questions.1.question"),
|
||||
answer: $_("faq.getting_started.questions.1.answer"),
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
question: $_("faq.getting_started.questions.2.question"),
|
||||
answer: $_("faq.getting_started.questions.2.answer"),
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
question: $_("faq.getting_started.questions.3.question"),
|
||||
answer: $_("faq.getting_started.questions.3.answer"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: $_("faq.creators.title"),
|
||||
icon: "icon-[ri--user-heart-line]",
|
||||
questions: [
|
||||
{
|
||||
id: 5,
|
||||
question: $_("faq.creators.questions.0.question"),
|
||||
answer: $_("faq.creators.questions.0.answer"),
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
question: $_("faq.creators.questions.1.question"),
|
||||
answer: $_("faq.creators.questions.1.answer"),
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
question: $_("faq.creators.questions.2.question"),
|
||||
answer: $_("faq.creators.questions.2.answer"),
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
question: $_("faq.creators.questions.3.question"),
|
||||
answer: $_("faq.creators.questions.3.answer"),
|
||||
},
|
||||
],
|
||||
},
|
||||
// {
|
||||
// id: 3,
|
||||
// title: $_("faq.categories.payments"),
|
||||
// icon: CreditCardIcon,
|
||||
// questions: [
|
||||
// {
|
||||
// id: 9,
|
||||
// question: "What payment methods do you accept?",
|
||||
// answer:
|
||||
// "We accept all major credit cards (Visa, MasterCard, American Express), PayPal, and various digital payment methods. All transactions are processed securely through encrypted payment gateways.",
|
||||
// },
|
||||
// {
|
||||
// id: 10,
|
||||
// question: "How does billing work?",
|
||||
// answer:
|
||||
// "Subscriptions are billed monthly or annually depending on your chosen plan. You'll be charged on the same date each billing cycle. We send email notifications before each billing date, and you can cancel anytime from your account settings.",
|
||||
// },
|
||||
// {
|
||||
// id: 11,
|
||||
// question: "Can I get a refund?",
|
||||
// answer:
|
||||
// "We offer refunds within 7 days of purchase if you haven't accessed premium content. For technical issues or billing errors, contact our support team. Refunds are processed within 5-10 business days to your original payment method.",
|
||||
// },
|
||||
// {
|
||||
// id: 12,
|
||||
// question: "How do creator payouts work?",
|
||||
// answer:
|
||||
// "Creators are paid weekly via direct deposit, PayPal, or wire transfer. Minimum payout is $50. Earnings are calculated after a 7-day processing period to account for potential chargebacks or refunds.",
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
{
|
||||
id: 4,
|
||||
title: $_("faq.privacy.title"),
|
||||
icon: "icon-[ri--git-repository-private-line]",
|
||||
questions: [
|
||||
{
|
||||
id: 13,
|
||||
question: $_("faq.privacy.questions.0.question"),
|
||||
answer: $_("faq.privacy.questions.0.answer"),
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
question: $_("faq.privacy.questions.1.question"),
|
||||
answer: $_("faq.privacy.questions.1.answer"),
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
question: $_("faq.privacy.questions.2.question"),
|
||||
answer: $_("faq.privacy.questions.2.answer"),
|
||||
},
|
||||
{
|
||||
id: 16,
|
||||
question: $_("faq.privacy.questions.3.question"),
|
||||
answer: $_("faq.privacy.questions.3.answer"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: $_("faq.technical.title"),
|
||||
icon: "icon-[ri--settings-3-line]",
|
||||
questions: [
|
||||
{
|
||||
id: 17,
|
||||
question: $_("faq.technical.questions.0.question"),
|
||||
answer: $_("faq.technical.questions.0.answer"),
|
||||
},
|
||||
{
|
||||
id: 18,
|
||||
question: $_("faq.technical.questions.1.question"),
|
||||
answer: $_("faq.technical.questions.1.answer"),
|
||||
},
|
||||
{
|
||||
id: 19,
|
||||
question: $_("faq.technical.questions.2.question"),
|
||||
answer: $_("faq.technical.questions.2.answer"),
|
||||
},
|
||||
{
|
||||
id: 20,
|
||||
question: $_("faq.technical.questions.3.question"),
|
||||
answer: $_("faq.technical.questions.3.answer"),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const allQuestions = faqCategories.flatMap((category) =>
|
||||
category.questions.map((q) => ({ ...q, categoryTitle: category.title })),
|
||||
);
|
||||
const allQuestions = faqCategories.flatMap((category) =>
|
||||
category.questions.map((q) => ({ ...q, categoryTitle: category.title })),
|
||||
);
|
||||
|
||||
const filteredQuestions = $derived(() => {
|
||||
if (!searchQuery.trim()) return allQuestions;
|
||||
return allQuestions.filter(
|
||||
(q) =>
|
||||
q.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
q.answer.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
q.categoryTitle.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
});
|
||||
const filteredQuestions = $derived(() => {
|
||||
if (!searchQuery.trim()) return allQuestions;
|
||||
return allQuestions.filter(
|
||||
(q) =>
|
||||
q.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
q.answer.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
q.categoryTitle.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
});
|
||||
|
||||
function toggleExpanded(id: number) {
|
||||
const newExpanded = new SvelteSet(expandedItems);
|
||||
if (newExpanded.has(id)) {
|
||||
newExpanded.delete(id);
|
||||
} else {
|
||||
newExpanded.add(id);
|
||||
}
|
||||
expandedItems = newExpanded;
|
||||
}
|
||||
function toggleExpanded(id: number) {
|
||||
const newExpanded = new SvelteSet(expandedItems);
|
||||
if (newExpanded.has(id)) {
|
||||
newExpanded.delete(id);
|
||||
} else {
|
||||
newExpanded.add(id);
|
||||
}
|
||||
expandedItems = newExpanded;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Meta title={$_("faq.title")} description={$_("faq.description")} />
|
||||
|
||||
<div
|
||||
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
||||
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
||||
>
|
||||
<PeonyBackground />
|
||||
<PeonyBackground />
|
||||
|
||||
<div class="container mx-auto py-20 relative px-4">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-16">
|
||||
<h1
|
||||
class="text-5xl md:text-6xl font-bold mb-8 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
|
||||
<div class="container mx-auto py-20 relative px-4">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-16">
|
||||
<h1
|
||||
class="text-5xl md:text-6xl font-bold mb-8 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
|
||||
>
|
||||
{$_("faq.title")}
|
||||
</h1>
|
||||
<p class="text-xl text-muted-foreground max-w-3xl mx-auto leading-relaxed">
|
||||
{$_("faq.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="max-w-2xl mx-auto mb-12">
|
||||
<div class="relative">
|
||||
<span class="icon-[ri--search-line] absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5"
|
||||
></span>
|
||||
<Input
|
||||
placeholder={$_("faq.search_placeholder")}
|
||||
bind:value={searchQuery}
|
||||
class="pl-12 h-14 text-lg bg-card/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if searchQuery.trim()}
|
||||
<!-- Search Results -->
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h2 class="text-2xl font-bold mb-6">
|
||||
{$_("faq.search_results", {
|
||||
values: { count: filteredQuestions().length },
|
||||
})}
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
{#each filteredQuestions() as question (question.id)}
|
||||
<Card
|
||||
class="bg-gradient-to-br from-card/90 via-card/95 to-card/85 backdrop-blur-xl shadow-lg shadow-primary/10"
|
||||
>
|
||||
{$_("faq.title")}
|
||||
</h1>
|
||||
<p class="text-xl text-muted-foreground max-w-3xl mx-auto leading-relaxed">
|
||||
{$_("faq.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="max-w-2xl mx-auto mb-12">
|
||||
<div class="relative">
|
||||
<span
|
||||
class="icon-[ri--search-line] absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5"
|
||||
></span>
|
||||
<Input
|
||||
placeholder={$_("faq.search_placeholder")}
|
||||
bind:value={searchQuery}
|
||||
class="pl-12 h-14 text-lg bg-card/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if searchQuery.trim()}
|
||||
<!-- Search Results -->
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h2 class="text-2xl font-bold mb-6">
|
||||
{$_("faq.search_results", {
|
||||
values: { count: filteredQuestions().length },
|
||||
})}
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
{#each filteredQuestions() as question (question.id)}
|
||||
<Card
|
||||
class="bg-gradient-to-br from-card/90 via-card/95 to-card/85 backdrop-blur-xl shadow-lg shadow-primary/10"
|
||||
>
|
||||
<CardContent class="p-0">
|
||||
<button
|
||||
onclick={() => toggleExpanded(question.id)}
|
||||
class="w-full p-6 text-left hover:bg-primary/5 transition-colors flex items-center justify-between"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm text-primary font-medium mb-1">
|
||||
{question.categoryTitle}
|
||||
</div>
|
||||
<h3 class="font-semibold text-lg">{question.question}</h3>
|
||||
</div>
|
||||
{#if expandedItems.has(question.id)}
|
||||
<span
|
||||
class="icon-[ri--arrow-drop-up-line] w-7 h-7 text-muted-foreground flex-shrink-0 ml-4"
|
||||
></span>
|
||||
{:else}
|
||||
<span
|
||||
class="icon-[ri--arrow-drop-down-line] w-7 h-7 text-muted-foreground flex-shrink-0 ml-4"
|
||||
></span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if expandedItems.has(question.id)}
|
||||
<div class="p-6">
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
{question.answer}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{#if filteredQuestions.length === 0}
|
||||
<div class="text-center py-12">
|
||||
<p class="text-muted-foreground text-lg">{$_("faq.no_results")}</p>
|
||||
<Button variant="outline" onclick={() => (searchQuery = "")} class="mt-4"
|
||||
>{$_("faq.clear_search")}</Button
|
||||
>
|
||||
<CardContent class="p-0">
|
||||
<button
|
||||
onclick={() => toggleExpanded(question.id)}
|
||||
class="w-full p-6 text-left hover:bg-primary/5 transition-colors flex items-center justify-between"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm text-primary font-medium mb-1">
|
||||
{question.categoryTitle}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Category View -->
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{#each faqCategories as category (category.id)}
|
||||
<Card
|
||||
class="bg-gradient-to-br from-card/90 via-card/95 to-card/85 backdrop-blur-xl shadow-lg shadow-primary/10"
|
||||
>
|
||||
<CardHeader class="pb-4">
|
||||
<CardTitle class="flex items-center gap-3 text-xl">
|
||||
<div
|
||||
class="w-10 h-10 bg-gradient-to-br from-primary/20 to-accent/20 rounded-full flex items-center justify-center"
|
||||
>
|
||||
<span class={category.icon + " w-5 h-5 text-primary"}
|
||||
></span>
|
||||
</div>
|
||||
{category.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="pt-0">
|
||||
<div class="space-y-3">
|
||||
{#each category.questions as question (question.id)}
|
||||
<div
|
||||
class="border border-border/50 rounded-lg overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onclick={() => toggleExpanded(question.id)}
|
||||
class="w-full p-4 text-left hover:bg-primary/5 transition-colors flex items-center justify-between"
|
||||
>
|
||||
<h4 class="font-medium text-sm pr-4">
|
||||
{question.question}
|
||||
</h4>
|
||||
{#if expandedItems.has(question.id)}
|
||||
<span
|
||||
class="icon-[ri--arrow-drop-up-line] w-6 h-6 text-muted-foreground flex-shrink-0"
|
||||
></span>
|
||||
{:else}
|
||||
<span
|
||||
class="icon-[ri--arrow-drop-down-line] w-6 h-6 text-muted-foreground flex-shrink-0"
|
||||
></span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if expandedItems.has(question.id)}
|
||||
<div class="p-4 border-t border-border/50">
|
||||
<p
|
||||
class="text-muted-foreground text-sm leading-relaxed"
|
||||
>
|
||||
{question.answer}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Contact Support -->
|
||||
<div class="max-w-2xl mx-auto mt-16">
|
||||
<Card class="bg-gradient-to-br from-primary/10 to-accent/10 border-primary/20">
|
||||
<CardContent class="p-8 text-center">
|
||||
<h3 class="text-2xl font-bold mb-4">{$_("faq.support.title")}</h3>
|
||||
<p class="text-muted-foreground mb-6 leading-relaxed">
|
||||
{$_("faq.support.description")}
|
||||
<h3 class="font-semibold text-lg">{question.question}</h3>
|
||||
</div>
|
||||
{#if expandedItems.has(question.id)}
|
||||
<span
|
||||
class="icon-[ri--arrow-drop-up-line] w-7 h-7 text-muted-foreground flex-shrink-0 ml-4"
|
||||
></span>
|
||||
{:else}
|
||||
<span
|
||||
class="icon-[ri--arrow-drop-down-line] w-7 h-7 text-muted-foreground flex-shrink-0 ml-4"
|
||||
></span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if expandedItems.has(question.id)}
|
||||
<div class="p-6">
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
{question.answer}
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button
|
||||
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
href="mailto:{$_('faq.support.contact_email')}"
|
||||
>{$_("faq.support.contact")}</Button
|
||||
>
|
||||
<!-- <Button
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{#if filteredQuestions.length === 0}
|
||||
<div class="text-center py-12">
|
||||
<p class="text-muted-foreground text-lg">{$_("faq.no_results")}</p>
|
||||
<Button variant="outline" onclick={() => (searchQuery = "")} class="mt-4"
|
||||
>{$_("faq.clear_search")}</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Category View -->
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{#each faqCategories as category (category.id)}
|
||||
<Card
|
||||
class="bg-gradient-to-br from-card/90 via-card/95 to-card/85 backdrop-blur-xl shadow-lg shadow-primary/10"
|
||||
>
|
||||
<CardHeader class="pb-4">
|
||||
<CardTitle class="flex items-center gap-3 text-xl">
|
||||
<div
|
||||
class="w-10 h-10 bg-gradient-to-br from-primary/20 to-accent/20 rounded-full flex items-center justify-center"
|
||||
>
|
||||
<span class={category.icon + " w-5 h-5 text-primary"}></span>
|
||||
</div>
|
||||
{category.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="pt-0">
|
||||
<div class="space-y-3">
|
||||
{#each category.questions as question (question.id)}
|
||||
<div class="border border-border/50 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onclick={() => toggleExpanded(question.id)}
|
||||
class="w-full p-4 text-left hover:bg-primary/5 transition-colors flex items-center justify-between"
|
||||
>
|
||||
<h4 class="font-medium text-sm pr-4">
|
||||
{question.question}
|
||||
</h4>
|
||||
{#if expandedItems.has(question.id)}
|
||||
<span
|
||||
class="icon-[ri--arrow-drop-up-line] w-6 h-6 text-muted-foreground flex-shrink-0"
|
||||
></span>
|
||||
{:else}
|
||||
<span
|
||||
class="icon-[ri--arrow-drop-down-line] w-6 h-6 text-muted-foreground flex-shrink-0"
|
||||
></span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if expandedItems.has(question.id)}
|
||||
<div class="p-4 border-t border-border/50">
|
||||
<p class="text-muted-foreground text-sm leading-relaxed">
|
||||
{question.answer}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Contact Support -->
|
||||
<div class="max-w-2xl mx-auto mt-16">
|
||||
<Card class="bg-gradient-to-br from-primary/10 to-accent/10 border-primary/20">
|
||||
<CardContent class="p-8 text-center">
|
||||
<h3 class="text-2xl font-bold mb-4">{$_("faq.support.title")}</h3>
|
||||
<p class="text-muted-foreground mb-6 leading-relaxed">
|
||||
{$_("faq.support.description")}
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button
|
||||
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
href="mailto:{$_('faq.support.contact_email')}">{$_("faq.support.contact")}</Button
|
||||
>
|
||||
<!-- <Button
|
||||
variant="outline"
|
||||
class="border-primary/50 hover:bg-primary/10"
|
||||
>{$_("faq.support.live_chat")}</Button
|
||||
> -->
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,102 +1,97 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "$lib/components/ui/card";
|
||||
import { Separator } from "$lib/components/ui/separator";
|
||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "$lib/components/ui/card";
|
||||
import { Separator } from "$lib/components/ui/separator";
|
||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
</script>
|
||||
|
||||
<Meta title={$_("imprint.title")} description={$_("imprint.description")} />
|
||||
|
||||
<div
|
||||
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
||||
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
||||
>
|
||||
<PeonyBackground />
|
||||
<PeonyBackground />
|
||||
|
||||
<div class="container mx-auto py-20 relative px-4">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-12">
|
||||
<h1
|
||||
class="text-4xl md:text-5xl font-bold mb-4 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
|
||||
>
|
||||
{$_("imprint.title")}
|
||||
</h1>
|
||||
<p class="text-lg text-muted-foreground">
|
||||
{$_("imprint.description")}
|
||||
</p>
|
||||
<div class="container mx-auto py-20 relative px-4">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-12">
|
||||
<h1
|
||||
class="text-4xl md:text-5xl font-bold mb-4 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
|
||||
>
|
||||
{$_("imprint.title")}
|
||||
</h1>
|
||||
<p class="text-lg text-muted-foreground">
|
||||
{$_("imprint.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Company Information -->
|
||||
<Card class="mb-8 bg-gradient-to-br from-card to-card/50 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<span class="icon-[ri--building-2-line] w-5 h-5 text-primary"></span>
|
||||
{$_("imprint.company_information")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2">
|
||||
{$_("imprint.company_name.title")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{$_("imprint.company_name.value")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2">
|
||||
{$_("imprint.legal_form.title")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{$_("imprint.legal_form.value")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2">
|
||||
{$_("imprint.registration_number.title")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{$_("imprint.registration_number.value")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2">{$_("imprint.tax_id.title")}</h3>
|
||||
<p class="text-muted-foreground">{$_("imprint.tax_id.value")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Company Information -->
|
||||
<Card class="mb-8 bg-gradient-to-br from-card to-card/50 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<span class="icon-[ri--building-2-line] w-5 h-5 text-primary"></span>
|
||||
{$_("imprint.company_information")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2">
|
||||
{$_("imprint.company_name.title")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{$_("imprint.company_name.value")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2">
|
||||
{$_("imprint.legal_form.title")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{$_("imprint.legal_form.value")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2">
|
||||
{$_("imprint.registration_number.title")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{$_("imprint.registration_number.value")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2">{$_("imprint.tax_id.title")}</h3>
|
||||
<p class="text-muted-foreground">{$_("imprint.tax_id.value")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Contact Information -->
|
||||
<Card class="mb-8 bg-gradient-to-br from-card to-card/50 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<span class="icon-[ri--mail-line] w-5 h-5 text-primary"></span>
|
||||
{$_("imprint.contact_information")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2">
|
||||
{$_("imprint.registered_address")}
|
||||
</h3>
|
||||
<div class="text-muted-foreground">
|
||||
<p>{$_("imprint.address.company")}</p>
|
||||
<p>{$_("imprint.address.name")}</p>
|
||||
<p>{$_("imprint.address.street")}</p>
|
||||
<p>{$_("imprint.address.city")}</p>
|
||||
<p>{$_("imprint.address.country")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div>
|
||||
<!-- Contact Information -->
|
||||
<Card class="mb-8 bg-gradient-to-br from-card to-card/50 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<span class="icon-[ri--mail-line] w-5 h-5 text-primary"></span>
|
||||
{$_("imprint.contact_information")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2">
|
||||
{$_("imprint.registered_address")}
|
||||
</h3>
|
||||
<div class="text-muted-foreground">
|
||||
<p>{$_("imprint.address.company")}</p>
|
||||
<p>{$_("imprint.address.name")}</p>
|
||||
<p>{$_("imprint.address.street")}</p>
|
||||
<p>{$_("imprint.address.city")}</p>
|
||||
<p>{$_("imprint.address.country")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div>
|
||||
<h3 class="font-semibold mb-2">Business Address</h3>
|
||||
<div class="text-muted-foreground">
|
||||
<p>Sexy.Art Studios</p>
|
||||
@@ -106,42 +101,42 @@ import Meta from "$lib/components/meta/meta.svelte";
|
||||
<p>United States</p>
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator />
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="icon-[ri--phone-line] w-5 h-5 text-primary mt-0.5"></span>
|
||||
<div>
|
||||
<h3 class="font-semibold mb-1">{$_("imprint.phone.title")}</h3>
|
||||
<p class="text-muted-foreground">{$_("imprint.phone.value")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="icon-[ri--mail-line] w-5 h-5 text-primary mt-0.5"></span>
|
||||
<div>
|
||||
<h3 class="font-semibold mb-1">{$_("imprint.email.title")}</h3>
|
||||
<p class="text-muted-foreground">{$_("imprint.email.value")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="icon-[ri--global-line] w-5 h-5 text-primary mt-0.5"></span>
|
||||
<div>
|
||||
<h3 class="font-semibold mb-1">
|
||||
{$_("imprint.website.title")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{$_("imprint.website.value")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="icon-[ri--phone-line] w-5 h-5 text-primary mt-0.5"></span>
|
||||
<div>
|
||||
<h3 class="font-semibold mb-1">{$_("imprint.phone.title")}</h3>
|
||||
<p class="text-muted-foreground">{$_("imprint.phone.value")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="icon-[ri--mail-line] w-5 h-5 text-primary mt-0.5"></span>
|
||||
<div>
|
||||
<h3 class="font-semibold mb-1">{$_("imprint.email.title")}</h3>
|
||||
<p class="text-muted-foreground">{$_("imprint.email.value")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="icon-[ri--global-line] w-5 h-5 text-primary mt-0.5"></span>
|
||||
<div>
|
||||
<h3 class="font-semibold mb-1">
|
||||
{$_("imprint.website.title")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{$_("imprint.website.value")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Management -->
|
||||
<!-- <Card
|
||||
<!-- Management -->
|
||||
<!-- <Card
|
||||
class="mb-8 bg-gradient-to-br from-card to-card/50 border-primary/20"
|
||||
>
|
||||
<CardHeader>
|
||||
@@ -173,8 +168,8 @@ import Meta from "$lib/components/meta/meta.svelte";
|
||||
</CardContent>
|
||||
</Card> -->
|
||||
|
||||
<!-- Regulatory Information -->
|
||||
<!-- <Card
|
||||
<!-- Regulatory Information -->
|
||||
<!-- <Card
|
||||
class="mb-8 bg-gradient-to-br from-card to-card/50 border-primary/20"
|
||||
>
|
||||
<CardHeader>
|
||||
@@ -215,8 +210,8 @@ import Meta from "$lib/components/meta/meta.svelte";
|
||||
</CardContent>
|
||||
</Card> -->
|
||||
|
||||
<!-- Technical Information -->
|
||||
<!-- <Card
|
||||
<!-- Technical Information -->
|
||||
<!-- <Card
|
||||
class="mb-8 bg-gradient-to-br from-card to-card/50 border-primary/20"
|
||||
>
|
||||
<CardHeader>
|
||||
@@ -246,28 +241,28 @@ import Meta from "$lib/components/meta/meta.svelte";
|
||||
</CardContent>
|
||||
</Card> -->
|
||||
|
||||
<!-- Disclaimer -->
|
||||
<Card class="bg-gradient-to-br from-card to-card/50 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle>{$_("imprint.disclaimer")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
{$_("imprint.disclaimer_text.0")}
|
||||
</p>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
{$_("imprint.disclaimer_text.1")}
|
||||
</p>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
{$_("imprint.disclaimer_text.2")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<!-- Disclaimer -->
|
||||
<Card class="bg-gradient-to-br from-card to-card/50 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle>{$_("imprint.disclaimer")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
{$_("imprint.disclaimer_text.0")}
|
||||
</p>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
{$_("imprint.disclaimer_text.1")}
|
||||
</p>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
{$_("imprint.disclaimer_text.2")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Last Updated -->
|
||||
<div class="text-center mt-8 text-sm text-muted-foreground">
|
||||
<p>{$_("imprint.last_updated")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Last Updated -->
|
||||
<div class="text-center mt-8 text-sm text-muted-foreground">
|
||||
<p>{$_("imprint.last_updated")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,55 +6,61 @@ import { getGraphQLClient } from "$lib/api";
|
||||
const LEADERBOARD_QUERY = gql`
|
||||
query Leaderboard($limit: Int, $offset: Int) {
|
||||
leaderboard(limit: $limit, offset: $offset) {
|
||||
user_id display_name avatar
|
||||
total_weighted_points total_raw_points
|
||||
recordings_count playbacks_count achievements_count rank
|
||||
user_id
|
||||
display_name
|
||||
avatar
|
||||
total_weighted_points
|
||||
total_raw_points
|
||||
recordings_count
|
||||
playbacks_count
|
||||
achievements_count
|
||||
rank
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, url, locals }) => {
|
||||
// Guard: Redirect to login if not authenticated
|
||||
if (!locals.authStatus.authenticated) {
|
||||
throw redirect(302, "/login");
|
||||
}
|
||||
// Guard: Redirect to login if not authenticated
|
||||
if (!locals.authStatus.authenticated) {
|
||||
throw redirect(302, "/login");
|
||||
}
|
||||
|
||||
try {
|
||||
const limit = parseInt(url.searchParams.get("limit") || "100");
|
||||
const offset = parseInt(url.searchParams.get("offset") || "0");
|
||||
try {
|
||||
const limit = parseInt(url.searchParams.get("limit") || "100");
|
||||
const offset = parseInt(url.searchParams.get("offset") || "0");
|
||||
|
||||
const client = getGraphQLClient(fetch);
|
||||
const data = await client.request<{
|
||||
leaderboard: {
|
||||
user_id: string;
|
||||
display_name: string | null;
|
||||
avatar: string | null;
|
||||
total_weighted_points: number | null;
|
||||
total_raw_points: number | null;
|
||||
recordings_count: number | null;
|
||||
playbacks_count: number | null;
|
||||
achievements_count: number | null;
|
||||
rank: number;
|
||||
}[];
|
||||
}>(LEADERBOARD_QUERY, { limit, offset });
|
||||
const client = getGraphQLClient(fetch);
|
||||
const data = await client.request<{
|
||||
leaderboard: {
|
||||
user_id: string;
|
||||
display_name: string | null;
|
||||
avatar: string | null;
|
||||
total_weighted_points: number | null;
|
||||
total_raw_points: number | null;
|
||||
recordings_count: number | null;
|
||||
playbacks_count: number | null;
|
||||
achievements_count: number | null;
|
||||
rank: number;
|
||||
}[];
|
||||
}>(LEADERBOARD_QUERY, { limit, offset });
|
||||
|
||||
return {
|
||||
leaderboard: data.leaderboard || [],
|
||||
pagination: {
|
||||
limit,
|
||||
offset,
|
||||
hasMore: data.leaderboard?.length === limit,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Leaderboard load error:", error);
|
||||
return {
|
||||
leaderboard: [],
|
||||
pagination: {
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
leaderboard: data.leaderboard || [],
|
||||
pagination: {
|
||||
limit,
|
||||
offset,
|
||||
hasMore: data.leaderboard?.length === limit,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Leaderboard load error:", error);
|
||||
return {
|
||||
leaderboard: [],
|
||||
pagination: {
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,192 +1,204 @@
|
||||
<script lang="ts">
|
||||
import { _, locale } from "svelte-i18n";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "$lib/components/ui/card";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "$lib/components/ui/avatar";
|
||||
import { getAssetUrl } from "$lib/directus";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||
import { _, locale } from "svelte-i18n";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "$lib/components/ui/card";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "$lib/components/ui/avatar";
|
||||
import { getAssetUrl } from "$lib/directus";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||
|
||||
const { data } = $props();
|
||||
const { data } = $props();
|
||||
|
||||
// Format points with comma separator
|
||||
function formatPoints(points: number): string {
|
||||
return Math.round(points).toLocaleString($locale || "en");
|
||||
}
|
||||
// Format points with comma separator
|
||||
function formatPoints(points: number): string {
|
||||
return Math.round(points).toLocaleString($locale || "en");
|
||||
}
|
||||
|
||||
// Get medal emoji for top 3
|
||||
function getMedalEmoji(rank: number): string {
|
||||
switch (rank) {
|
||||
case 1:
|
||||
return "🥇";
|
||||
case 2:
|
||||
return "🥈";
|
||||
case 3:
|
||||
return "🥉";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
// Get medal emoji for top 3
|
||||
function getMedalEmoji(rank: number): string {
|
||||
switch (rank) {
|
||||
case 1:
|
||||
return "🥇";
|
||||
case 2:
|
||||
return "🥈";
|
||||
case 3:
|
||||
return "🥉";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// Get user initials
|
||||
function getUserInitials(name: string): string {
|
||||
if (!name) return "?";
|
||||
const parts = name.split(" ");
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
|
||||
}
|
||||
return name.substring(0, 2).toUpperCase();
|
||||
}
|
||||
// Get user initials
|
||||
function getUserInitials(name: string): string {
|
||||
if (!name) return "?";
|
||||
const parts = name.split(" ");
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
|
||||
}
|
||||
return name.substring(0, 2).toUpperCase();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Meta title={$_("gamification.leaderboard")} description={$_("gamification.leaderboard_description")} />
|
||||
<Meta
|
||||
title={$_("gamification.leaderboard")}
|
||||
description={$_("gamification.leaderboard_description")}
|
||||
/>
|
||||
|
||||
<div class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5">
|
||||
<PeonyBackground />
|
||||
<PeonyBackground />
|
||||
|
||||
<div class="container mx-auto px-4 py-8 relative z-10">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold mb-2">{$_("gamification.leaderboard")}</h1>
|
||||
<p class="text-muted-foreground">{$_("gamification.leaderboard_subtitle")}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" href="/">
|
||||
<span class="icon-[ri--arrow-left-line] w-4 h-4 mr-2"></span>
|
||||
{$_("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="container mx-auto px-4 py-8 relative z-10">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold mb-2">{$_("gamification.leaderboard")}</h1>
|
||||
<p class="text-muted-foreground">{$_("gamification.leaderboard_subtitle")}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" href="/">
|
||||
<span class="icon-[ri--arrow-left-line] w-4 h-4 mr-2"></span>
|
||||
{$_("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Leaderboard Card -->
|
||||
<Card class="bg-card/90 backdrop-blur-sm border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<span class="icon-[ri--trophy-line] w-5 h-5 text-primary"></span>
|
||||
{$_("gamification.top_players")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if data.leaderboard.length === 0}
|
||||
<div class="text-center py-12 text-muted-foreground">
|
||||
<span class="icon-[ri--trophy-line] w-12 h-12 mx-auto mb-4 opacity-50"></span>
|
||||
<p>{$_("gamification.no_rankings_yet")}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each data.leaderboard as entry (entry.user_id)}
|
||||
<a
|
||||
href="/users/{entry.user_id}"
|
||||
class="flex items-center gap-4 p-4 rounded-lg hover:bg-accent/10 transition-colors group"
|
||||
>
|
||||
<!-- Rank Badge -->
|
||||
<div class="flex-shrink-0 w-14 text-center">
|
||||
{#if entry.rank <= 3}
|
||||
<span class="text-3xl">{getMedalEmoji(entry.rank)}</span>
|
||||
{:else}
|
||||
<span class="text-xl font-bold text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
#{entry.rank}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Leaderboard Card -->
|
||||
<Card class="bg-card/90 backdrop-blur-sm border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<span class="icon-[ri--trophy-line] w-5 h-5 text-primary"></span>
|
||||
{$_("gamification.top_players")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if data.leaderboard.length === 0}
|
||||
<div class="text-center py-12 text-muted-foreground">
|
||||
<span class="icon-[ri--trophy-line] w-12 h-12 mx-auto mb-4 opacity-50"></span>
|
||||
<p>{$_("gamification.no_rankings_yet")}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each data.leaderboard as entry (entry.user_id)}
|
||||
<a
|
||||
href="/users/{entry.user_id}"
|
||||
class="flex items-center gap-4 p-4 rounded-lg hover:bg-accent/10 transition-colors group"
|
||||
>
|
||||
<!-- Rank Badge -->
|
||||
<div class="flex-shrink-0 w-14 text-center">
|
||||
{#if entry.rank <= 3}
|
||||
<span class="text-3xl">{getMedalEmoji(entry.rank)}</span>
|
||||
{:else}
|
||||
<span
|
||||
class="text-xl font-bold text-muted-foreground group-hover:text-foreground transition-colors"
|
||||
>
|
||||
#{entry.rank}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Avatar -->
|
||||
<Avatar class="h-12 w-12 ring-2 ring-accent/20 group-hover:ring-primary/40 transition-all">
|
||||
{#if entry.avatar}
|
||||
<AvatarImage src={getAssetUrl(entry.avatar, "mini")} alt={entry.display_name} />
|
||||
{/if}
|
||||
<AvatarFallback class="bg-gradient-to-br from-primary to-accent text-primary-foreground font-semibold">
|
||||
{getUserInitials(entry.display_name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<!-- Avatar -->
|
||||
<Avatar
|
||||
class="h-12 w-12 ring-2 ring-accent/20 group-hover:ring-primary/40 transition-all"
|
||||
>
|
||||
{#if entry.avatar}
|
||||
<AvatarImage src={getAssetUrl(entry.avatar, "mini")} alt={entry.display_name} />
|
||||
{/if}
|
||||
<AvatarFallback
|
||||
class="bg-gradient-to-br from-primary to-accent text-primary-foreground font-semibold"
|
||||
>
|
||||
{getUserInitials(entry.display_name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<!-- User Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold truncate group-hover:text-primary transition-colors">
|
||||
{entry.display_name || $_("common.anonymous")}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground flex items-center gap-3">
|
||||
<span title={$_("gamification.recordings")}>
|
||||
<span class="icon-[ri--video-line] w-3.5 h-3.5 inline mr-1"></span>
|
||||
{entry.recordings_count}
|
||||
</span>
|
||||
<span title={$_("gamification.plays")}>
|
||||
<span class="icon-[ri--play-line] w-3.5 h-3.5 inline mr-1"></span>
|
||||
{entry.playbacks_count}
|
||||
</span>
|
||||
<span title={$_("gamification.achievements")}>
|
||||
<span class="icon-[ri--trophy-line] w-3.5 h-3.5 inline mr-1"></span>
|
||||
{entry.achievements_count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- User Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold truncate group-hover:text-primary transition-colors">
|
||||
{entry.display_name || $_("common.anonymous")}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground flex items-center gap-3">
|
||||
<span title={$_("gamification.recordings")}>
|
||||
<span class="icon-[ri--video-line] w-3.5 h-3.5 inline mr-1"></span>
|
||||
{entry.recordings_count}
|
||||
</span>
|
||||
<span title={$_("gamification.plays")}>
|
||||
<span class="icon-[ri--play-line] w-3.5 h-3.5 inline mr-1"></span>
|
||||
{entry.playbacks_count}
|
||||
</span>
|
||||
<span title={$_("gamification.achievements")}>
|
||||
<span class="icon-[ri--trophy-line] w-3.5 h-3.5 inline mr-1"></span>
|
||||
{entry.achievements_count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Score -->
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-2xl font-bold text-primary">
|
||||
{formatPoints(entry.total_weighted_points)}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{$_("gamification.points")}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Score -->
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-2xl font-bold text-primary">
|
||||
{formatPoints(entry.total_weighted_points)}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{$_("gamification.points")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow indicator -->
|
||||
<div class="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span class="icon-[ri--arrow-right-s-line] w-5 h-5 text-muted-foreground"></span>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- Arrow indicator -->
|
||||
<div class="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span class="icon-[ri--arrow-right-s-line] w-5 h-5 text-muted-foreground"></span>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if data.pagination.hasMore}
|
||||
<div class="mt-6 text-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
href="/leaderboard?offset={data.pagination.offset + data.pagination.limit}&limit={data.pagination.limit}"
|
||||
>
|
||||
{$_("common.load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<!-- Pagination -->
|
||||
{#if data.pagination.hasMore}
|
||||
<div class="mt-6 text-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
href="/leaderboard?offset={data.pagination.offset +
|
||||
data.pagination.limit}&limit={data.pagination.limit}"
|
||||
>
|
||||
{$_("common.load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Info Card -->
|
||||
<Card class="mt-6 bg-card/90 backdrop-blur-sm border-border/50">
|
||||
<CardContent class="p-6">
|
||||
<h3 class="font-semibold mb-2 flex items-center gap-2">
|
||||
<span class="icon-[ri--information-line] w-4 h-4 text-primary"></span>
|
||||
{$_("gamification.how_it_works")}
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
{$_("gamification.how_it_works_description")}
|
||||
</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="icon-[ri--video-add-line] w-5 h-5 text-primary flex-shrink-0 mt-0.5"></span>
|
||||
<div>
|
||||
<div class="font-medium">{$_("gamification.earn_by_creating")}</div>
|
||||
<div class="text-muted-foreground">{$_("gamification.earn_by_creating_desc")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="icon-[ri--play-circle-line] w-5 h-5 text-primary flex-shrink-0 mt-0.5"></span>
|
||||
<div>
|
||||
<div class="font-medium">{$_("gamification.earn_by_playing")}</div>
|
||||
<div class="text-muted-foreground">{$_("gamification.earn_by_playing_desc")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="icon-[ri--time-line] w-5 h-5 text-primary flex-shrink-0 mt-0.5"></span>
|
||||
<div>
|
||||
<div class="font-medium">{$_("gamification.stay_active")}</div>
|
||||
<div class="text-muted-foreground">{$_("gamification.stay_active_desc")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<!-- Info Card -->
|
||||
<Card class="mt-6 bg-card/90 backdrop-blur-sm border-border/50">
|
||||
<CardContent class="p-6">
|
||||
<h3 class="font-semibold mb-2 flex items-center gap-2">
|
||||
<span class="icon-[ri--information-line] w-4 h-4 text-primary"></span>
|
||||
{$_("gamification.how_it_works")}
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
{$_("gamification.how_it_works_description")}
|
||||
</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="icon-[ri--video-add-line] w-5 h-5 text-primary flex-shrink-0 mt-0.5"
|
||||
></span>
|
||||
<div>
|
||||
<div class="font-medium">{$_("gamification.earn_by_creating")}</div>
|
||||
<div class="text-muted-foreground">{$_("gamification.earn_by_creating_desc")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="icon-[ri--play-circle-line] w-5 h-5 text-primary flex-shrink-0 mt-0.5"
|
||||
></span>
|
||||
<div>
|
||||
<div class="font-medium">{$_("gamification.earn_by_playing")}</div>
|
||||
<div class="text-muted-foreground">{$_("gamification.earn_by_playing_desc")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="icon-[ri--time-line] w-5 h-5 text-primary flex-shrink-0 mt-0.5"></span>
|
||||
<div>
|
||||
<div class="font-medium">{$_("gamification.stay_active")}</div>
|
||||
<div class="text-muted-foreground">{$_("gamification.stay_active_desc")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,366 +1,355 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "$lib/components/ui/card";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "$lib/components/ui/tabs";
|
||||
import { Separator } from "$lib/components/ui/separator";
|
||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "$lib/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "$lib/components/ui/tabs";
|
||||
import { Separator } from "$lib/components/ui/separator";
|
||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
|
||||
let activeTab = $state("privacy");
|
||||
let activeTab = $state("privacy");
|
||||
</script>
|
||||
|
||||
<Meta title={$_("legal.title")} description={$_("legal.description")} />
|
||||
|
||||
<div
|
||||
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
||||
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
||||
>
|
||||
<PeonyBackground />
|
||||
<PeonyBackground />
|
||||
|
||||
<div class="container mx-auto py-20 relative px-4">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-12">
|
||||
<h1
|
||||
class="text-4xl md:text-5xl font-bold mb-4 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
|
||||
>
|
||||
{$_("legal.title")}
|
||||
</h1>
|
||||
<p class="text-lg text-muted-foreground">
|
||||
{$_("legal.description")}
|
||||
</p>
|
||||
</div>
|
||||
<div class="container mx-auto py-20 relative px-4">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-12">
|
||||
<h1
|
||||
class="text-4xl md:text-5xl font-bold mb-4 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
|
||||
>
|
||||
{$_("legal.title")}
|
||||
</h1>
|
||||
<p class="text-lg text-muted-foreground">
|
||||
{$_("legal.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Legal Tabs -->
|
||||
<Tabs bind:value={activeTab} class="w-full">
|
||||
<TabsList class="h-auto grid w-full grid-cols-1 md:grid-cols-4 mb-8">
|
||||
<TabsTrigger value="privacy" class="flex items-center gap-2">
|
||||
<span class="icon-[ri--shield-line] w-4 h-4"></span>
|
||||
{$_("legal.privacy.title")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="terms" class="flex items-center gap-2">
|
||||
<span class="icon-[ri--file-list-3-line] w-4 h-4"></span>
|
||||
{$_("legal.terms.title")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="community" class="flex items-center gap-2">
|
||||
<span class="icon-[ri--eye-line] w-4 h-4"></span>
|
||||
{$_("legal.community.title")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="cookies" class="flex items-center gap-2">
|
||||
<span class="icon-[ri--cake-3-line] w-4 h-4"></span>
|
||||
{$_("legal.cookie.title")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<!-- Legal Tabs -->
|
||||
<Tabs bind:value={activeTab} class="w-full">
|
||||
<TabsList class="h-auto grid w-full grid-cols-1 md:grid-cols-4 mb-8">
|
||||
<TabsTrigger value="privacy" class="flex items-center gap-2">
|
||||
<span class="icon-[ri--shield-line] w-4 h-4"></span>
|
||||
{$_("legal.privacy.title")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="terms" class="flex items-center gap-2">
|
||||
<span class="icon-[ri--file-list-3-line] w-4 h-4"></span>
|
||||
{$_("legal.terms.title")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="community" class="flex items-center gap-2">
|
||||
<span class="icon-[ri--eye-line] w-4 h-4"></span>
|
||||
{$_("legal.community.title")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="cookies" class="flex items-center gap-2">
|
||||
<span class="icon-[ri--cake-3-line] w-4 h-4"></span>
|
||||
{$_("legal.cookie.title")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<!-- Privacy Policy -->
|
||||
<TabsContent value="privacy">
|
||||
<Card class="bg-gradient-to-br from-card to-card/50 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<span class="icon-[ri--shield-line] w-5 h-5 text-primary"></span>
|
||||
{$_("legal.privacy.title")}
|
||||
</CardTitle>
|
||||
<p class="text-muted-foreground">
|
||||
{$_("legal.privacy.last_updated")}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.privacy.information.title")}
|
||||
</h3>
|
||||
<div class="space-y-3 text-muted-foreground">
|
||||
<p>
|
||||
{@html $_("legal.privacy.information.text.0")}
|
||||
</p>
|
||||
<p>
|
||||
{@html $_("legal.privacy.information.text.1")}
|
||||
</p>
|
||||
<!-- <p>
|
||||
<!-- Privacy Policy -->
|
||||
<TabsContent value="privacy">
|
||||
<Card class="bg-gradient-to-br from-card to-card/50 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<span class="icon-[ri--shield-line] w-5 h-5 text-primary"></span>
|
||||
{$_("legal.privacy.title")}
|
||||
</CardTitle>
|
||||
<p class="text-muted-foreground">
|
||||
{$_("legal.privacy.last_updated")}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.privacy.information.title")}
|
||||
</h3>
|
||||
<div class="space-y-3 text-muted-foreground">
|
||||
<p>
|
||||
{@html $_("legal.privacy.information.text.0")}
|
||||
</p>
|
||||
<p>
|
||||
{@html $_("legal.privacy.information.text.1")}
|
||||
</p>
|
||||
<!-- <p>
|
||||
<strong>Usage Information:</strong>
|
||||
We automatically collect information about how you use our services,
|
||||
including your IP address, browser type, and device information.
|
||||
</p> -->
|
||||
<!-- <p>
|
||||
<!-- <p>
|
||||
<strong>Payment Information:</strong>
|
||||
When you make purchases, we collect payment information through
|
||||
secure third-party processors.
|
||||
</p> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.privacy.information_use.title")}
|
||||
</h3>
|
||||
<div class="space-y-3 text-muted-foreground">
|
||||
<p>{$_("legal.privacy.information_use.subtitle")}</p>
|
||||
<ul class="list-disc pl-6 space-y-1">
|
||||
{@html $_("legal.privacy.information_use.text.0")}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.privacy.information_use.title")}
|
||||
</h3>
|
||||
<div class="space-y-3 text-muted-foreground">
|
||||
<p>{$_("legal.privacy.information_use.subtitle")}</p>
|
||||
<ul class="list-disc pl-6 space-y-1">
|
||||
{@html $_("legal.privacy.information_use.text.0")}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.privacy.information_sharing.title")}
|
||||
</h3>
|
||||
<div class="space-y-3 text-muted-foreground">
|
||||
<p>
|
||||
{$_("legal.privacy.information_sharing.subtitle")}
|
||||
</p>
|
||||
<ul class="list-disc pl-6 space-y-1">
|
||||
{@html $_("legal.privacy.information_sharing.text.0")}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.privacy.information_sharing.title")}
|
||||
</h3>
|
||||
<div class="space-y-3 text-muted-foreground">
|
||||
<p>
|
||||
{$_("legal.privacy.information_sharing.subtitle")}
|
||||
</p>
|
||||
<ul class="list-disc pl-6 space-y-1">
|
||||
{@html $_("legal.privacy.information_sharing.text.0")}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.privacy.security.title")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{@html $_("legal.privacy.security.text.0")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.privacy.security.title")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{@html $_("legal.privacy.security.text.0")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.privacy.rights.title")}
|
||||
</h3>
|
||||
<div class="space-y-3 text-muted-foreground">
|
||||
<p>{$_("legal.privacy.rights.subtitle")}</p>
|
||||
<ul class="list-disc pl-6 space-y-1">
|
||||
{@html $_("legal.privacy.rights.text.0")}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.privacy.rights.title")}
|
||||
</h3>
|
||||
<div class="space-y-3 text-muted-foreground">
|
||||
<p>{$_("legal.privacy.rights.subtitle")}</p>
|
||||
<ul class="list-disc pl-6 space-y-1">
|
||||
{@html $_("legal.privacy.rights.text.0")}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<!-- Terms of Service -->
|
||||
<TabsContent value="terms">
|
||||
<Card class="bg-gradient-to-br from-card to-card/50 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<span class="icon-[ri--file-list-3-line] w-5 h-5 text-primary"
|
||||
></span>
|
||||
{$_("legal.terms.title")}
|
||||
</CardTitle>
|
||||
<p class="text-muted-foreground">
|
||||
{$_("legal.terms.last_updated")}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.terms.acceptance.title")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{@html $_("legal.terms.acceptance.text.0")}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Terms of Service -->
|
||||
<TabsContent value="terms">
|
||||
<Card class="bg-gradient-to-br from-card to-card/50 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<span class="icon-[ri--file-list-3-line] w-5 h-5 text-primary"></span>
|
||||
{$_("legal.terms.title")}
|
||||
</CardTitle>
|
||||
<p class="text-muted-foreground">
|
||||
{$_("legal.terms.last_updated")}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.terms.acceptance.title")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{@html $_("legal.terms.acceptance.text.0")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.terms.age.title")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{@html $_("legal.terms.age.text.0")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.terms.age.title")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{@html $_("legal.terms.age.text.0")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.terms.accounts.title")}
|
||||
</h3>
|
||||
<div class="space-y-3 text-muted-foreground">
|
||||
<p>{$_("legal.terms.accounts.subtitle")}</p>
|
||||
<ul class="list-disc pl-6 space-y-1">
|
||||
{@html $_("legal.terms.accounts.text.0")}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.terms.accounts.title")}
|
||||
</h3>
|
||||
<div class="space-y-3 text-muted-foreground">
|
||||
<p>{$_("legal.terms.accounts.subtitle")}</p>
|
||||
<ul class="list-disc pl-6 space-y-1">
|
||||
{@html $_("legal.terms.accounts.text.0")}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.terms.content.title")}
|
||||
</h3>
|
||||
<div class="space-y-3 text-muted-foreground">
|
||||
<p>
|
||||
{$_("legal.terms.content.subtitle")}
|
||||
</p>
|
||||
<ul class="list-disc pl-6 space-y-1">
|
||||
{@html $_("legal.terms.content.text.0")}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.terms.content.title")}
|
||||
</h3>
|
||||
<div class="space-y-3 text-muted-foreground">
|
||||
<p>
|
||||
{$_("legal.terms.content.subtitle")}
|
||||
</p>
|
||||
<ul class="list-disc pl-6 space-y-1">
|
||||
{@html $_("legal.terms.content.text.0")}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.terms.payment.title")}
|
||||
</h3>
|
||||
<div class="space-y-3 text-muted-foreground">
|
||||
<p>{$_("legal.terms.payment.subtitle")}</p>
|
||||
<ul class="list-disc pl-6 space-y-1">
|
||||
{@html $_("legal.terms.payment.text.0")}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.terms.payment.title")}
|
||||
</h3>
|
||||
<div class="space-y-3 text-muted-foreground">
|
||||
<p>{$_("legal.terms.payment.subtitle")}</p>
|
||||
<ul class="list-disc pl-6 space-y-1">
|
||||
{@html $_("legal.terms.payment.text.0")}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.terms.termination.title")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{@html $_("legal.terms.termination.text.0")}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.terms.termination.title")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{@html $_("legal.terms.termination.text.0")}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<!-- Community Guidelines -->
|
||||
<TabsContent value="community">
|
||||
<Card class="bg-gradient-to-br from-card to-card/50 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<span class="icon-[ri--eye-line] w-5 h-5 text-primary"></span>
|
||||
{$_("legal.community.title")}
|
||||
</CardTitle>
|
||||
<p class="text-muted-foreground">
|
||||
{$_("legal.community.description")}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.community.values.title")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{@html $_("legal.community.values.text.0")}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Community Guidelines -->
|
||||
<TabsContent value="community">
|
||||
<Card class="bg-gradient-to-br from-card to-card/50 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<span class="icon-[ri--eye-line] w-5 h-5 text-primary"></span>
|
||||
{$_("legal.community.title")}
|
||||
</CardTitle>
|
||||
<p class="text-muted-foreground">
|
||||
{$_("legal.community.description")}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.community.values.title")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{@html $_("legal.community.values.text.0")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.community.respect.title")}
|
||||
</h3>
|
||||
<div class="space-y-3 text-muted-foreground">
|
||||
<ul class="list-disc pl-6 space-y-1">
|
||||
{@html $_("legal.community.respect.text.0")}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.community.respect.title")}
|
||||
</h3>
|
||||
<div class="space-y-3 text-muted-foreground">
|
||||
<ul class="list-disc pl-6 space-y-1">
|
||||
{@html $_("legal.community.respect.text.0")}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.community.standards.title")}
|
||||
</h3>
|
||||
<div class="space-y-3 text-muted-foreground">
|
||||
<ul class="list-disc pl-6 space-y-1">
|
||||
{@html $_("legal.community.standards.text.0")}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.community.standards.title")}
|
||||
</h3>
|
||||
<div class="space-y-3 text-muted-foreground">
|
||||
<ul class="list-disc pl-6 space-y-1">
|
||||
{@html $_("legal.community.standards.text.0")}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.community.interaction.title")}
|
||||
</h3>
|
||||
<div class="space-y-3 text-muted-foreground">
|
||||
<ul class="list-disc pl-6 space-y-1">
|
||||
{@html $_("legal.community.interaction.text.0")}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.community.interaction.title")}
|
||||
</h3>
|
||||
<div class="space-y-3 text-muted-foreground">
|
||||
<ul class="list-disc pl-6 space-y-1">
|
||||
{@html $_("legal.community.interaction.text.0")}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.community.enforcement.title")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{@html $_("legal.community.enforcement.text.0")}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.community.enforcement.title")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{@html $_("legal.community.enforcement.text.0")}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<!-- Cookie Policy -->
|
||||
<TabsContent value="cookies">
|
||||
<Card class="bg-gradient-to-br from-card to-card/50 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<span class="icon-[ri--cake-3-line] w-5 h-5 text-primary"></span>
|
||||
{$_("legal.cookie.title")}
|
||||
</CardTitle>
|
||||
<p class="text-muted-foreground">
|
||||
{$_("legal.cookie.description")}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.cookie.what.title")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{@html $_("legal.cookie.what.text.0")}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Cookie Policy -->
|
||||
<TabsContent value="cookies">
|
||||
<Card class="bg-gradient-to-br from-card to-card/50 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<span class="icon-[ri--cake-3-line] w-5 h-5 text-primary"></span>
|
||||
{$_("legal.cookie.title")}
|
||||
</CardTitle>
|
||||
<p class="text-muted-foreground">
|
||||
{$_("legal.cookie.description")}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.cookie.what.title")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{@html $_("legal.cookie.what.text.0")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.cookie.types.title")}
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h4 class="font-medium mb-2">
|
||||
{$_("legal.cookie.types.essential.title")}
|
||||
</h4>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{@html $_("legal.cookie.types.essential.text.0")}
|
||||
</p>
|
||||
</div>
|
||||
<!-- <div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.cookie.types.title")}
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h4 class="font-medium mb-2">
|
||||
{$_("legal.cookie.types.essential.title")}
|
||||
</h4>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{@html $_("legal.cookie.types.essential.text.0")}
|
||||
</p>
|
||||
</div>
|
||||
<!-- <div>
|
||||
<h4 class="font-medium mb-2">Performance Cookies</h4>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
These cookies collect information about how visitors use
|
||||
@@ -382,56 +371,55 @@ let activeTab = $state("privacy");
|
||||
used and how we can improve it.
|
||||
</p>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.cookie.managing.title")}
|
||||
</h3>
|
||||
<div class="space-y-3 text-muted-foreground">
|
||||
<p>{$_("legal.cookie.managing.subtitle")}</p>
|
||||
<ul class="list-disc pl-6 space-y-1">
|
||||
{@html $_("legal.cookie.managing.text.0")}
|
||||
</ul>
|
||||
<p>
|
||||
{@html $_("legal.cookie.managing.text.1")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.cookie.managing.title")}
|
||||
</h3>
|
||||
<div class="space-y-3 text-muted-foreground">
|
||||
<p>{$_("legal.cookie.managing.subtitle")}</p>
|
||||
<ul class="list-disc pl-6 space-y-1">
|
||||
{@html $_("legal.cookie.managing.text.0")}
|
||||
</ul>
|
||||
<p>
|
||||
{@html $_("legal.cookie.managing.text.1")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.cookie.third_party.title")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{@html $_("legal.cookie.third_party.text.0")}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
{$_("legal.cookie.third_party.title")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{@html $_("legal.cookie.third_party.text.0")}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<!-- Contact Information -->
|
||||
<Card class="mt-8 bg-gradient-to-br from-card to-card/50 border-primary/20">
|
||||
<CardContent class="p-6 text-center">
|
||||
<h3 class="font-semibold mb-2">
|
||||
{$_("legal.questions")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground mb-4">
|
||||
{$_("legal.questions_description")}
|
||||
</p>
|
||||
<a
|
||||
href="mailto:{$_('legal.questions_email')}"
|
||||
class="text-primary hover:underline">{$_("legal.questions_email")}</a
|
||||
>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<!-- Contact Information -->
|
||||
<Card class="mt-8 bg-gradient-to-br from-card to-card/50 border-primary/20">
|
||||
<CardContent class="p-6 text-center">
|
||||
<h3 class="font-semibold mb-2">
|
||||
{$_("legal.questions")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground mb-4">
|
||||
{$_("legal.questions_description")}
|
||||
</p>
|
||||
<a href="mailto:{$_('legal.questions_email')}" class="text-primary hover:underline"
|
||||
>{$_("legal.questions_email")}</a
|
||||
>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export async function load({ locals }) {
|
||||
return {
|
||||
authStatus: locals.authStatus,
|
||||
};
|
||||
return {
|
||||
authStatus: locals.authStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,163 +1,163 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "$lib/components/ui/card";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import * as Alert from "$lib/components/ui/alert";
|
||||
import { goto } from "$app/navigation";
|
||||
import { login } from "$lib/services";
|
||||
import { onMount } from "svelte";
|
||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import Logo from "$lib/components/logo/logo.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "$lib/components/ui/card";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import * as Alert from "$lib/components/ui/alert";
|
||||
import { goto } from "$app/navigation";
|
||||
import { login } from "$lib/services";
|
||||
import { onMount } from "svelte";
|
||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import Logo from "$lib/components/logo/logo.svelte";
|
||||
|
||||
let email = $state("");
|
||||
let password = $state("");
|
||||
let error = $state("");
|
||||
let showPassword = $state(false);
|
||||
let _rememberMe = $state(false);
|
||||
let isLoading = $state(false);
|
||||
let isError = $state(false);
|
||||
let email = $state("");
|
||||
let password = $state("");
|
||||
let error = $state("");
|
||||
let showPassword = $state(false);
|
||||
let _rememberMe = $state(false);
|
||||
let isLoading = $state(false);
|
||||
let isError = $state(false);
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await login(email, password);
|
||||
goto("/videos", { invalidateAll: true });
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
isError = true;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await login(email, password);
|
||||
goto("/videos", { invalidateAll: true });
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
isError = true;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
const { data } = $props();
|
||||
const { data } = $props();
|
||||
|
||||
onMount(() => {
|
||||
if (!data.authStatus.authenticated) {
|
||||
return;
|
||||
}
|
||||
goto("/me");
|
||||
});
|
||||
onMount(() => {
|
||||
if (!data.authStatus.authenticated) {
|
||||
return;
|
||||
}
|
||||
goto("/me");
|
||||
});
|
||||
</script>
|
||||
|
||||
<Meta title={$_("auth.login.title")} description={$_("auth.login.description")} />
|
||||
|
||||
<div
|
||||
class="relative min-h-screen flex items-center justify-center bg-gradient-to-br from-primary/5 via-accent/5 to-background p-4 overflow-hidden"
|
||||
class="relative min-h-screen flex items-center justify-center bg-gradient-to-br from-primary/5 via-accent/5 to-background p-4 overflow-hidden"
|
||||
>
|
||||
<PeonyBackground />
|
||||
<PeonyBackground />
|
||||
|
||||
<div class="w-full max-w-md">
|
||||
<!-- Logo -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="flex items-center justify-center gap-3 text-2xl font-bold mb-2">
|
||||
|
||||
<Logo />
|
||||
<div class="w-full max-w-md">
|
||||
<!-- Logo -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="flex items-center justify-center gap-3 text-2xl font-bold mb-2">
|
||||
<Logo />
|
||||
</div>
|
||||
<p class="text-muted-foreground">{$_("auth.login.welcome")}</p>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
class="bg-gradient-to-br from-card/85 via-card/90 to-card/80 backdrop-blur-xl shadow-2xl shadow-primary/20"
|
||||
>
|
||||
<CardHeader class="text-center">
|
||||
<CardTitle class="text-2xl">{$_("auth.login.title")}</CardTitle>
|
||||
<CardDescription>{$_("auth.login.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<!-- Email -->
|
||||
<div class="space-y-2">
|
||||
<Label for="email">{$_("auth.login.email")}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder={$_("auth.login.email_placeholder")}
|
||||
bind:value={email}
|
||||
required
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="space-y-2">
|
||||
<Label for="password">{$_("auth.login.password")}</Label>
|
||||
<div class="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={$_("auth.login.password_placeholder")}
|
||||
bind:value={password}
|
||||
required
|
||||
class="bg-background/50 border-primary/20 focus:border-primary pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
class="cursor-pointer absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{#if showPassword}
|
||||
<span class="icon-[ri--eye-off-line] w-4 h-4"></span>
|
||||
{:else}
|
||||
<span class="icon-[ri--eye-line] w-4 h-4"></span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-muted-foreground">{$_("auth.login.welcome")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
class="bg-gradient-to-br from-card/85 via-card/90 to-card/80 backdrop-blur-xl shadow-2xl shadow-primary/20"
|
||||
>
|
||||
<CardHeader class="text-center">
|
||||
<CardTitle class="text-2xl">{$_("auth.login.title")}</CardTitle>
|
||||
<CardDescription>{$_("auth.login.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<!-- Email -->
|
||||
<div class="space-y-2">
|
||||
<Label for="email">{$_("auth.login.email")}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder={$_("auth.login.email_placeholder")}
|
||||
bind:value={email}
|
||||
required
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="space-y-2">
|
||||
<Label for="password">{$_("auth.login.password")}</Label>
|
||||
<div class="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={$_("auth.login.password_placeholder")}
|
||||
bind:value={password}
|
||||
required
|
||||
class="bg-background/50 border-primary/20 focus:border-primary pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
class="cursor-pointer absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{#if showPassword}
|
||||
<span class="icon-[ri--eye-off-line] w-4 h-4"></span>
|
||||
{:else}
|
||||
<span class="icon-[ri--eye-line] w-4 h-4"></span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remember Me & Forgot Password -->
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- <div class="flex items-center space-x-2">
|
||||
<!-- Remember Me & Forgot Password -->
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- <div class="flex items-center space-x-2">
|
||||
<Checkbox id="remember" bind:checked={rememberMe} />
|
||||
<Label for="remember" class="text-sm"
|
||||
>{$_("auth.login.remember_me")}</Label
|
||||
>
|
||||
</div> -->
|
||||
<a href="/password" class="text-sm text-primary hover:underline"
|
||||
>{$_("auth.login.forgot_password")}</a
|
||||
>
|
||||
</div>
|
||||
<a href="/password" class="text-sm text-primary hover:underline"
|
||||
>{$_("auth.login.forgot_password")}</a
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if isError}
|
||||
<div class="grid w-full max-w-xl items-start gap-4">
|
||||
<Alert.Root variant="destructive">
|
||||
<Alert.Title class="items-center flex"
|
||||
><span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"
|
||||
></span>{$_("auth.login.error")}</Alert.Title
|
||||
>
|
||||
<Alert.Description>{error}</Alert.Description>
|
||||
</Alert.Root>
|
||||
</div>
|
||||
{/if}
|
||||
{#if isError}
|
||||
<div class="grid w-full max-w-xl items-start gap-4">
|
||||
<Alert.Root variant="destructive">
|
||||
<Alert.Title class="items-center flex"
|
||||
><span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"></span>{$_(
|
||||
"auth.login.error",
|
||||
)}</Alert.Title
|
||||
>
|
||||
<Alert.Description>{error}</Alert.Description>
|
||||
</Alert.Root>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Submit Button -->
|
||||
<Button
|
||||
type="submit"
|
||||
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{#if isLoading}
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
|
||||
></div>
|
||||
{$_("auth.login.signing_in")}
|
||||
{:else}
|
||||
{$_("auth.login.sign_in")}
|
||||
{/if}
|
||||
</Button>
|
||||
</form>
|
||||
<!-- Submit Button -->
|
||||
<Button
|
||||
type="submit"
|
||||
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{#if isLoading}
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
|
||||
></div>
|
||||
{$_("auth.login.signing_in")}
|
||||
{:else}
|
||||
{$_("auth.login.sign_in")}
|
||||
{/if}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<!-- Divider -->
|
||||
<!-- <div class="relative">
|
||||
<!-- Divider -->
|
||||
<!-- <div class="relative">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-border/50"></div>
|
||||
</div>
|
||||
@@ -168,8 +168,8 @@ onMount(() => {
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<!-- Social Login -->
|
||||
<!-- <div class="grid grid-cols-2 gap-4">
|
||||
<!-- Social Login -->
|
||||
<!-- <div class="grid grid-cols-2 gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="border-primary/20 hover:bg-primary/10"
|
||||
@@ -207,16 +207,16 @@ onMount(() => {
|
||||
</Button>
|
||||
</div> -->
|
||||
|
||||
<!-- Sign Up Link -->
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{$_("auth.login.no_account")}
|
||||
<a href="/signup" class="text-primary hover:underline font-medium"
|
||||
>{$_("auth.login.sign_up_link")}</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<!-- Sign Up Link -->
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{$_("auth.login.no_account")}
|
||||
<a href="/signup" class="text-primary hover:underline font-medium"
|
||||
>{$_("auth.login.sign_up_link")}</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getArticles } from "$lib/services";
|
||||
export async function load({ fetch }) {
|
||||
return {
|
||||
articles: await getArticles(fetch),
|
||||
};
|
||||
return {
|
||||
articles: await getArticles(fetch),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,63 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card, CardContent } from "$lib/components/ui/card";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card, CardContent } from "$lib/components/ui/card";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "$lib/components/ui/select";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
||||
|
||||
import TimeAgo from "javascript-time-ago";
|
||||
import type { Article } from "$lib/types";
|
||||
import { getAssetUrl } from "$lib/directus";
|
||||
import { calcReadingTime } from "$lib/utils.js";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import TimeAgo from "javascript-time-ago";
|
||||
import type { Article } from "$lib/types";
|
||||
import { getAssetUrl } from "$lib/directus";
|
||||
import { calcReadingTime } from "$lib/utils.js";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
|
||||
let searchQuery = $state("");
|
||||
let categoryFilter = $state("all");
|
||||
let sortBy = $state("recent");
|
||||
let searchQuery = $state("");
|
||||
let categoryFilter = $state("all");
|
||||
let sortBy = $state("recent");
|
||||
|
||||
const timeAgo = new TimeAgo("en");
|
||||
const { data }: { data: { articles: Article[] } } = $props();
|
||||
const timeAgo = new TimeAgo("en");
|
||||
const { data }: { data: { articles: Article[] } } = $props();
|
||||
|
||||
const featuredArticle = data.articles.find((article) => article.featured);
|
||||
const featuredArticle = data.articles.find((article) => article.featured);
|
||||
|
||||
const filteredArticles = $derived(() => {
|
||||
return data.articles
|
||||
.filter((article) => {
|
||||
const matchesSearch =
|
||||
article.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
article.excerpt.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
article.author.first_name
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase());
|
||||
const matchesCategory =
|
||||
categoryFilter === "all" || article.category === categoryFilter;
|
||||
return matchesSearch && matchesCategory;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (sortBy === "recent")
|
||||
return (
|
||||
new Date(b.publish_date).getTime() -
|
||||
new Date(a.publish_date).getTime()
|
||||
);
|
||||
// if (sortBy === "popular")
|
||||
// return (
|
||||
// parseInt(b.views.replace(/[^\d]/g, "")) -
|
||||
// parseInt(a.views.replace(/[^\d]/g, ""))
|
||||
// );
|
||||
if (sortBy === "featured")
|
||||
return (b.featured ? 1 : 0) - (a.featured ? 1 : 0);
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
});
|
||||
const filteredArticles = $derived(() => {
|
||||
return data.articles
|
||||
.filter((article) => {
|
||||
const matchesSearch =
|
||||
article.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
article.excerpt.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
article.author.first_name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesCategory = categoryFilter === "all" || article.category === categoryFilter;
|
||||
return matchesSearch && matchesCategory;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (sortBy === "recent")
|
||||
return new Date(b.publish_date).getTime() - new Date(a.publish_date).getTime();
|
||||
// if (sortBy === "popular")
|
||||
// return (
|
||||
// parseInt(b.views.replace(/[^\d]/g, "")) -
|
||||
// parseInt(a.views.replace(/[^\d]/g, ""))
|
||||
// );
|
||||
if (sortBy === "featured") return (b.featured ? 1 : 0) - (a.featured ? 1 : 0);
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<Meta title={$_('magazine.title')} description={$_('magazine.description')} />
|
||||
<Meta title={$_("magazine.title")} description={$_("magazine.description")} />
|
||||
|
||||
<div
|
||||
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
||||
@@ -84,12 +72,12 @@ const filteredArticles = $derived(() => {
|
||||
<h1
|
||||
class="text-5xl md:text-7xl font-bold mb-8 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
|
||||
>
|
||||
{$_('magazine.title')}
|
||||
{$_("magazine.title")}
|
||||
</h1>
|
||||
<p
|
||||
class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto"
|
||||
>
|
||||
{$_('magazine.description')}
|
||||
{$_("magazine.description")}
|
||||
</p>
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-col md:flex-row gap-4 max-w-4xl mx-auto">
|
||||
@@ -99,7 +87,7 @@ const filteredArticles = $derived(() => {
|
||||
class="icon-[ri--search-line] absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
|
||||
></span>
|
||||
<Input
|
||||
placeholder={$_('magazine.search_placeholder')}
|
||||
placeholder={$_("magazine.search_placeholder")}
|
||||
bind:value={searchQuery}
|
||||
class="pl-10 bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
@@ -111,42 +99,28 @@ const filteredArticles = $derived(() => {
|
||||
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
||||
>
|
||||
<span class="icon-[ri--filter-line] w-4 h-4 mr-2"></span>
|
||||
{categoryFilter === 'all'
|
||||
? $_('magazine.categories.all')
|
||||
: categoryFilter === 'photography'
|
||||
? $_('magazine.categories.photography')
|
||||
: categoryFilter === 'production'
|
||||
? $_('magazine.categories.production')
|
||||
: categoryFilter === 'interview'
|
||||
? $_('magazine.categories.interview')
|
||||
: categoryFilter === 'psychology'
|
||||
? $_('magazine.categories.psychology')
|
||||
: categoryFilter === 'trends'
|
||||
? $_('magazine.categories.trends')
|
||||
: $_('magazine.categories.spotlight')}
|
||||
{categoryFilter === "all"
|
||||
? $_("magazine.categories.all")
|
||||
: categoryFilter === "photography"
|
||||
? $_("magazine.categories.photography")
|
||||
: categoryFilter === "production"
|
||||
? $_("magazine.categories.production")
|
||||
: categoryFilter === "interview"
|
||||
? $_("magazine.categories.interview")
|
||||
: categoryFilter === "psychology"
|
||||
? $_("magazine.categories.psychology")
|
||||
: categoryFilter === "trends"
|
||||
? $_("magazine.categories.trends")
|
||||
: $_("magazine.categories.spotlight")}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all"
|
||||
>{$_('magazine.categories.all')}</SelectItem
|
||||
>
|
||||
<SelectItem value="photography"
|
||||
>{$_('magazine.categories.photography')}</SelectItem
|
||||
>
|
||||
<SelectItem value="production"
|
||||
>{$_('magazine.categories.production')}</SelectItem
|
||||
>
|
||||
<SelectItem value="interview"
|
||||
>{$_('magazine.categories.interview')}</SelectItem
|
||||
>
|
||||
<SelectItem value="psychology"
|
||||
>{$_('magazine.categories.psychology')}</SelectItem
|
||||
>
|
||||
<SelectItem value="trends"
|
||||
>{$_('magazine.categories.trends')}</SelectItem
|
||||
>
|
||||
<SelectItem value="spotlight"
|
||||
>{$_('magazine.categories.spotlight')}</SelectItem
|
||||
>
|
||||
<SelectItem value="all">{$_("magazine.categories.all")}</SelectItem>
|
||||
<SelectItem value="photography">{$_("magazine.categories.photography")}</SelectItem>
|
||||
<SelectItem value="production">{$_("magazine.categories.production")}</SelectItem>
|
||||
<SelectItem value="interview">{$_("magazine.categories.interview")}</SelectItem>
|
||||
<SelectItem value="psychology">{$_("magazine.categories.psychology")}</SelectItem>
|
||||
<SelectItem value="trends">{$_("magazine.categories.trends")}</SelectItem>
|
||||
<SelectItem value="spotlight">{$_("magazine.categories.spotlight")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -155,25 +129,21 @@ const filteredArticles = $derived(() => {
|
||||
<SelectTrigger
|
||||
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
||||
>
|
||||
{sortBy === 'recent'
|
||||
? $_('magazine.sort.recent')
|
||||
: sortBy === 'popular'
|
||||
? $_('magazine.sort.popular')
|
||||
: sortBy === 'featured'
|
||||
? $_('magazine.sort.featured')
|
||||
: $_('magazine.sort.name')}
|
||||
{sortBy === "recent"
|
||||
? $_("magazine.sort.recent")
|
||||
: sortBy === "popular"
|
||||
? $_("magazine.sort.popular")
|
||||
: sortBy === "featured"
|
||||
? $_("magazine.sort.featured")
|
||||
: $_("magazine.sort.name")}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="recent"
|
||||
>{$_('magazine.sort.recent')}</SelectItem
|
||||
>
|
||||
<SelectItem value="recent">{$_("magazine.sort.recent")}</SelectItem>
|
||||
<!-- <SelectItem value="popular"
|
||||
>{$_("magazine.sort.popular")}</SelectItem
|
||||
> -->
|
||||
<SelectItem value="featured"
|
||||
>{$_('magazine.sort.featured')}</SelectItem
|
||||
>
|
||||
<SelectItem value="name">{$_('magazine.sort.name')}</SelectItem>
|
||||
<SelectItem value="featured">{$_("magazine.sort.featured")}</SelectItem>
|
||||
<SelectItem value="name">{$_("magazine.sort.name")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -183,21 +153,21 @@ const filteredArticles = $derived(() => {
|
||||
|
||||
<div class="container mx-auto px-4 py-12">
|
||||
<!-- Featured Article -->
|
||||
{#if featuredArticle && categoryFilter === 'all' && !searchQuery}
|
||||
{#if featuredArticle && categoryFilter === "all" && !searchQuery}
|
||||
<Card
|
||||
class="py-0 mb-12 overflow-hidden bg-gradient-to-br from-card/90 via-card/95 to-card/85 backdrop-blur-xl shadow-2xl shadow-primary/20"
|
||||
>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2">
|
||||
<div class="relative">
|
||||
<img
|
||||
src={getAssetUrl(featuredArticle.image, 'medium')}
|
||||
src={getAssetUrl(featuredArticle.image, "medium")}
|
||||
alt={featuredArticle.title}
|
||||
class="w-full h-96 object-cover"
|
||||
/>
|
||||
<div
|
||||
class="absolute top-4 left-4 bg-gradient-to-r from-primary to-accent text-white px-3 py-1 rounded-full text-sm font-medium"
|
||||
>
|
||||
{$_('magazine.featured')}
|
||||
{$_("magazine.featured")}
|
||||
</div>
|
||||
</div>
|
||||
<CardContent class="p-8 flex flex-col justify-center">
|
||||
@@ -208,13 +178,9 @@ const filteredArticles = $derived(() => {
|
||||
{featuredArticle.category}
|
||||
</span>
|
||||
</div>
|
||||
<h2
|
||||
class="text-2xl md:text-3xl font-bold mb-4 hover:text-primary transition-colors"
|
||||
>
|
||||
<h2 class="text-2xl md:text-3xl font-bold mb-4 hover:text-primary transition-colors">
|
||||
<button class="text-left">
|
||||
<a href="/article/{featuredArticle.slug}"
|
||||
>{featuredArticle.title}</a
|
||||
>
|
||||
<a href="/article/{featuredArticle.slug}">{featuredArticle.title}</a>
|
||||
</button>
|
||||
</h2>
|
||||
<p class="text-muted-foreground mb-6 text-lg leading-relaxed">
|
||||
@@ -223,26 +189,20 @@ const filteredArticles = $derived(() => {
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<img
|
||||
src={getAssetUrl(featuredArticle.author.avatar, 'mini')}
|
||||
src={getAssetUrl(featuredArticle.author.avatar, "mini")}
|
||||
alt={featuredArticle.author.first_name}
|
||||
class="w-10 h-10 rounded-full object-cover"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-medium">{featuredArticle.author.first_name}</p>
|
||||
<div
|
||||
class="flex items-center gap-3 text-sm text-muted-foreground"
|
||||
>
|
||||
<span
|
||||
>{timeAgo.format(
|
||||
new Date(featuredArticle.publish_date)
|
||||
)}</span
|
||||
>
|
||||
<div class="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span>{timeAgo.format(new Date(featuredArticle.publish_date))}</span>
|
||||
<span>•</span>
|
||||
<span
|
||||
>{$_('magazine.read_time', {
|
||||
>{$_("magazine.read_time", {
|
||||
values: {
|
||||
time: calcReadingTime(featuredArticle.content)
|
||||
}
|
||||
time: calcReadingTime(featuredArticle.content),
|
||||
},
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
@@ -250,8 +210,7 @@ const filteredArticles = $derived(() => {
|
||||
</div>
|
||||
<Button
|
||||
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
href="/magazine/{featuredArticle.slug}"
|
||||
>{$_('magazine.read_article')}</Button
|
||||
href="/magazine/{featuredArticle.slug}">{$_("magazine.read_article")}</Button
|
||||
>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -267,7 +226,7 @@ const filteredArticles = $derived(() => {
|
||||
>
|
||||
<div class="relative">
|
||||
<img
|
||||
src={getAssetUrl(article.image, 'preview')}
|
||||
src={getAssetUrl(article.image, "preview")}
|
||||
alt={article.title}
|
||||
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
@@ -287,7 +246,7 @@ const filteredArticles = $derived(() => {
|
||||
<div
|
||||
class="absolute top-3 right-3 bg-gradient-to-r from-primary to-accent text-white text-xs px-2 py-1 rounded-full"
|
||||
>
|
||||
{$_('magazine.featured')}
|
||||
{$_("magazine.featured")}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -307,9 +266,7 @@ const filteredArticles = $derived(() => {
|
||||
>
|
||||
<a href="/magazine/{article.slug}">{article.title}</a>
|
||||
</h3>
|
||||
<p
|
||||
class="text-muted-foreground text-sm line-clamp-3 leading-relaxed"
|
||||
>
|
||||
<p class="text-muted-foreground text-sm line-clamp-3 leading-relaxed">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
@@ -330,23 +287,21 @@ const filteredArticles = $derived(() => {
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
src={getAssetUrl(article.author.avatar, 'mini')}
|
||||
src={getAssetUrl(article.author.avatar, "mini")}
|
||||
alt={article.author.first_name}
|
||||
class="w-8 h-8 rounded-full object-cover"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm font-medium">{article.author.first_name}</p>
|
||||
<div
|
||||
class="flex items-center gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span class="icon-[ri--calendar-line] w-4 h-4"></span>
|
||||
{timeAgo.format(new Date(article.publish_date))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{$_('magazine.read_time', {
|
||||
values: { time: calcReadingTime(article.content) }
|
||||
{$_("magazine.read_time", {
|
||||
values: { time: calcReadingTime(article.content) },
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -356,8 +311,7 @@ const filteredArticles = $derived(() => {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full mt-4 border-primary/20 hover:bg-primary/10"
|
||||
href="/magazine/{article.slug}"
|
||||
>{$_('magazine.read_article')}</Button
|
||||
href="/magazine/{article.slug}">{$_("magazine.read_article")}</Button
|
||||
>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -367,17 +321,17 @@ const filteredArticles = $derived(() => {
|
||||
{#if filteredArticles().length === 0}
|
||||
<div class="text-center py-12">
|
||||
<p class="text-muted-foreground text-lg mb-4">
|
||||
{$_('magazine.no_results')}
|
||||
{$_("magazine.no_results")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
searchQuery = '';
|
||||
categoryFilter = 'all';
|
||||
searchQuery = "";
|
||||
categoryFilter = "all";
|
||||
}}
|
||||
class="border-primary/20 hover:bg-primary/10"
|
||||
>
|
||||
{$_('magazine.clear_filters')}
|
||||
{$_("magazine.clear_filters")}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { getArticleBySlug } from "$lib/services.js";
|
||||
export async function load({ fetch, params, locals }) {
|
||||
try {
|
||||
return {
|
||||
article: await getArticleBySlug(params.slug, fetch),
|
||||
authStatus: locals.authStatus,
|
||||
};
|
||||
} catch {
|
||||
error(404, "Article not found");
|
||||
}
|
||||
try {
|
||||
return {
|
||||
article: await getArticleBySlug(params.slug, fetch),
|
||||
authStatus: locals.authStatus,
|
||||
};
|
||||
} catch {
|
||||
error(404, "Article not found");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,86 +1,84 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import { page } from "$app/state";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card, CardContent } from "$lib/components/ui/card";
|
||||
import { calcReadingTime } from "$lib/utils";
|
||||
import TimeAgo from "javascript-time-ago";
|
||||
import { getAssetUrl } from "$lib/directus";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||
import SharingPopupButton from "$lib/components/sharing-popup/sharing-popup-button.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { page } from "$app/state";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card, CardContent } from "$lib/components/ui/card";
|
||||
import { calcReadingTime } from "$lib/utils";
|
||||
import TimeAgo from "javascript-time-ago";
|
||||
import { getAssetUrl } from "$lib/directus";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||
import SharingPopupButton from "$lib/components/sharing-popup/sharing-popup-button.svelte";
|
||||
|
||||
const { data } = $props();
|
||||
const { data } = $props();
|
||||
|
||||
const timeAgo = new TimeAgo("en");
|
||||
const timeAgo = new TimeAgo("en");
|
||||
</script>
|
||||
|
||||
<Meta
|
||||
title={data.article.title}
|
||||
description={data.article.excerpt}
|
||||
image={getAssetUrl(data.article.image, "medium")!}
|
||||
title={data.article.title}
|
||||
description={data.article.excerpt}
|
||||
image={getAssetUrl(data.article.image, "medium")!}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
||||
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
||||
>
|
||||
<PeonyBackground />
|
||||
<PeonyBackground />
|
||||
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<!-- Main Article -->
|
||||
<article class="lg:col-span-2">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<Button variant="ghost" href="/magazine" class="mb-6 hover:bg-primary/10"
|
||||
><span class="icon-[ri--arrow-left-long-line] w-4 h-4 mr-1"></span>{$_(
|
||||
"magazine.back",
|
||||
)}</Button
|
||||
>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<!-- Main Article -->
|
||||
<article class="lg:col-span-2">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<Button variant="ghost" href="/magazine" class="mb-6 hover:bg-primary/10"
|
||||
><span class="icon-[ri--arrow-left-long-line] w-4 h-4 mr-1"></span>{$_(
|
||||
"magazine.back",
|
||||
)}</Button
|
||||
>
|
||||
|
||||
<!-- Category Badge -->
|
||||
<div class="mb-4">
|
||||
<span
|
||||
class="bg-primary/10 text-primary px-3 py-1 rounded-full text-sm font-medium capitalize"
|
||||
>
|
||||
{data.article.category}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Category Badge -->
|
||||
<div class="mb-4">
|
||||
<span
|
||||
class="bg-primary/10 text-primary px-3 py-1 rounded-full text-sm font-medium capitalize"
|
||||
>
|
||||
{data.article.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h1 class="text-3xl md:text-4xl lg:text-5xl font-bold mb-4 leading-tight">
|
||||
{data.article.title}
|
||||
</h1>
|
||||
<!-- Title -->
|
||||
<h1 class="text-3xl md:text-4xl lg:text-5xl font-bold mb-4 leading-tight">
|
||||
{data.article.title}
|
||||
</h1>
|
||||
|
||||
<!-- Subtitle -->
|
||||
<p class="text-xl text-muted-foreground mb-6 leading-relaxed">
|
||||
{data.article.excerpt}
|
||||
</p>
|
||||
<!-- Subtitle -->
|
||||
<p class="text-xl text-muted-foreground mb-6 leading-relaxed">
|
||||
{data.article.excerpt}
|
||||
</p>
|
||||
|
||||
<!-- Meta Information -->
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-6 text-sm text-muted-foreground mb-6"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="icon-[ri--calendar-line] w-4 h-4"></span>
|
||||
{timeAgo.format(new Date(data.article.publish_date))}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="icon-[ri--timer-2-line] w-4 h-4"></span>
|
||||
{$_("magazine.read_time", {
|
||||
values: {
|
||||
time: calcReadingTime(data.article.content),
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
<!-- <div class="flex items-center gap-2">
|
||||
<!-- Meta Information -->
|
||||
<div class="flex flex-wrap items-center gap-6 text-sm text-muted-foreground mb-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="icon-[ri--calendar-line] w-4 h-4"></span>
|
||||
{timeAgo.format(new Date(data.article.publish_date))}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="icon-[ri--timer-2-line] w-4 h-4"></span>
|
||||
{$_("magazine.read_time", {
|
||||
values: {
|
||||
time: calcReadingTime(data.article.content),
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
<!-- <div class="flex items-center gap-2">
|
||||
<UserIcon class="w-4 h-4" />
|
||||
{data.article.views} views
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<!-- <div class="flex flex-wrap gap-3 mb-8">
|
||||
<!-- Action Buttons -->
|
||||
<!-- <div class="flex flex-wrap gap-3 mb-8">
|
||||
<Button
|
||||
variant={isLiked ? "default" : "outline"}
|
||||
size="sm"
|
||||
@@ -92,91 +90,95 @@ const timeAgo = new TimeAgo("en");
|
||||
<HeartIcon class="w-4 h-4 {isLiked ? 'fill-current' : ''}" />
|
||||
{data.article.likes}
|
||||
</Button> -->
|
||||
<SharingPopupButton content={{
|
||||
title: data.article.title,
|
||||
description: data.article.excerpt,
|
||||
url: page.url.href,
|
||||
type: "article" as const,
|
||||
}} />
|
||||
|
||||
</div>
|
||||
<SharingPopupButton
|
||||
content={{
|
||||
title: data.article.title,
|
||||
description: data.article.excerpt,
|
||||
url: page.url.href,
|
||||
type: "article" as const,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Featured Image -->
|
||||
<div class="mb-8">
|
||||
<img
|
||||
src={getAssetUrl(data.article.image, "medium")}
|
||||
alt={data.article.title}
|
||||
class="w-full h-64 md:h-96 object-cover rounded-2xl shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
<!-- Featured Image -->
|
||||
<div class="mb-8">
|
||||
<img
|
||||
src={getAssetUrl(data.article.image, "medium")}
|
||||
alt={data.article.title}
|
||||
class="w-full h-64 md:h-96 object-cover rounded-2xl shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Article Content -->
|
||||
<Card class="p-0 mb-8 bg-card/50">
|
||||
<CardContent class="p-8">
|
||||
<div
|
||||
class="prose prose-lg max-w-none prose-headings:text-foreground prose-p:text-muted-foreground prose-strong:text-foreground prose-ul:text-muted-foreground"
|
||||
>
|
||||
{@html data.article.content}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<!-- Article Content -->
|
||||
<Card class="p-0 mb-8 bg-card/50">
|
||||
<CardContent class="p-8">
|
||||
<div
|
||||
class="prose prose-lg max-w-none prose-headings:text-foreground prose-p:text-muted-foreground prose-strong:text-foreground prose-ul:text-muted-foreground"
|
||||
>
|
||||
{@html data.article.content}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="icon-[ri--price-tag-3-line] w-5 h-5 text-primary"></span>
|
||||
<span class="font-semibold">Tags</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each data.article.tags as tag (tag)}
|
||||
<a class="bg-primary/10 text-primary px-3 py-1 rounded-full text-sm" href="/tags/{tag}">
|
||||
#{tag}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tags -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="icon-[ri--price-tag-3-line] w-5 h-5 text-primary"></span>
|
||||
<span class="font-semibold">Tags</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each data.article.tags as tag (tag)}
|
||||
<a
|
||||
class="bg-primary/10 text-primary px-3 py-1 rounded-full text-sm"
|
||||
href="/tags/{tag}"
|
||||
>
|
||||
#{tag}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Author Bio -->
|
||||
<Card class="p-0 bg-gradient-to-r from-card/50 to-card">
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<img
|
||||
src={getAssetUrl(data.article.author.avatar, "mini")}
|
||||
alt={data.article.author.first_name}
|
||||
class="w-16 h-16 rounded-full object-cover ring-2 ring-primary/20"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold text-lg mb-2">
|
||||
About {data.article.author.first_name}
|
||||
</h3>
|
||||
{#if data.article.author.description}
|
||||
<p class="text-muted-foreground mb-4">
|
||||
{data.article.author.description}
|
||||
</p>
|
||||
{/if}
|
||||
{#if data.article.author.website}
|
||||
<div class="flex gap-4 text-sm">
|
||||
<a
|
||||
href={"https://" + data.article.author.website}
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
{data.article.author.website}
|
||||
</a>
|
||||
<!-- <a href="https://{data.article.author.social.website}" class="text-primary hover:underline">
|
||||
<!-- Author Bio -->
|
||||
<Card class="p-0 bg-gradient-to-r from-card/50 to-card">
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<img
|
||||
src={getAssetUrl(data.article.author.avatar, "mini")}
|
||||
alt={data.article.author.first_name}
|
||||
class="w-16 h-16 rounded-full object-cover ring-2 ring-primary/20"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold text-lg mb-2">
|
||||
About {data.article.author.first_name}
|
||||
</h3>
|
||||
{#if data.article.author.description}
|
||||
<p class="text-muted-foreground mb-4">
|
||||
{data.article.author.description}
|
||||
</p>
|
||||
{/if}
|
||||
{#if data.article.author.website}
|
||||
<div class="flex gap-4 text-sm">
|
||||
<a
|
||||
href={"https://" + data.article.author.website}
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
{data.article.author.website}
|
||||
</a>
|
||||
<!-- <a href="https://{data.article.author.social.website}" class="text-primary hover:underline">
|
||||
{data.article.author.social.website}
|
||||
</a> -->
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</article>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</article>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="space-y-6">
|
||||
<!-- Related Articles -->
|
||||
<!--
|
||||
<!-- Sidebar -->
|
||||
<aside class="space-y-6">
|
||||
<!-- Related Articles -->
|
||||
<!--
|
||||
<Card class="bg-card/50">
|
||||
<CardContent class="p-6">
|
||||
<h3 class="font-semibold mb-4 flex items-center gap-2">
|
||||
@@ -213,16 +215,16 @@ const timeAgo = new TimeAgo("en");
|
||||
</Card>
|
||||
-->
|
||||
|
||||
<!-- Back to Magazine -->
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-full border-primary/20 hover:bg-primary/10"
|
||||
href="/magazine"
|
||||
><span class="icon-[ri--arrow-left-long-line] w-4 h-4 mr-1"></span>{$_(
|
||||
"magazine.back",
|
||||
)}</Button
|
||||
>
|
||||
</aside>
|
||||
</div>
|
||||
<!-- Back to Magazine -->
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-full border-primary/20 hover:bg-primary/10"
|
||||
href="/magazine"
|
||||
><span class="icon-[ri--arrow-left-long-line] w-4 h-4 mr-1"></span>{$_(
|
||||
"magazine.back",
|
||||
)}</Button
|
||||
>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,23 +3,23 @@ import { getAnalytics, getFolders, getRecordings } from "$lib/services";
|
||||
import { isModel } from "$lib/directus";
|
||||
|
||||
export async function load({ locals, fetch }) {
|
||||
// Redirect to login if not authenticated
|
||||
if (!locals.authStatus.authenticated) {
|
||||
throw redirect(302, "/login");
|
||||
}
|
||||
// Redirect to login if not authenticated
|
||||
if (!locals.authStatus.authenticated) {
|
||||
throw redirect(302, "/login");
|
||||
}
|
||||
|
||||
const recordings = await getRecordings(fetch).catch(() => []);
|
||||
const recordings = await getRecordings(fetch).catch(() => []);
|
||||
|
||||
const analytics = isModel(locals.authStatus.user)
|
||||
? await getAnalytics(fetch).catch(() => null)
|
||||
: null;
|
||||
const analytics = isModel(locals.authStatus.user)
|
||||
? await getAnalytics(fetch).catch(() => null)
|
||||
: null;
|
||||
|
||||
const folders = await getFolders(fetch).catch(() => []);
|
||||
const folders = await getFolders(fetch).catch(() => []);
|
||||
|
||||
return {
|
||||
authStatus: locals.authStatus,
|
||||
folders,
|
||||
recordings,
|
||||
analytics,
|
||||
};
|
||||
return {
|
||||
authStatus: locals.authStatus,
|
||||
folders,
|
||||
recordings,
|
||||
analytics,
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { getModels } from "$lib/services";
|
||||
export async function load({ fetch }) {
|
||||
return {
|
||||
models: await getModels(fetch),
|
||||
};
|
||||
return {
|
||||
models: await getModels(fetch),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,162 +1,148 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card, CardContent } from "$lib/components/ui/card";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "$lib/components/ui/select";
|
||||
import { getAssetUrl } from "$lib/directus";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card, CardContent } from "$lib/components/ui/card";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
||||
import { getAssetUrl } from "$lib/directus";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
|
||||
let searchQuery = $state("");
|
||||
let sortBy = $state("popular");
|
||||
let categoryFilter = $state("all");
|
||||
let searchQuery = $state("");
|
||||
let sortBy = $state("popular");
|
||||
let categoryFilter = $state("all");
|
||||
|
||||
const { data } = $props();
|
||||
const { data } = $props();
|
||||
|
||||
const filteredModels = $derived(() => {
|
||||
return data.models
|
||||
.filter((model) => {
|
||||
const matchesSearch =
|
||||
searchQuery === "" ||
|
||||
model.artist_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
model.tags.some((tag) =>
|
||||
tag.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
const matchesCategory =
|
||||
categoryFilter === "all" || model.category === categoryFilter;
|
||||
return matchesSearch && matchesCategory;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// if (sortBy === "popular") {
|
||||
// const aNum = parseInt(a.subscribers.replace(/[^\d]/g, ""));
|
||||
// const bNum = parseInt(b.subscribers.replace(/[^\d]/g, ""));
|
||||
// return bNum - aNum;
|
||||
// }
|
||||
// if (sortBy === "rating") return b.rating - a.rating;
|
||||
// if (sortBy === "videos") return b.videos - a.videos;
|
||||
return a.artist_name.localeCompare(b.artist_name);
|
||||
});
|
||||
});
|
||||
const filteredModels = $derived(() => {
|
||||
return data.models
|
||||
.filter((model) => {
|
||||
const matchesSearch =
|
||||
searchQuery === "" ||
|
||||
model.artist_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
model.tags.some((tag) => tag.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
const matchesCategory = categoryFilter === "all" || model.category === categoryFilter;
|
||||
return matchesSearch && matchesCategory;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// if (sortBy === "popular") {
|
||||
// const aNum = parseInt(a.subscribers.replace(/[^\d]/g, ""));
|
||||
// const bNum = parseInt(b.subscribers.replace(/[^\d]/g, ""));
|
||||
// return bNum - aNum;
|
||||
// }
|
||||
// if (sortBy === "rating") return b.rating - a.rating;
|
||||
// if (sortBy === "videos") return b.videos - a.videos;
|
||||
return a.artist_name.localeCompare(b.artist_name);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<Meta title={$_("models.title")} description={$_("models.description")} />
|
||||
|
||||
<div
|
||||
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
||||
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
||||
>
|
||||
<!-- Global Plasma Background -->
|
||||
<div class="absolute inset-0 pointer-events-none">
|
||||
<div
|
||||
class="absolute top-40 left-1/4 w-80 h-80 bg-gradient-to-r from-primary/16 via-accent/20 to-primary/12 rounded-full blur-3xl animate-blob-slow"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-40 right-1/4 w-96 h-96 bg-gradient-to-r from-accent/16 via-primary/20 to-accent/12 rounded-full blur-3xl animate-blob-slow animation-delay-5000"
|
||||
></div>
|
||||
<div
|
||||
class="absolute top-1/3 right-1/3 w-64 h-64 bg-gradient-to-r from-primary/14 via-accent/18 to-primary/10 rounded-full blur-2xl animate-blob-reverse animation-delay-2500"
|
||||
></div>
|
||||
</div>
|
||||
<!-- Global Plasma Background -->
|
||||
<div class="absolute inset-0 pointer-events-none">
|
||||
<div
|
||||
class="absolute top-40 left-1/4 w-80 h-80 bg-gradient-to-r from-primary/16 via-accent/20 to-primary/12 rounded-full blur-3xl animate-blob-slow"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-40 right-1/4 w-96 h-96 bg-gradient-to-r from-accent/16 via-primary/20 to-accent/12 rounded-full blur-3xl animate-blob-slow animation-delay-5000"
|
||||
></div>
|
||||
<div
|
||||
class="absolute top-1/3 right-1/3 w-64 h-64 bg-gradient-to-r from-primary/14 via-accent/18 to-primary/10 rounded-full blur-2xl animate-blob-reverse animation-delay-2500"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<section class="relative py-20 overflow-hidden">
|
||||
<div class="relative container mx-auto px-4 text-center">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<h1
|
||||
class="text-5xl md:text-7xl font-bold mb-8 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
|
||||
>
|
||||
{$_("models.title")}
|
||||
</h1>
|
||||
<p
|
||||
class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto"
|
||||
>
|
||||
{$_("models.description")}
|
||||
</p>
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-col md:flex-row gap-4 max-w-4xl mx-auto">
|
||||
<!-- Search -->
|
||||
<div class="relative flex-1">
|
||||
<span
|
||||
class="icon-[ri--search-line] absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
|
||||
></span>
|
||||
<Input
|
||||
placeholder={$_("models.search_placeholder")}
|
||||
bind:value={searchQuery}
|
||||
class="pl-10 bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<section class="relative py-20 overflow-hidden">
|
||||
<div class="relative container mx-auto px-4 text-center">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<h1
|
||||
class="text-5xl md:text-7xl font-bold mb-8 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
|
||||
>
|
||||
{$_("models.title")}
|
||||
</h1>
|
||||
<p
|
||||
class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto"
|
||||
>
|
||||
{$_("models.description")}
|
||||
</p>
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-col md:flex-row gap-4 max-w-4xl mx-auto">
|
||||
<!-- Search -->
|
||||
<div class="relative flex-1">
|
||||
<span
|
||||
class="icon-[ri--search-line] absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
|
||||
></span>
|
||||
<Input
|
||||
placeholder={$_("models.search_placeholder")}
|
||||
bind:value={searchQuery}
|
||||
class="pl-10 bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Category Filter -->
|
||||
<Select type="single" bind:value={categoryFilter}>
|
||||
<SelectTrigger
|
||||
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
||||
>
|
||||
<span class="icon-[ri--filter-line] w-4 h-4 mr-2"></span>
|
||||
{categoryFilter === "all"
|
||||
? $_("models.categories.all")
|
||||
: categoryFilter === "romantic"
|
||||
? $_("models.categories.romantic")
|
||||
: categoryFilter === "artistic"
|
||||
? $_("models.categories.artistic")
|
||||
: $_("models.categories.intimate")}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{$_("models.categories.all")}</SelectItem>
|
||||
<SelectItem value="romantic"
|
||||
>{$_("models.categories.romantic")}</SelectItem
|
||||
>
|
||||
<SelectItem value="artistic"
|
||||
>{$_("models.categories.artistic")}</SelectItem
|
||||
>
|
||||
<SelectItem value="intimate"
|
||||
>{$_("models.categories.intimate")}</SelectItem
|
||||
>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<!-- Category Filter -->
|
||||
<Select type="single" bind:value={categoryFilter}>
|
||||
<SelectTrigger
|
||||
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
||||
>
|
||||
<span class="icon-[ri--filter-line] w-4 h-4 mr-2"></span>
|
||||
{categoryFilter === "all"
|
||||
? $_("models.categories.all")
|
||||
: categoryFilter === "romantic"
|
||||
? $_("models.categories.romantic")
|
||||
: categoryFilter === "artistic"
|
||||
? $_("models.categories.artistic")
|
||||
: $_("models.categories.intimate")}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{$_("models.categories.all")}</SelectItem>
|
||||
<SelectItem value="romantic">{$_("models.categories.romantic")}</SelectItem>
|
||||
<SelectItem value="artistic">{$_("models.categories.artistic")}</SelectItem>
|
||||
<SelectItem value="intimate">{$_("models.categories.intimate")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- Sort -->
|
||||
<Select type="single" bind:value={sortBy}>
|
||||
<SelectTrigger
|
||||
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
||||
>
|
||||
{sortBy === "popular"
|
||||
? $_("models.sort.popular")
|
||||
: sortBy === "rating"
|
||||
? $_("models.sort.rating")
|
||||
: sortBy === "videos"
|
||||
? $_("models.sort.videos")
|
||||
: $_("models.sort.name")}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="popular">{$_("models.sort.popular")}</SelectItem>
|
||||
<SelectItem value="rating">{$_("models.sort.rating")}</SelectItem>
|
||||
<SelectItem value="videos">{$_("models.sort.videos")}</SelectItem>
|
||||
<SelectItem value="name">{$_("models.sort.name")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sort -->
|
||||
<Select type="single" bind:value={sortBy}>
|
||||
<SelectTrigger
|
||||
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
||||
>
|
||||
{sortBy === "popular"
|
||||
? $_("models.sort.popular")
|
||||
: sortBy === "rating"
|
||||
? $_("models.sort.rating")
|
||||
: sortBy === "videos"
|
||||
? $_("models.sort.videos")
|
||||
: $_("models.sort.name")}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="popular">{$_("models.sort.popular")}</SelectItem>
|
||||
<SelectItem value="rating">{$_("models.sort.rating")}</SelectItem>
|
||||
<SelectItem value="videos">{$_("models.sort.videos")}</SelectItem>
|
||||
<SelectItem value="name">{$_("models.sort.name")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Models Grid -->
|
||||
<div class="container mx-auto px-4 py-12">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{#each filteredModels() as model (model.slug)}
|
||||
<Card
|
||||
class="py-0 group hover:shadow-2xl hover:shadow-primary/25 transition-all duration-500 hover:-translate-y-3 bg-gradient-to-br from-card/90 via-card/95 to-card/85 backdrop-blur-xl shadow-lg shadow-primary/10 overflow-hidden"
|
||||
>
|
||||
<div class="relative">
|
||||
<img
|
||||
src={getAssetUrl(model.avatar, "preview")}
|
||||
alt={model.artist_name}
|
||||
class="w-full aspect-square object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Models Grid -->
|
||||
<div class="container mx-auto px-4 py-12">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{#each filteredModels() as model (model.slug)}
|
||||
<Card
|
||||
class="py-0 group hover:shadow-2xl hover:shadow-primary/25 transition-all duration-500 hover:-translate-y-3 bg-gradient-to-br from-card/90 via-card/95 to-card/85 backdrop-blur-xl shadow-lg shadow-primary/10 overflow-hidden"
|
||||
>
|
||||
<div class="relative">
|
||||
<img
|
||||
src={getAssetUrl(model.avatar, "preview")}
|
||||
alt={model.artist_name}
|
||||
class="w-full aspect-square object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
|
||||
<!-- Online Status -->
|
||||
<!-- {#if model.isOnline}
|
||||
<!-- Online Status -->
|
||||
<!-- {#if model.isOnline}
|
||||
<div
|
||||
class="absolute top-3 left-3 bg-green-500 text-white text-xs px-2 py-1 rounded-full flex items-center gap-1"
|
||||
>
|
||||
@@ -165,8 +151,8 @@ const filteredModels = $derived(() => {
|
||||
</div>
|
||||
{/if} -->
|
||||
|
||||
<!-- Heart Button -->
|
||||
<!-- <button
|
||||
<!-- Heart Button -->
|
||||
<!-- <button
|
||||
class="absolute top-3 right-3 w-10 h-10 bg-black/50 hover:bg-primary/80 rounded-full flex items-center justify-center transition-colors group/heart"
|
||||
>
|
||||
<HeartIcon
|
||||
@@ -174,30 +160,25 @@ const filteredModels = $derived(() => {
|
||||
/>
|
||||
</button> -->
|
||||
|
||||
<!-- Play Overlay -->
|
||||
<a
|
||||
href="/models/{model.slug}"
|
||||
aria-label={model.artist_name}
|
||||
class="absolute inset-0 group-hover:scale-105 transition bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 bg-primary/90 rounded-full flex items-center justify-center"
|
||||
>
|
||||
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white ml-1"
|
||||
></span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<!-- Play Overlay -->
|
||||
<a
|
||||
href="/models/{model.slug}"
|
||||
aria-label={model.artist_name}
|
||||
class="absolute inset-0 group-hover:scale-105 transition bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 flex items-center justify-center"
|
||||
>
|
||||
<div class="w-16 h-16 bg-primary/90 rounded-full flex items-center justify-center">
|
||||
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white ml-1"></span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3
|
||||
class="font-semibold text-lg mb-1 group-hover:text-primary transition-colors"
|
||||
>
|
||||
{model.artist_name}
|
||||
</h3>
|
||||
<!-- <div
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 class="font-semibold text-lg mb-1 group-hover:text-primary transition-colors">
|
||||
{model.artist_name}
|
||||
</h3>
|
||||
<!-- <div
|
||||
class="flex items-center gap-4 text-sm text-muted-foreground"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
@@ -206,62 +187,60 @@ const filteredModels = $derived(() => {
|
||||
</div>
|
||||
<div>{model.subscribers} followers</div>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
{#each model.tags as tag (tag)}
|
||||
<a
|
||||
class="text-xs bg-primary/10 text-primary px-2 py-1 rounded-full"
|
||||
href="/tags/{tag}"
|
||||
>
|
||||
{tag}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- Tags -->
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
{#each model.tags as tag (tag)}
|
||||
<a
|
||||
class="text-xs bg-primary/10 text-primary px-2 py-1 rounded-full"
|
||||
href="/tags/{tag}"
|
||||
>
|
||||
{tag}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div
|
||||
class="flex items-center justify-between text-sm text-muted-foreground mb-4"
|
||||
>
|
||||
<!-- <span>{model.videos} videos</span> -->
|
||||
<span class="capitalize">{model.category}</span>
|
||||
</div>
|
||||
<!-- Stats -->
|
||||
<div class="flex items-center justify-between text-sm text-muted-foreground mb-4">
|
||||
<!-- <span>{model.videos} videos</span> -->
|
||||
<span class="capitalize">{model.category}</span>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="flex-1 border-primary/20 hover:bg-primary/10"
|
||||
href="/models/{model.slug}">{$_("models.view_profile")}</Button
|
||||
>
|
||||
<!-- <Button
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="flex-1 border-primary/20 hover:bg-primary/10"
|
||||
href="/models/{model.slug}">{$_("models.view_profile")}</Button
|
||||
>
|
||||
<!-- <Button
|
||||
size="sm"
|
||||
class="flex-1 bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
>{$_("models.follow")}</Button
|
||||
> -->
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if filteredModels().length === 0}
|
||||
<div class="text-center py-12">
|
||||
<p class="text-muted-foreground text-lg">{$_("models.no_results")}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
searchQuery = "";
|
||||
categoryFilter = "all";
|
||||
}}
|
||||
class="mt-4"
|
||||
>
|
||||
{$_("models.clear_filters")}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if filteredModels().length === 0}
|
||||
<div class="text-center py-12">
|
||||
<p class="text-muted-foreground text-lg">{$_("models.no_results")}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
searchQuery = "";
|
||||
categoryFilter = "all";
|
||||
}}
|
||||
class="mt-4"
|
||||
>
|
||||
{$_("models.clear_filters")}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import {
|
||||
countCommentsForModel,
|
||||
getModelBySlug,
|
||||
getVideosForModel,
|
||||
} from "$lib/services.js";
|
||||
import { countCommentsForModel, getModelBySlug, getVideosForModel } from "$lib/services.js";
|
||||
export async function load({ fetch, params }) {
|
||||
try {
|
||||
const model = await getModelBySlug(params.slug, fetch);
|
||||
const commentsCount = await countCommentsForModel(model.id, fetch);
|
||||
const videos = await getVideosForModel(model.id, fetch);
|
||||
return { model, commentsCount, videos };
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
error(404, "Model not found");
|
||||
}
|
||||
try {
|
||||
const model = await getModelBySlug(params.slug, fetch);
|
||||
const commentsCount = await countCommentsForModel(model.id, fetch);
|
||||
const videos = await getVideosForModel(model.id, fetch);
|
||||
return { model, commentsCount, videos };
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
error(404, "Model not found");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { _, locale } from "svelte-i18n";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card, CardContent } from "$lib/components/ui/card";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "$lib/components/ui/tabs";
|
||||
import { getAssetUrl } from "$lib/directus";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||
import SharingPopupButton from "$lib/components/sharing-popup/sharing-popup-button.svelte";
|
||||
import { page } from "$app/state";
|
||||
import ImageViewer from "$lib/components/image-viewer/image-viewer.svelte";
|
||||
import { formatVideoDuration } from "$lib/utils.js";
|
||||
import { _, locale } from "svelte-i18n";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card, CardContent } from "$lib/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "$lib/components/ui/tabs";
|
||||
import { getAssetUrl } from "$lib/directus";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||
import SharingPopupButton from "$lib/components/sharing-popup/sharing-popup-button.svelte";
|
||||
import { page } from "$app/state";
|
||||
import ImageViewer from "$lib/components/image-viewer/image-viewer.svelte";
|
||||
import { formatVideoDuration } from "$lib/utils.js";
|
||||
|
||||
let activeTab = $state("videos");
|
||||
let activeTab = $state("videos");
|
||||
|
||||
const { data } = $props();
|
||||
const { data } = $props();
|
||||
|
||||
let images = $derived(
|
||||
data.model.photos.map((p) => ({
|
||||
...p,
|
||||
url: getAssetUrl(p.id),
|
||||
thumbnail: getAssetUrl(p.id, "thumbnail"),
|
||||
})),
|
||||
);
|
||||
let images = $derived(
|
||||
data.model.photos.map((p) => ({
|
||||
...p,
|
||||
url: getAssetUrl(p.id),
|
||||
thumbnail: getAssetUrl(p.id, "thumbnail"),
|
||||
})),
|
||||
);
|
||||
|
||||
// Calculate total likes and plays from all videos
|
||||
let totalLikes = $derived(
|
||||
data.videos.reduce((sum, video) => sum + (video.likes_count || 0), 0)
|
||||
);
|
||||
let totalPlays = $derived(
|
||||
data.videos.reduce((sum, video) => sum + (video.plays_count || 0), 0)
|
||||
);
|
||||
// Calculate total likes and plays from all videos
|
||||
let totalLikes = $derived(data.videos.reduce((sum, video) => sum + (video.likes_count || 0), 0));
|
||||
let totalPlays = $derived(data.videos.reduce((sum, video) => sum + (video.plays_count || 0), 0));
|
||||
</script>
|
||||
|
||||
<Meta
|
||||
title={data.model.artist_name}
|
||||
description={data.model.description}
|
||||
image={getAssetUrl(data.model.avatar, 'medium')!}
|
||||
image={getAssetUrl(data.model.avatar, "medium")!}
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -65,22 +56,18 @@ let totalPlays = $derived(
|
||||
variant="ghost"
|
||||
class="absolute top-4 left-4 bg-black/50 hover:bg-black/70 text-white"
|
||||
href="/models"
|
||||
><span class="icon-[ri--arrow-left-long-line] w-4 h-4 mr-1"></span>{$_(
|
||||
'models.back'
|
||||
)}</Button
|
||||
><span class="icon-[ri--arrow-left-long-line] w-4 h-4 mr-1"></span>{$_("models.back")}</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Profile Header -->
|
||||
<div class="container mx-auto px-4 -mt-20 relative z-10">
|
||||
<div
|
||||
class="bg-card/90 backdrop-blur-sm rounded-2xl border border-border/50 p-6 shadow-2xl"
|
||||
>
|
||||
<div class="bg-card/90 backdrop-blur-sm rounded-2xl border border-border/50 p-6 shadow-2xl">
|
||||
<div class="flex flex-col md:flex-row gap-6">
|
||||
<!-- Profile Image -->
|
||||
<div class="relative">
|
||||
<img
|
||||
src={getAssetUrl(data.model.avatar, 'thumbnail')}
|
||||
src={getAssetUrl(data.model.avatar, "thumbnail")}
|
||||
alt="${data.model.artist_name}"
|
||||
class="w-32 h-32 rounded-2xl object-cover ring-4 ring-primary/20"
|
||||
/>
|
||||
@@ -96,9 +83,7 @@ let totalPlays = $derived(
|
||||
|
||||
<!-- Profile Info -->
|
||||
<div class="flex-1">
|
||||
<div
|
||||
class="flex flex-col md:flex-row md:items-start md:justify-between gap-4"
|
||||
>
|
||||
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold mb-2">{data.model.artist_name}</h1>
|
||||
<div class="flex items-center gap-4 text-muted-foreground mb-3">
|
||||
@@ -112,16 +97,14 @@ let totalPlays = $derived(
|
||||
</div> -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="icon-[ri--calendar-line] w-4 h-4"></span>
|
||||
{$_('models.joined', {
|
||||
{$_("models.joined", {
|
||||
values: {
|
||||
join_date: new Date(
|
||||
data.model.date_created
|
||||
).toLocaleDateString($locale!, {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
join_date: new Date(data.model.date_created).toLocaleDateString($locale!, {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}),
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,7 +152,7 @@ let totalPlays = $derived(
|
||||
title: data.model.artist_name,
|
||||
description: data.model.description,
|
||||
url: page.url,
|
||||
type: 'model'
|
||||
type: "model",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -178,14 +161,12 @@ let totalPlays = $derived(
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div
|
||||
class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6 pt-6 border-t border-border/50"
|
||||
>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6 pt-6 border-t border-border/50">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-primary">
|
||||
{data.videos.length}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">{$_('models.videos')}</div>
|
||||
<div class="text-sm text-muted-foreground">{$_("models.videos")}</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-primary">
|
||||
@@ -204,7 +185,7 @@ let totalPlays = $derived(
|
||||
{data.commentsCount}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{$_('models.comments')}
|
||||
{$_("models.comments")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,11 +198,11 @@ let totalPlays = $derived(
|
||||
<TabsList class="grid w-full grid-cols-2 max-w-md mx-auto mb-8">
|
||||
<TabsTrigger value="videos" class="flex items-center gap-2">
|
||||
<span class="icon-[ri--play-large-fill] w-4 h-4"></span>
|
||||
{$_('models.videos')}
|
||||
{$_("models.videos")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="photos" class="flex items-center gap-2">
|
||||
<span class="icon-[ri--camera-fill] w-4 h-4"></span>
|
||||
{$_('models.photos')}
|
||||
{$_("models.photos")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -233,17 +214,17 @@ let totalPlays = $derived(
|
||||
>
|
||||
<div class="relative">
|
||||
<img
|
||||
src={getAssetUrl(video.image, 'preview')}
|
||||
src={getAssetUrl(video.image, "preview")}
|
||||
alt={video.title}
|
||||
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent transition-transform group-hover:scale-105 duration-300"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-2 left-2 text-white text-sm font-medium"
|
||||
>
|
||||
{#if video.movie_file?.duration}{formatVideoDuration(video.movie_file.duration)}{/if}
|
||||
<div class="absolute bottom-2 left-2 text-white text-sm font-medium">
|
||||
{#if video.movie_file?.duration}{formatVideoDuration(
|
||||
video.movie_file.duration,
|
||||
)}{/if}
|
||||
</div>
|
||||
<!-- <div
|
||||
class="absolute top-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded-full"
|
||||
@@ -258,20 +239,15 @@ let totalPlays = $derived(
|
||||
<div
|
||||
class="w-16 h-16 bg-primary/90 rounded-full flex flex-col items-center justify-center shadow-2xl"
|
||||
>
|
||||
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white"
|
||||
></span>
|
||||
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white"></span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<CardContent class="px-4 pb-4 pt-0">
|
||||
<h3
|
||||
class="font-semibold mb-2 group-hover:text-primary transition-colors"
|
||||
>
|
||||
<h3 class="font-semibold mb-2 group-hover:text-primary transition-colors">
|
||||
{video.title}
|
||||
</h3>
|
||||
<div
|
||||
class="flex items-center justify-between text-sm text-muted-foreground"
|
||||
>
|
||||
<div class="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="icon-[ri--play-fill] w-4 h-4"></span>
|
||||
{video.plays_count || 0}
|
||||
@@ -287,10 +263,9 @@ let totalPlays = $derived(
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="photos">
|
||||
<TabsContent value="photos">
|
||||
<ImageViewer {images} />
|
||||
</TabsContent>
|
||||
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export async function load({ locals }) {
|
||||
return {
|
||||
authStatus: locals.authStatus,
|
||||
};
|
||||
return {
|
||||
authStatus: locals.authStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,126 +1,124 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "$lib/components/ui/card";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import * as Alert from "$lib/components/ui/alert";
|
||||
import { goto } from "$app/navigation";
|
||||
import { requestPassword } from "$lib/services";
|
||||
import { onMount } from "svelte";
|
||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import Logo from "$lib/components/logo/logo.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "$lib/components/ui/card";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import * as Alert from "$lib/components/ui/alert";
|
||||
import { goto } from "$app/navigation";
|
||||
import { requestPassword } from "$lib/services";
|
||||
import { onMount } from "svelte";
|
||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import Logo from "$lib/components/logo/logo.svelte";
|
||||
|
||||
let email = $state("");
|
||||
let error = $state("");
|
||||
let isLoading = $state(false);
|
||||
let isError = $state(false);
|
||||
let email = $state("");
|
||||
let error = $state("");
|
||||
let isLoading = $state(false);
|
||||
let isError = $state(false);
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await requestPassword(email);
|
||||
toast.success(
|
||||
$_("auth.password_request.toast_request", { values: { email } }),
|
||||
);
|
||||
goto("/login");
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
isError = true;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await requestPassword(email);
|
||||
toast.success($_("auth.password_request.toast_request", { values: { email } }));
|
||||
goto("/login");
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
isError = true;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
const { data } = $props();
|
||||
const { data } = $props();
|
||||
|
||||
onMount(() => {
|
||||
if (!data.authStatus.authenticated) {
|
||||
return;
|
||||
}
|
||||
goto("/");
|
||||
});
|
||||
onMount(() => {
|
||||
if (!data.authStatus.authenticated) {
|
||||
return;
|
||||
}
|
||||
goto("/");
|
||||
});
|
||||
</script>
|
||||
|
||||
<Meta
|
||||
title={$_("auth.password_request.title")}
|
||||
description={$_("auth.password_request.description")}
|
||||
title={$_("auth.password_request.title")}
|
||||
description={$_("auth.password_request.description")}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="relative min-h-screen flex items-center justify-center bg-gradient-to-br from-primary/5 via-accent/5 to-background p-4 overflow-hidden"
|
||||
class="relative min-h-screen flex items-center justify-center bg-gradient-to-br from-primary/5 via-accent/5 to-background p-4 overflow-hidden"
|
||||
>
|
||||
<PeonyBackground />
|
||||
<PeonyBackground />
|
||||
|
||||
<div class="w-full max-w-md">
|
||||
<!-- Logo -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="flex items-center justify-center gap-3 text-2xl font-bold mb-2">
|
||||
|
||||
<Logo />
|
||||
</div>
|
||||
<p class="text-muted-foreground">{$_("auth.password_request.welcome")}</p>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
class="bg-gradient-to-br from-card/85 via-card/90 to-card/80 backdrop-blur-xl shadow-2xl shadow-primary/20"
|
||||
>
|
||||
<CardHeader class="text-center">
|
||||
<CardTitle class="text-2xl">{$_("auth.password_request.title")}</CardTitle>
|
||||
<CardDescription>{$_("auth.password_request.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<!-- Email -->
|
||||
<div class="space-y-2">
|
||||
<Label for="email">{$_("auth.password_request.email")}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder={$_("auth.password_request.email_placeholder")}
|
||||
bind:value={email}
|
||||
required
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if isError}
|
||||
<div class="grid w-full max-w-xl items-start gap-4">
|
||||
<Alert.Root variant="destructive">
|
||||
<Alert.Title class="items-center flex"
|
||||
><span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"
|
||||
></span>{$_("auth.password_request.error")}</Alert.Title
|
||||
>
|
||||
<Alert.Description>{error}</Alert.Description>
|
||||
</Alert.Root>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Submit Button -->
|
||||
<Button
|
||||
type="submit"
|
||||
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{#if isLoading}
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
|
||||
></div>
|
||||
{$_("auth.password_request.requesting")}
|
||||
{:else}
|
||||
{$_("auth.password_request.request")}
|
||||
{/if}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div class="w-full max-w-md">
|
||||
<!-- Logo -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="flex items-center justify-center gap-3 text-2xl font-bold mb-2">
|
||||
<Logo />
|
||||
</div>
|
||||
<p class="text-muted-foreground">{$_("auth.password_request.welcome")}</p>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
class="bg-gradient-to-br from-card/85 via-card/90 to-card/80 backdrop-blur-xl shadow-2xl shadow-primary/20"
|
||||
>
|
||||
<CardHeader class="text-center">
|
||||
<CardTitle class="text-2xl">{$_("auth.password_request.title")}</CardTitle>
|
||||
<CardDescription>{$_("auth.password_request.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<!-- Email -->
|
||||
<div class="space-y-2">
|
||||
<Label for="email">{$_("auth.password_request.email")}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder={$_("auth.password_request.email_placeholder")}
|
||||
bind:value={email}
|
||||
required
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if isError}
|
||||
<div class="grid w-full max-w-xl items-start gap-4">
|
||||
<Alert.Root variant="destructive">
|
||||
<Alert.Title class="items-center flex"
|
||||
><span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"></span>{$_(
|
||||
"auth.password_request.error",
|
||||
)}</Alert.Title
|
||||
>
|
||||
<Alert.Description>{error}</Alert.Description>
|
||||
</Alert.Root>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Submit Button -->
|
||||
<Button
|
||||
type="submit"
|
||||
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{#if isLoading}
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
|
||||
></div>
|
||||
{$_("auth.password_request.requesting")}
|
||||
{:else}
|
||||
{$_("auth.password_request.request")}
|
||||
{/if}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
|
||||
export async function load({ locals, url }) {
|
||||
const token = url.searchParams.get("token");
|
||||
if (!token) {
|
||||
error(404, "Not found");
|
||||
}
|
||||
return {
|
||||
authStatus: locals.authStatus,
|
||||
token,
|
||||
};
|
||||
const token = url.searchParams.get("token");
|
||||
if (!token) {
|
||||
error(404, "Not found");
|
||||
}
|
||||
return {
|
||||
authStatus: locals.authStatus,
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,171 +1,169 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "$lib/components/ui/card";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import * as Alert from "$lib/components/ui/alert";
|
||||
import { goto } from "$app/navigation";
|
||||
import { resetPassword } from "$lib/services";
|
||||
import { onMount } from "svelte";
|
||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import Logo from "$lib/components/logo/logo.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "$lib/components/ui/card";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import * as Alert from "$lib/components/ui/alert";
|
||||
import { goto } from "$app/navigation";
|
||||
import { resetPassword } from "$lib/services";
|
||||
import { onMount } from "svelte";
|
||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import Logo from "$lib/components/logo/logo.svelte";
|
||||
|
||||
let error = $state("");
|
||||
let isLoading = $state(false);
|
||||
let isError = $state(false);
|
||||
let password = $state("");
|
||||
let confirmPassword = $state("");
|
||||
let showPassword = $state(false);
|
||||
let showConfirmPassword = $state(false);
|
||||
let error = $state("");
|
||||
let isLoading = $state(false);
|
||||
let isError = $state(false);
|
||||
let password = $state("");
|
||||
let confirmPassword = $state("");
|
||||
let showPassword = $state(false);
|
||||
let showConfirmPassword = $state(false);
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (password !== confirmPassword) {
|
||||
throw new Error($_("auth.password_reset.password_error"));
|
||||
}
|
||||
isLoading = true;
|
||||
isError = false;
|
||||
error = "";
|
||||
await resetPassword(data.token, password);
|
||||
toast.success($_("auth.password_reset.toast_reset"));
|
||||
goto("/login");
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
isError = true;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (password !== confirmPassword) {
|
||||
throw new Error($_("auth.password_reset.password_error"));
|
||||
}
|
||||
isLoading = true;
|
||||
isError = false;
|
||||
error = "";
|
||||
await resetPassword(data.token, password);
|
||||
toast.success($_("auth.password_reset.toast_reset"));
|
||||
goto("/login");
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
isError = true;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
const { data } = $props();
|
||||
const { data } = $props();
|
||||
|
||||
onMount(() => {
|
||||
if (!data.authStatus.authenticated) {
|
||||
return;
|
||||
}
|
||||
goto("/");
|
||||
});
|
||||
onMount(() => {
|
||||
if (!data.authStatus.authenticated) {
|
||||
return;
|
||||
}
|
||||
goto("/");
|
||||
});
|
||||
</script>
|
||||
|
||||
<Meta title={$_("auth.password_reset.title")} description={$_("auth.password_reset.description")} />
|
||||
|
||||
<div
|
||||
class="relative min-h-screen flex items-center justify-center bg-gradient-to-br from-primary/5 via-accent/5 to-background p-4 overflow-hidden"
|
||||
class="relative min-h-screen flex items-center justify-center bg-gradient-to-br from-primary/5 via-accent/5 to-background p-4 overflow-hidden"
|
||||
>
|
||||
<PeonyBackground />
|
||||
<PeonyBackground />
|
||||
|
||||
<div class="w-full max-w-md">
|
||||
<!-- Logo -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="flex items-center justify-center gap-3 text-2xl font-bold mb-2">
|
||||
|
||||
<Logo />
|
||||
</div>
|
||||
<p class="text-muted-foreground">{$_("auth.password_reset.welcome")}</p>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
class="bg-gradient-to-br from-card/85 via-card/90 to-card/80 backdrop-blur-xl shadow-2xl shadow-primary/20"
|
||||
>
|
||||
<CardHeader class="text-center">
|
||||
<CardTitle class="text-2xl">{$_("auth.password_reset.title")}</CardTitle>
|
||||
<CardDescription>{$_("auth.password_reset.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<!-- Password -->
|
||||
<div class="space-y-2">
|
||||
<Label for="password">{$_("auth.password_reset.password")}</Label>
|
||||
<div class="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={$_("auth.password_reset.password_placeholder")}
|
||||
bind:value={password}
|
||||
required
|
||||
class="bg-background/50 border-primary/20 focus:border-primary pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{#if showPassword}
|
||||
<span class="icon-[ri--eye-off-line] w-4 h-4"></span>
|
||||
{:else}
|
||||
<span class="icon-[ri--eye-line] w-4 h-4"></span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<div class="space-y-2">
|
||||
<Label for="confirmPassword"
|
||||
>{$_("auth.password_reset.confirm_password")}</Label
|
||||
>
|
||||
<div class="relative">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder={$_("auth.password_reset.confirm_password_placeholder")}
|
||||
bind:value={confirmPassword}
|
||||
required
|
||||
class="bg-background/50 border-primary/20 focus:border-primary pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showConfirmPassword = !showConfirmPassword)}
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{#if showConfirmPassword}
|
||||
<span class="icon-[ri--eye-off-line] w-4 h-4"></span>
|
||||
{:else}
|
||||
<span class="icon-[ri--eye-line] w-4 h-4"></span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isError}
|
||||
<div class="grid w-full max-w-xl items-start gap-4">
|
||||
<Alert.Root variant="destructive">
|
||||
<Alert.Title class="items-center flex"
|
||||
><span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"
|
||||
></span>{$_("auth.password_reset.error")}</Alert.Title
|
||||
>
|
||||
<Alert.Description>{error}</Alert.Description>
|
||||
</Alert.Root>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Submit Button -->
|
||||
<Button
|
||||
type="submit"
|
||||
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{#if isLoading}
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
|
||||
></div>
|
||||
{$_("auth.password_reset.resetting")}
|
||||
{:else}
|
||||
{$_("auth.password_reset.reset")}
|
||||
{/if}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div class="w-full max-w-md">
|
||||
<!-- Logo -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="flex items-center justify-center gap-3 text-2xl font-bold mb-2">
|
||||
<Logo />
|
||||
</div>
|
||||
<p class="text-muted-foreground">{$_("auth.password_reset.welcome")}</p>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
class="bg-gradient-to-br from-card/85 via-card/90 to-card/80 backdrop-blur-xl shadow-2xl shadow-primary/20"
|
||||
>
|
||||
<CardHeader class="text-center">
|
||||
<CardTitle class="text-2xl">{$_("auth.password_reset.title")}</CardTitle>
|
||||
<CardDescription>{$_("auth.password_reset.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<!-- Password -->
|
||||
<div class="space-y-2">
|
||||
<Label for="password">{$_("auth.password_reset.password")}</Label>
|
||||
<div class="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={$_("auth.password_reset.password_placeholder")}
|
||||
bind:value={password}
|
||||
required
|
||||
class="bg-background/50 border-primary/20 focus:border-primary pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{#if showPassword}
|
||||
<span class="icon-[ri--eye-off-line] w-4 h-4"></span>
|
||||
{:else}
|
||||
<span class="icon-[ri--eye-line] w-4 h-4"></span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<div class="space-y-2">
|
||||
<Label for="confirmPassword">{$_("auth.password_reset.confirm_password")}</Label>
|
||||
<div class="relative">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder={$_("auth.password_reset.confirm_password_placeholder")}
|
||||
bind:value={confirmPassword}
|
||||
required
|
||||
class="bg-background/50 border-primary/20 focus:border-primary pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showConfirmPassword = !showConfirmPassword)}
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{#if showConfirmPassword}
|
||||
<span class="icon-[ri--eye-off-line] w-4 h-4"></span>
|
||||
{:else}
|
||||
<span class="icon-[ri--eye-line] w-4 h-4"></span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isError}
|
||||
<div class="grid w-full max-w-xl items-start gap-4">
|
||||
<Alert.Root variant="destructive">
|
||||
<Alert.Title class="items-center flex"
|
||||
><span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"></span>{$_(
|
||||
"auth.password_reset.error",
|
||||
)}</Alert.Title
|
||||
>
|
||||
<Alert.Description>{error}</Alert.Description>
|
||||
</Alert.Root>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Submit Button -->
|
||||
<Button
|
||||
type="submit"
|
||||
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{#if isLoading}
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
|
||||
></div>
|
||||
{$_("auth.password_reset.resetting")}
|
||||
{:else}
|
||||
{$_("auth.password_reset.reset")}
|
||||
{/if}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { getRecording } from "$lib/services";
|
||||
|
||||
export async function load({ locals, url, fetch }) {
|
||||
const recordingId = url.searchParams.get("recording");
|
||||
const recordingId = url.searchParams.get("recording");
|
||||
|
||||
let recording = null;
|
||||
if (recordingId && locals.authStatus.authenticated) {
|
||||
try {
|
||||
recording = await getRecording(recordingId, fetch);
|
||||
} catch (error) {
|
||||
console.error("Failed to load recording:", error);
|
||||
}
|
||||
}
|
||||
let recording = null;
|
||||
if (recordingId && locals.authStatus.authenticated) {
|
||||
try {
|
||||
recording = await getRecording(recordingId, fetch);
|
||||
} catch (error) {
|
||||
console.error("Failed to load recording:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authStatus: locals.authStatus,
|
||||
recording,
|
||||
};
|
||||
return {
|
||||
authStatus: locals.authStatus,
|
||||
recording,
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,171 +1,166 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import Button from "$lib/components/ui/button/button.svelte";
|
||||
import type { BluetoothDevice, DeviceInfo } from "$lib/types";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import Button from "$lib/components/ui/button/button.svelte";
|
||||
import type { BluetoothDevice, DeviceInfo } from "$lib/types";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
recordedDevices: DeviceInfo[];
|
||||
connectedDevices: BluetoothDevice[];
|
||||
onConfirm: (mappings: Map<string, BluetoothDevice>) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
interface Props {
|
||||
open: boolean;
|
||||
recordedDevices: DeviceInfo[];
|
||||
connectedDevices: BluetoothDevice[];
|
||||
onConfirm: (mappings: Map<string, BluetoothDevice>) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let { open, recordedDevices, connectedDevices, onConfirm, onCancel }: Props = $props();
|
||||
let { open, recordedDevices, connectedDevices, onConfirm, onCancel }: Props = $props();
|
||||
|
||||
// Device mappings: recorded device name -> connected device
|
||||
let mappings = new SvelteMap<string, BluetoothDevice>();
|
||||
// Device mappings: recorded device name -> connected device
|
||||
let mappings = new SvelteMap<string, BluetoothDevice>();
|
||||
|
||||
// Check if a connected device is compatible with a recorded device
|
||||
function isCompatible(recordedDevice: DeviceInfo, connectedDevice: BluetoothDevice): boolean {
|
||||
const connectedActuators = connectedDevice.actuators.map(
|
||||
(a) => a.outputType,
|
||||
);
|
||||
// Check if a connected device is compatible with a recorded device
|
||||
function isCompatible(recordedDevice: DeviceInfo, connectedDevice: BluetoothDevice): boolean {
|
||||
const connectedActuators = connectedDevice.actuators.map((a) => a.outputType);
|
||||
|
||||
// Check if all required actuator types from recording exist on connected device
|
||||
return recordedDevice.capabilities.every(requiredType =>
|
||||
connectedActuators.includes(requiredType)
|
||||
);
|
||||
}
|
||||
// Check if all required actuator types from recording exist on connected device
|
||||
return recordedDevice.capabilities.every((requiredType) =>
|
||||
connectedActuators.includes(requiredType),
|
||||
);
|
||||
}
|
||||
|
||||
// Get compatible devices for a recorded device
|
||||
function getCompatibleDevices(recordedDevice: DeviceInfo): BluetoothDevice[] {
|
||||
return connectedDevices.filter(device => isCompatible(recordedDevice, device));
|
||||
}
|
||||
// Get compatible devices for a recorded device
|
||||
function getCompatibleDevices(recordedDevice: DeviceInfo): BluetoothDevice[] {
|
||||
return connectedDevices.filter((device) => isCompatible(recordedDevice, device));
|
||||
}
|
||||
|
||||
// Auto-map devices on open
|
||||
$effect(() => {
|
||||
if (open && recordedDevices.length > 0 && connectedDevices.length > 0) {
|
||||
const newMappings = new SvelteMap<string, BluetoothDevice>();
|
||||
// Auto-map devices on open
|
||||
$effect(() => {
|
||||
if (open && recordedDevices.length > 0 && connectedDevices.length > 0) {
|
||||
const newMappings = new SvelteMap<string, BluetoothDevice>();
|
||||
|
||||
recordedDevices.forEach(recordedDevice => {
|
||||
// Try to find exact name match first
|
||||
let match = connectedDevices.find(d => d.name === recordedDevice.name);
|
||||
recordedDevices.forEach((recordedDevice) => {
|
||||
// Try to find exact name match first
|
||||
let match = connectedDevices.find((d) => d.name === recordedDevice.name);
|
||||
|
||||
// If no exact match, find first compatible device
|
||||
if (!match) {
|
||||
const compatible = getCompatibleDevices(recordedDevice);
|
||||
if (compatible.length > 0) {
|
||||
match = compatible[0];
|
||||
}
|
||||
}
|
||||
// If no exact match, find first compatible device
|
||||
if (!match) {
|
||||
const compatible = getCompatibleDevices(recordedDevice);
|
||||
if (compatible.length > 0) {
|
||||
match = compatible[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (match) {
|
||||
newMappings.set(recordedDevice.name, match);
|
||||
}
|
||||
});
|
||||
if (match) {
|
||||
newMappings.set(recordedDevice.name, match);
|
||||
}
|
||||
});
|
||||
|
||||
mappings = newMappings;
|
||||
}
|
||||
});
|
||||
mappings = newMappings;
|
||||
}
|
||||
});
|
||||
|
||||
function handleConfirm() {
|
||||
// Validate that all devices are mapped
|
||||
const allMapped = recordedDevices.every(rd => mappings.has(rd.name));
|
||||
if (!allMapped) {
|
||||
return;
|
||||
}
|
||||
onConfirm(mappings);
|
||||
}
|
||||
function handleConfirm() {
|
||||
// Validate that all devices are mapped
|
||||
const allMapped = recordedDevices.every((rd) => mappings.has(rd.name));
|
||||
if (!allMapped) {
|
||||
return;
|
||||
}
|
||||
onConfirm(mappings);
|
||||
}
|
||||
|
||||
function handleDeviceSelect(recordedDeviceName: string, deviceId: string) {
|
||||
if (!deviceId) return;
|
||||
function handleDeviceSelect(recordedDeviceName: string, deviceId: string) {
|
||||
if (!deviceId) return;
|
||||
|
||||
const device = connectedDevices.find(d => d.id === deviceId);
|
||||
if (device) {
|
||||
const newMappings = new SvelteMap(mappings);
|
||||
newMappings.set(recordedDeviceName, device);
|
||||
mappings = newMappings;
|
||||
}
|
||||
}
|
||||
const device = connectedDevices.find((d) => d.id === deviceId);
|
||||
if (device) {
|
||||
const newMappings = new SvelteMap(mappings);
|
||||
newMappings.set(recordedDeviceName, device);
|
||||
mappings = newMappings;
|
||||
}
|
||||
}
|
||||
|
||||
const allDevicesMapped = $derived(
|
||||
recordedDevices.every(rd => mappings.has(rd.name))
|
||||
);
|
||||
const allDevicesMapped = $derived(recordedDevices.every((rd) => mappings.has(rd.name)));
|
||||
</script>
|
||||
|
||||
<Dialog.Root {open}>
|
||||
<Dialog.Content class="max-w-2xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Map Devices for Playback</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Assign your connected devices to match the recorded devices. Only compatible devices are shown.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Content class="max-w-2xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Map Devices for Playback</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Assign your connected devices to match the recorded devices. Only compatible devices are
|
||||
shown.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="space-y-4 py-4">
|
||||
{#each recordedDevices as recordedDevice (recordedDevice.name)}
|
||||
{@const compatibleDevices = getCompatibleDevices(recordedDevice)}
|
||||
{@const currentMapping = mappings.get(recordedDevice.name)}
|
||||
<div class="space-y-4 py-4">
|
||||
{#each recordedDevices as recordedDevice (recordedDevice.name)}
|
||||
{@const compatibleDevices = getCompatibleDevices(recordedDevice)}
|
||||
{@const currentMapping = mappings.get(recordedDevice.name)}
|
||||
|
||||
<div class="flex items-center gap-4 p-4 bg-muted/30 rounded-lg border border-border/50">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="icon-[ri--router-line] w-5 h-5 text-primary"></span>
|
||||
<h3 class="font-semibold text-card-foreground">{recordedDevice.name}</h3>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each recordedDevice.capabilities as capability (capability)}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary border border-primary/20">
|
||||
{capability}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 p-4 bg-muted/30 rounded-lg border border-border/50">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="icon-[ri--router-line] w-5 h-5 text-primary"></span>
|
||||
<h3 class="font-semibold text-card-foreground">{recordedDevice.name}</h3>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each recordedDevice.capabilities as capability (capability)}
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary border border-primary/20"
|
||||
>
|
||||
{capability}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-px h-12 bg-border"></div>
|
||||
<div class="w-px h-12 bg-border"></div>
|
||||
|
||||
<div class="flex-1">
|
||||
{#if compatibleDevices.length === 0}
|
||||
<div class="flex items-center gap-2 text-destructive">
|
||||
<span class="icon-[ri--error-warning-line] w-5 h-5"></span>
|
||||
<span class="text-sm">No compatible devices</span>
|
||||
</div>
|
||||
{:else}
|
||||
<select
|
||||
class="w-full px-3 py-2 rounded-md border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
value={currentMapping?.id || ''}
|
||||
onchange={(e) => handleDeviceSelect(recordedDevice.name, e.currentTarget.value)}
|
||||
>
|
||||
<option value="" disabled>Select device...</option>
|
||||
{#each compatibleDevices as device (device.name)}
|
||||
<option value={device.id}>
|
||||
{device.name}
|
||||
{#if device.name === recordedDevice.name}(exact match){/if}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="flex-1">
|
||||
{#if compatibleDevices.length === 0}
|
||||
<div class="flex items-center gap-2 text-destructive">
|
||||
<span class="icon-[ri--error-warning-line] w-5 h-5"></span>
|
||||
<span class="text-sm">No compatible devices</span>
|
||||
</div>
|
||||
{:else}
|
||||
<select
|
||||
class="w-full px-3 py-2 rounded-md border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
value={currentMapping?.id || ""}
|
||||
onchange={(e) => handleDeviceSelect(recordedDevice.name, e.currentTarget.value)}
|
||||
>
|
||||
<option value="" disabled>Select device...</option>
|
||||
{#each compatibleDevices as device (device.name)}
|
||||
<option value={device.id}>
|
||||
{device.name}
|
||||
{#if device.name === recordedDevice.name}(exact match){/if}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if recordedDevices.length === 0}
|
||||
<div class="text-center py-8 text-muted-foreground">
|
||||
No devices in this recording
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if recordedDevices.length === 0}
|
||||
<div class="text-center py-8 text-muted-foreground">No devices in this recording</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="flex gap-2">
|
||||
<Button variant="outline" onclick={onCancel} class="cursor-pointer">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onclick={handleConfirm}
|
||||
disabled={!allDevicesMapped}
|
||||
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
>
|
||||
{#if !allDevicesMapped}
|
||||
<span class="icon-[ri--error-warning-line] w-4 h-4 mr-2"></span>
|
||||
Map All Devices
|
||||
{:else}
|
||||
<span class="icon-[ri--play-fill] w-4 h-4 mr-2"></span>
|
||||
Start Playback
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
<Dialog.Footer class="flex gap-2">
|
||||
<Button variant="outline" onclick={onCancel} class="cursor-pointer">Cancel</Button>
|
||||
<Button
|
||||
onclick={handleConfirm}
|
||||
disabled={!allDevicesMapped}
|
||||
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
>
|
||||
{#if !allDevicesMapped}
|
||||
<span class="icon-[ri--error-warning-line] w-4 h-4 mr-2"></span>
|
||||
Map All Devices
|
||||
{:else}
|
||||
<span class="icon-[ri--play-fill] w-4 h-4 mr-2"></span>
|
||||
Start Playback
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -1,174 +1,157 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { Textarea } from "$lib/components/ui/textarea";
|
||||
import { TagsInput } from "$lib/components/ui/tags-input";
|
||||
import type { RecordedEvent, DeviceInfo } from "$lib/types";
|
||||
import { _ } from "svelte-i18n";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { Textarea } from "$lib/components/ui/textarea";
|
||||
import { TagsInput } from "$lib/components/ui/tags-input";
|
||||
import type { RecordedEvent, DeviceInfo } from "$lib/types";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
events: RecordedEvent[];
|
||||
deviceInfo: DeviceInfo[];
|
||||
duration: number;
|
||||
onSave: (data: {
|
||||
title: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
}) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
interface Props {
|
||||
open: boolean;
|
||||
events: RecordedEvent[];
|
||||
deviceInfo: DeviceInfo[];
|
||||
duration: number;
|
||||
onSave: (data: { title: string; description: string; tags: string[] }) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let { open, events, deviceInfo, duration, onSave, onCancel }: Props = $props();
|
||||
let { open, events, deviceInfo, duration, onSave, onCancel }: Props = $props();
|
||||
|
||||
let title = $state("");
|
||||
let description = $state("");
|
||||
let tags = $state<string[]>([]);
|
||||
let isSaving = $state(false);
|
||||
let title = $state("");
|
||||
let description = $state("");
|
||||
let tags = $state<string[]>([]);
|
||||
let isSaving = $state(false);
|
||||
|
||||
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")}`;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!title.trim()) return;
|
||||
async function handleSave() {
|
||||
if (!title.trim()) return;
|
||||
|
||||
isSaving = true;
|
||||
try {
|
||||
await onSave({ title: title.trim(), description: description.trim(), tags });
|
||||
// Reset form
|
||||
title = "";
|
||||
description = "";
|
||||
tags = [];
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
isSaving = true;
|
||||
try {
|
||||
await onSave({ title: title.trim(), description: description.trim(), tags });
|
||||
// Reset form
|
||||
title = "";
|
||||
description = "";
|
||||
tags = [];
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
title = "";
|
||||
description = "";
|
||||
tags = [];
|
||||
onCancel();
|
||||
}
|
||||
function handleCancel() {
|
||||
title = "";
|
||||
description = "";
|
||||
tags = [];
|
||||
onCancel();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root {open} onOpenChange={(isOpen) => !isOpen && handleCancel()}>
|
||||
<Dialog.Content class="max-w-2xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Save Recording</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Save your recording to view and play it later from your dashboard
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Content class="max-w-2xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Save Recording</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Save your recording to view and play it later from your dashboard
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="space-y-6 py-4">
|
||||
<!-- Recording Stats -->
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div
|
||||
class="flex flex-col items-center p-4 rounded-lg bg-muted/30 border border-border/30"
|
||||
>
|
||||
<span class="icon-[ri--time-line] w-5 h-5 text-primary mb-2"></span>
|
||||
<span class="text-xs text-muted-foreground mb-1">Duration</span>
|
||||
<span class="font-semibold">{formatDuration(duration)}</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col items-center p-4 rounded-lg bg-muted/30 border border-border/30"
|
||||
>
|
||||
<span class="icon-[ri--pulse-line] w-5 h-5 text-accent mb-2"></span>
|
||||
<span class="text-xs text-muted-foreground mb-1">Events</span>
|
||||
<span class="font-semibold">{events.length}</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col items-center p-4 rounded-lg bg-muted/30 border border-border/30"
|
||||
>
|
||||
<span class="icon-[ri--gamepad-line] w-5 h-5 text-primary mb-2"></span>
|
||||
<span class="text-xs text-muted-foreground mb-1">Devices</span>
|
||||
<span class="font-semibold">{deviceInfo.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-6 py-4">
|
||||
<!-- Recording Stats -->
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="flex flex-col items-center p-4 rounded-lg bg-muted/30 border border-border/30">
|
||||
<span class="icon-[ri--time-line] w-5 h-5 text-primary mb-2"></span>
|
||||
<span class="text-xs text-muted-foreground mb-1">Duration</span>
|
||||
<span class="font-semibold">{formatDuration(duration)}</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center p-4 rounded-lg bg-muted/30 border border-border/30">
|
||||
<span class="icon-[ri--pulse-line] w-5 h-5 text-accent mb-2"></span>
|
||||
<span class="text-xs text-muted-foreground mb-1">Events</span>
|
||||
<span class="font-semibold">{events.length}</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center p-4 rounded-lg bg-muted/30 border border-border/30">
|
||||
<span class="icon-[ri--gamepad-line] w-5 h-5 text-primary mb-2"></span>
|
||||
<span class="text-xs text-muted-foreground mb-1">Devices</span>
|
||||
<span class="font-semibold">{deviceInfo.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Info -->
|
||||
<div class="space-y-2">
|
||||
<Label>Devices Used</Label>
|
||||
{#each deviceInfo as device (device.name)}
|
||||
<div
|
||||
class="flex items-center gap-2 text-sm bg-muted/20 rounded px-3 py-2"
|
||||
>
|
||||
<span class="icon-[ri--rocket-line] w-4 h-4"></span>
|
||||
<span class="font-medium">{device.name}</span>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
• {device.capabilities.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- Device Info -->
|
||||
<div class="space-y-2">
|
||||
<Label>Devices Used</Label>
|
||||
{#each deviceInfo as device (device.name)}
|
||||
<div class="flex items-center gap-2 text-sm bg-muted/20 rounded px-3 py-2">
|
||||
<span class="icon-[ri--rocket-line] w-4 h-4"></span>
|
||||
<span class="font-medium">{device.name}</span>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
• {device.capabilities.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Form Fields -->
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="title">Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
bind:value={title}
|
||||
placeholder="My awesome pattern"
|
||||
required
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<!-- Form Fields -->
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="title">Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
bind:value={title}
|
||||
placeholder="My awesome pattern"
|
||||
required
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
bind:value={description}
|
||||
placeholder="Describe your recording..."
|
||||
rows={3}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
bind:value={description}
|
||||
placeholder="Describe your recording..."
|
||||
rows={3}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="tags">Tags</Label>
|
||||
<TagsInput
|
||||
id="tags"
|
||||
bind:value={tags}
|
||||
placeholder="Add tags..."
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="tags">Tags</Label>
|
||||
<TagsInput
|
||||
id="tags"
|
||||
bind:value={tags}
|
||||
placeholder="Add tags..."
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={handleCancel}
|
||||
disabled={isSaving}
|
||||
class="cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onclick={handleSave}
|
||||
disabled={!title.trim() || isSaving}
|
||||
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
>
|
||||
{#if isSaving}
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
|
||||
></div>
|
||||
Saving...
|
||||
{:else}
|
||||
<span class="icon-[ri--save-line] w-4 h-4 mr-2"></span>
|
||||
Save Recording
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={handleCancel} disabled={isSaving} class="cursor-pointer">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onclick={handleSave}
|
||||
disabled={!title.trim() || isSaving}
|
||||
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
>
|
||||
{#if isSaving}
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
|
||||
></div>
|
||||
Saving...
|
||||
{:else}
|
||||
<span class="icon-[ri--save-line] w-4 h-4 mr-2"></span>
|
||||
Save Recording
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export async function load({ locals }) {
|
||||
return {
|
||||
authStatus: locals.authStatus,
|
||||
};
|
||||
return {
|
||||
authStatus: locals.authStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,74 +1,71 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import { goto } from "$app/navigation";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "$lib/components/ui/card";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { Checkbox } from "$lib/components/ui/checkbox";
|
||||
import { toast } from "svelte-sonner";
|
||||
import * as Alert from "$lib/components/ui/alert";
|
||||
import { register } from "$lib/services";
|
||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import Logo from "$lib/components/logo/logo.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { goto } from "$app/navigation";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "$lib/components/ui/card";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { Checkbox } from "$lib/components/ui/checkbox";
|
||||
import { toast } from "svelte-sonner";
|
||||
import * as Alert from "$lib/components/ui/alert";
|
||||
import { register } from "$lib/services";
|
||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import Logo from "$lib/components/logo/logo.svelte";
|
||||
|
||||
let firstName = $state("");
|
||||
let lastName = $state("");
|
||||
let email = $state("");
|
||||
let password = $state("");
|
||||
let confirmPassword = $state("");
|
||||
let showPassword = $state(false);
|
||||
let showConfirmPassword = $state(false);
|
||||
let agreeTerms = $state(false);
|
||||
let isLoading = $state(false);
|
||||
let isError = $state(false);
|
||||
let error = $state("");
|
||||
let firstName = $state("");
|
||||
let lastName = $state("");
|
||||
let email = $state("");
|
||||
let password = $state("");
|
||||
let confirmPassword = $state("");
|
||||
let showPassword = $state(false);
|
||||
let showConfirmPassword = $state(false);
|
||||
let agreeTerms = $state(false);
|
||||
let isLoading = $state(false);
|
||||
let isError = $state(false);
|
||||
let error = $state("");
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (!agreeTerms) {
|
||||
throw new Error($_("auth.signup.agree_error"));
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
throw new Error($_("auth.signup.password_error"));
|
||||
}
|
||||
isLoading = true;
|
||||
isError = false;
|
||||
error = "";
|
||||
await register(email, password, firstName, lastName);
|
||||
toast.success($_("auth.signup.toast_register", { values: { email } }));
|
||||
goto("/login");
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
isError = true;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (!agreeTerms) {
|
||||
throw new Error($_("auth.signup.agree_error"));
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
throw new Error($_("auth.signup.password_error"));
|
||||
}
|
||||
isLoading = true;
|
||||
isError = false;
|
||||
error = "";
|
||||
await register(email, password, firstName, lastName);
|
||||
toast.success($_("auth.signup.toast_register", { values: { email } }));
|
||||
goto("/login");
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
isError = true;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
const { data } = $props();
|
||||
const { data } = $props();
|
||||
|
||||
onMount(() => {
|
||||
if (!data.authStatus.authenticated) {
|
||||
return;
|
||||
}
|
||||
goto("/me");
|
||||
});
|
||||
onMount(() => {
|
||||
if (!data.authStatus.authenticated) {
|
||||
return;
|
||||
}
|
||||
goto("/me");
|
||||
});
|
||||
</script>
|
||||
|
||||
<Meta
|
||||
title={$_('auth.signup.title')}
|
||||
description={$_('auth.signup.description')}
|
||||
/>
|
||||
<Meta title={$_("auth.signup.title")} description={$_("auth.signup.description")} />
|
||||
|
||||
<div
|
||||
class="relative min-h-screen flex items-center justify-center bg-gradient-to-br from-primary/5 via-accent/5 to-background p-4 overflow-hidden"
|
||||
@@ -78,40 +75,38 @@ onMount(() => {
|
||||
<div class="w-full max-w-md">
|
||||
<!-- Logo -->
|
||||
<div class="text-center mb-8">
|
||||
<div
|
||||
class="flex items-center justify-center gap-3 text-2xl font-bold mb-2"
|
||||
>
|
||||
<div class="flex items-center justify-center gap-3 text-2xl font-bold mb-2">
|
||||
<Logo />
|
||||
</div>
|
||||
<p class="text-muted-foreground">{$_('auth.signup.welcome')}</p>
|
||||
<p class="text-muted-foreground">{$_("auth.signup.welcome")}</p>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
class="bg-gradient-to-br from-card/85 via-card/90 to-card/80 backdrop-blur-xl shadow-2xl shadow-primary/20"
|
||||
>
|
||||
<CardHeader class="text-center">
|
||||
<CardTitle class="text-2xl">{$_('auth.signup.title')}</CardTitle>
|
||||
<CardDescription>{$_('auth.signup.description')}</CardDescription>
|
||||
<CardTitle class="text-2xl">{$_("auth.signup.title")}</CardTitle>
|
||||
<CardDescription>{$_("auth.signup.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<!-- Name Fields -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="firstName">{$_('auth.signup.first_name')}</Label>
|
||||
<Label for="firstName">{$_("auth.signup.first_name")}</Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
placeholder={$_('auth.signup.first_name_placeholder')}
|
||||
placeholder={$_("auth.signup.first_name_placeholder")}
|
||||
bind:value={firstName}
|
||||
required
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="lastName">{$_('auth.signup.last_name')}</Label>
|
||||
<Label for="lastName">{$_("auth.signup.last_name")}</Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
placeholder={$_('auth.signup.last_name_placeholder')}
|
||||
placeholder={$_("auth.signup.last_name_placeholder")}
|
||||
bind:value={lastName}
|
||||
required
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
@@ -121,11 +116,11 @@ onMount(() => {
|
||||
|
||||
<!-- Email -->
|
||||
<div class="space-y-2">
|
||||
<Label for="email">{$_('auth.signup.email')}</Label>
|
||||
<Label for="email">{$_("auth.signup.email")}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder={$_('auth.signup.email_placeholder')}
|
||||
placeholder={$_("auth.signup.email_placeholder")}
|
||||
bind:value={email}
|
||||
required
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
@@ -134,12 +129,12 @@ onMount(() => {
|
||||
|
||||
<!-- Password -->
|
||||
<div class="space-y-2">
|
||||
<Label for="password">{$_('auth.signup.password')}</Label>
|
||||
<Label for="password">{$_("auth.signup.password")}</Label>
|
||||
<div class="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder={$_('auth.signup.password_placeholder')}
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={$_("auth.signup.password_placeholder")}
|
||||
bind:value={password}
|
||||
required
|
||||
class="bg-background/50 border-primary/20 focus:border-primary pr-10"
|
||||
@@ -160,14 +155,12 @@ onMount(() => {
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<div class="space-y-2">
|
||||
<Label for="confirmPassword"
|
||||
>{$_('auth.signup.confirm_password')}</Label
|
||||
>
|
||||
<Label for="confirmPassword">{$_("auth.signup.confirm_password")}</Label>
|
||||
<div class="relative">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
placeholder={$_('auth.signup.confirm_password_placeholder')}
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder={$_("auth.signup.confirm_password_placeholder")}
|
||||
bind:value={confirmPassword}
|
||||
required
|
||||
class="bg-background/50 border-primary/20 focus:border-primary pr-10"
|
||||
@@ -190,11 +183,11 @@ onMount(() => {
|
||||
<div class="flex items-start space-x-2">
|
||||
<Checkbox id="terms" bind:checked={agreeTerms} class="mt-1" />
|
||||
<Label for="terms" class="text-sm leading-relaxed">
|
||||
{$_('auth.signup.terms_agreement', {
|
||||
{$_("auth.signup.terms_agreement", {
|
||||
values: {
|
||||
terms: $_('auth.signup.terms_of_service'),
|
||||
privacy: $_('auth.signup.privacy_policy')
|
||||
}
|
||||
terms: $_("auth.signup.terms_of_service"),
|
||||
privacy: $_("auth.signup.privacy_policy"),
|
||||
},
|
||||
})}
|
||||
</Label>
|
||||
</div>
|
||||
@@ -203,8 +196,9 @@ onMount(() => {
|
||||
<div class="grid w-full max-w-xl items-start gap-4">
|
||||
<Alert.Root variant="destructive">
|
||||
<Alert.Title class="items-center flex"
|
||||
><span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"
|
||||
></span>{$_('auth.signup.error')}</Alert.Title
|
||||
><span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"></span>{$_(
|
||||
"auth.signup.error",
|
||||
)}</Alert.Title
|
||||
>
|
||||
<Alert.Description>{error}</Alert.Description>
|
||||
</Alert.Root>
|
||||
@@ -221,9 +215,9 @@ onMount(() => {
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
|
||||
></div>
|
||||
{$_('auth.signup.creating_account')}
|
||||
{$_("auth.signup.creating_account")}
|
||||
{:else}
|
||||
{$_('auth.signup.create_account')}
|
||||
{$_("auth.signup.create_account")}
|
||||
{/if}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -231,9 +225,9 @@ onMount(() => {
|
||||
<!-- Sign In Link -->
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{$_('auth.signup.have_account')}
|
||||
{$_("auth.signup.have_account")}
|
||||
<a href="/login" class="text-primary hover:underline font-medium"
|
||||
>{$_('auth.signup.sign_in_link')}</a
|
||||
>{$_("auth.signup.sign_in_link")}</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { verify } from "$lib/services.js";
|
||||
export async function load({ fetch, url }) {
|
||||
const token = url.searchParams.get("token");
|
||||
try {
|
||||
if (!token) {
|
||||
throw new Error();
|
||||
}
|
||||
await verify(token, fetch).catch((err) => {
|
||||
if (err.response.status != 302) {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
// redirect(308, '/login');
|
||||
} catch {
|
||||
error(404, "Not found");
|
||||
}
|
||||
const token = url.searchParams.get("token");
|
||||
try {
|
||||
if (!token) {
|
||||
throw new Error();
|
||||
}
|
||||
await verify(token, fetch).catch((err) => {
|
||||
if (err.response.status != 302) {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
// redirect(308, '/login');
|
||||
} catch {
|
||||
error(404, "Not found");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import { goto } from "$app/navigation";
|
||||
import { onMount } from "svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { goto } from "$app/navigation";
|
||||
import { onMount } from "svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
|
||||
onMount(() => {
|
||||
toast.success($_("auth.signup.toast_verify"));
|
||||
goto("/login");
|
||||
});
|
||||
onMount(() => {
|
||||
toast.success($_("auth.signup.toast_verify"));
|
||||
goto("/login");
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -2,22 +2,16 @@ import * as sitemap from "super-sitemap";
|
||||
import { getArticles, getModels, getVideos } from "$lib/services";
|
||||
|
||||
export const GET = async () => {
|
||||
return await sitemap.response({
|
||||
origin: "https://sexy.pivoine.art",
|
||||
excludeRoutePatterns: [
|
||||
"^/signup/verify",
|
||||
"^/password/reset",
|
||||
"^/me",
|
||||
"^/play",
|
||||
"^/tags/.+",
|
||||
],
|
||||
paramValues: {
|
||||
"/magazine/[slug]": (await getArticles(fetch)).map((a) => a.slug),
|
||||
"/models/[slug]": (await getModels(fetch)).map((a) => a.slug),
|
||||
"/videos/[slug]": (await getVideos(fetch)).map((a) => a.slug),
|
||||
},
|
||||
defaultChangefreq: "always",
|
||||
defaultPriority: 0.7,
|
||||
sort: "alpha", // default is false; 'alpha' sorts all paths alphabetically.
|
||||
});
|
||||
return await sitemap.response({
|
||||
origin: "https://sexy.pivoine.art",
|
||||
excludeRoutePatterns: ["^/signup/verify", "^/password/reset", "^/me", "^/play", "^/tags/.+"],
|
||||
paramValues: {
|
||||
"/magazine/[slug]": (await getArticles(fetch)).map((a) => a.slug),
|
||||
"/models/[slug]": (await getModels(fetch)).map((a) => a.slug),
|
||||
"/videos/[slug]": (await getVideos(fetch)).map((a) => a.slug),
|
||||
},
|
||||
defaultChangefreq: "always",
|
||||
defaultPriority: 0.7,
|
||||
sort: "alpha", // default is false; 'alpha' sorts all paths alphabetically.
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,24 +2,24 @@ import { error } from "@sveltejs/kit";
|
||||
import { getItemsByTag } from "$lib/services";
|
||||
|
||||
const getItems = (category, tag: string, fetch) => {
|
||||
return getItemsByTag(category, fetch).then((items) =>
|
||||
items
|
||||
?.filter((i) => i.tags.includes(tag))
|
||||
.map((i) => ({ ...i, category, title: i["artist_name"] || i["title"] })),
|
||||
);
|
||||
return getItemsByTag(category, fetch).then((items) =>
|
||||
items
|
||||
?.filter((i) => i.tags.includes(tag))
|
||||
.map((i) => ({ ...i, category, title: i["artist_name"] || i["title"] })),
|
||||
);
|
||||
};
|
||||
|
||||
export async function load({ fetch, params }) {
|
||||
try {
|
||||
return {
|
||||
tag: params.tag,
|
||||
items: await Promise.all([
|
||||
getItems("model", params.tag, fetch),
|
||||
getItems("video", params.tag, fetch),
|
||||
getItems("article", params.tag, fetch),
|
||||
]).then(([a, b, c]) => [...a, ...b, ...c]),
|
||||
};
|
||||
} catch {
|
||||
error(404, "Item not found");
|
||||
}
|
||||
try {
|
||||
return {
|
||||
tag: params.tag,
|
||||
items: await Promise.all([
|
||||
getItems("model", params.tag, fetch),
|
||||
getItems("video", params.tag, fetch),
|
||||
getItems("article", params.tag, fetch),
|
||||
]).then(([a, b, c]) => [...a, ...b, ...c]),
|
||||
};
|
||||
} catch {
|
||||
error(404, "Item not found");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card, CardContent } from "$lib/components/ui/card";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "$lib/components/ui/select";
|
||||
import { getAssetUrl } from "$lib/directus";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card, CardContent } from "$lib/components/ui/card";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
||||
import { getAssetUrl } from "$lib/directus";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
|
||||
let searchQuery = $state("");
|
||||
let categoryFilter = $state("all");
|
||||
let searchQuery = $state("");
|
||||
let categoryFilter = $state("all");
|
||||
|
||||
const { data } = $props();
|
||||
const { data } = $props();
|
||||
|
||||
function getUrlForItem(item) {
|
||||
switch (item.category) {
|
||||
case "video":
|
||||
return `/videos/${item.slug}`;
|
||||
case "article":
|
||||
return `/magazine/${item.slug}`;
|
||||
case "model":
|
||||
return `/models/${item.slug}`;
|
||||
}
|
||||
}
|
||||
function getUrlForItem(item) {
|
||||
switch (item.category) {
|
||||
case "video":
|
||||
return `/videos/${item.slug}`;
|
||||
case "article":
|
||||
return `/magazine/${item.slug}`;
|
||||
case "model":
|
||||
return `/models/${item.slug}`;
|
||||
}
|
||||
}
|
||||
|
||||
const filteredItems = $derived(() => {
|
||||
return data.items
|
||||
.filter((item: any) => {
|
||||
const matchesSearch =
|
||||
searchQuery === "" ||
|
||||
item.title.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesCategory =
|
||||
categoryFilter === "all" || item.category === categoryFilter;
|
||||
return matchesSearch && matchesCategory;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// if (sortBy === "popular") {
|
||||
// const aNum = parseInt(a.subscribers.replace(/[^\d]/g, ""));
|
||||
// const bNum = parseInt(b.subscribers.replace(/[^\d]/g, ""));
|
||||
// return bNum - aNum;
|
||||
// }
|
||||
// if (sortBy === "rating") return b.rating - a.rating;
|
||||
// if (sortBy === "videos") return b.videos - a.videos;
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
});
|
||||
const filteredItems = $derived(() => {
|
||||
return data.items
|
||||
.filter((item: any) => {
|
||||
const matchesSearch =
|
||||
searchQuery === "" || item.title.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesCategory = categoryFilter === "all" || item.category === categoryFilter;
|
||||
return matchesSearch && matchesCategory;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// if (sortBy === "popular") {
|
||||
// const aNum = parseInt(a.subscribers.replace(/[^\d]/g, ""));
|
||||
// const bNum = parseInt(b.subscribers.replace(/[^\d]/g, ""));
|
||||
// return bNum - aNum;
|
||||
// }
|
||||
// if (sortBy === "rating") return b.rating - a.rating;
|
||||
// if (sortBy === "videos") return b.videos - a.videos;
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<Meta
|
||||
title={$_('tags.title', { values: { tag: data.tag } })}
|
||||
description={$_('tags.description', { values: { tag: data.tag } })}
|
||||
title={$_("tags.title", { values: { tag: data.tag } })}
|
||||
description={$_("tags.description", { values: { tag: data.tag } })}
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -78,12 +71,12 @@ const filteredItems = $derived(() => {
|
||||
<h1
|
||||
class="text-5xl md:text-7xl font-bold mb-8 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
|
||||
>
|
||||
{$_('tags.title', { values: { tag: data.tag } })}
|
||||
{$_("tags.title", { values: { tag: data.tag } })}
|
||||
</h1>
|
||||
<p
|
||||
class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto"
|
||||
>
|
||||
{$_('tags.description', { values: { tag: data.tag } })}
|
||||
{$_("tags.description", { values: { tag: data.tag } })}
|
||||
</p>
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-col md:flex-row gap-4 max-w-4xl mx-auto">
|
||||
@@ -93,7 +86,7 @@ const filteredItems = $derived(() => {
|
||||
class="icon-[ri--search-line] absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
|
||||
></span>
|
||||
<Input
|
||||
placeholder={$_('tags.search_placeholder')}
|
||||
placeholder={$_("tags.search_placeholder")}
|
||||
bind:value={searchQuery}
|
||||
class="pl-10 bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
@@ -105,25 +98,19 @@ const filteredItems = $derived(() => {
|
||||
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
||||
>
|
||||
<span class="icon-[ri--filter-line] w-4 h-4 mr-2"></span>
|
||||
{categoryFilter === 'all'
|
||||
? $_('tags.categories.all')
|
||||
: categoryFilter === 'video'
|
||||
? $_('tags.categories.video')
|
||||
: categoryFilter === 'article'
|
||||
? $_('tags.categories.article')
|
||||
: $_('tags.categories.model')}
|
||||
{categoryFilter === "all"
|
||||
? $_("tags.categories.all")
|
||||
: categoryFilter === "video"
|
||||
? $_("tags.categories.video")
|
||||
: categoryFilter === "article"
|
||||
? $_("tags.categories.article")
|
||||
: $_("tags.categories.model")}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{$_('tags.categories.all')}</SelectItem>
|
||||
<SelectItem value="video"
|
||||
>{$_('tags.categories.video')}</SelectItem
|
||||
>
|
||||
<SelectItem value="article"
|
||||
>{$_('tags.categories.article')}</SelectItem
|
||||
>
|
||||
<SelectItem value="model"
|
||||
>{$_('tags.categories.model')}</SelectItem
|
||||
>
|
||||
<SelectItem value="all">{$_("tags.categories.all")}</SelectItem>
|
||||
<SelectItem value="video">{$_("tags.categories.video")}</SelectItem>
|
||||
<SelectItem value="article">{$_("tags.categories.article")}</SelectItem>
|
||||
<SelectItem value="model">{$_("tags.categories.model")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -139,7 +126,7 @@ const filteredItems = $derived(() => {
|
||||
>
|
||||
<div class="relative">
|
||||
<img
|
||||
src={getAssetUrl(item['image'] || item['avatar'], 'preview')}
|
||||
src={getAssetUrl(item["image"] || item["avatar"], "preview")}
|
||||
alt={item.title}
|
||||
class="w-full h-64 object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
@@ -158,9 +145,7 @@ const filteredItems = $derived(() => {
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3
|
||||
class="font-semibold text-lg mb-1 group-hover:text-primary transition-colors"
|
||||
>
|
||||
<h3 class="font-semibold text-lg mb-1 group-hover:text-primary transition-colors">
|
||||
{item.title}
|
||||
</h3>
|
||||
<!-- <div
|
||||
@@ -194,8 +179,8 @@ const filteredItems = $derived(() => {
|
||||
size="sm"
|
||||
class="flex-1 border-primary/20 hover:bg-primary/10"
|
||||
href={getUrlForItem(item)}
|
||||
>{$_('tags.view', {
|
||||
values: { category: item.category }
|
||||
>{$_("tags.view", {
|
||||
values: { category: item.category },
|
||||
})}</Button
|
||||
>
|
||||
<!-- <Button
|
||||
@@ -211,16 +196,16 @@ const filteredItems = $derived(() => {
|
||||
|
||||
{#if filteredItems().length === 0}
|
||||
<div class="text-center py-12">
|
||||
<p class="text-muted-foreground text-lg">{$_('tags.no_results')}</p>
|
||||
<p class="text-muted-foreground text-lg">{$_("tags.no_results")}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
searchQuery = '';
|
||||
categoryFilter = 'all';
|
||||
searchQuery = "";
|
||||
categoryFilter = "all";
|
||||
}}
|
||||
class="mt-4"
|
||||
>
|
||||
{$_('tags.clear_filters')}
|
||||
{$_("tags.clear_filters")}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -6,74 +6,99 @@ import { getGraphQLClient } from "$lib/api";
|
||||
const USER_PROFILE_QUERY = gql`
|
||||
query UserProfile($id: String!) {
|
||||
userProfile(id: $id) {
|
||||
id first_name last_name email description avatar date_created
|
||||
id
|
||||
first_name
|
||||
last_name
|
||||
email
|
||||
description
|
||||
avatar
|
||||
date_created
|
||||
}
|
||||
userGamification(userId: $id) {
|
||||
stats {
|
||||
user_id total_raw_points total_weighted_points
|
||||
recordings_count playbacks_count comments_count achievements_count rank
|
||||
user_id
|
||||
total_raw_points
|
||||
total_weighted_points
|
||||
recordings_count
|
||||
playbacks_count
|
||||
comments_count
|
||||
achievements_count
|
||||
rank
|
||||
}
|
||||
achievements {
|
||||
id code name description icon category date_unlocked progress required_count
|
||||
id
|
||||
code
|
||||
name
|
||||
description
|
||||
icon
|
||||
category
|
||||
date_unlocked
|
||||
progress
|
||||
required_count
|
||||
}
|
||||
recent_points {
|
||||
action
|
||||
points
|
||||
date_created
|
||||
recording_id
|
||||
}
|
||||
recent_points { action points date_created recording_id }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals, fetch }) => {
|
||||
// Guard: Redirect to login if not authenticated
|
||||
if (!locals.authStatus.authenticated) {
|
||||
throw redirect(302, "/login");
|
||||
}
|
||||
// Guard: Redirect to login if not authenticated
|
||||
if (!locals.authStatus.authenticated) {
|
||||
throw redirect(302, "/login");
|
||||
}
|
||||
|
||||
const { id } = params;
|
||||
const { id } = params;
|
||||
|
||||
try {
|
||||
const client = getGraphQLClient(fetch);
|
||||
const data = await client.request<{
|
||||
userProfile: {
|
||||
id: string;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
email: string;
|
||||
description: string | null;
|
||||
avatar: string | null;
|
||||
date_created: string;
|
||||
} | null;
|
||||
userGamification: {
|
||||
stats: {
|
||||
user_id: string;
|
||||
total_raw_points: number | null;
|
||||
total_weighted_points: number | null;
|
||||
recordings_count: number | null;
|
||||
playbacks_count: number | null;
|
||||
comments_count: number | null;
|
||||
achievements_count: number | null;
|
||||
rank: number;
|
||||
} | null;
|
||||
achievements: unknown[];
|
||||
recent_points: unknown[];
|
||||
} | null;
|
||||
}>(USER_PROFILE_QUERY, { id });
|
||||
try {
|
||||
const client = getGraphQLClient(fetch);
|
||||
const data = await client.request<{
|
||||
userProfile: {
|
||||
id: string;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
email: string;
|
||||
description: string | null;
|
||||
avatar: string | null;
|
||||
date_created: string;
|
||||
} | null;
|
||||
userGamification: {
|
||||
stats: {
|
||||
user_id: string;
|
||||
total_raw_points: number | null;
|
||||
total_weighted_points: number | null;
|
||||
recordings_count: number | null;
|
||||
playbacks_count: number | null;
|
||||
comments_count: number | null;
|
||||
achievements_count: number | null;
|
||||
rank: number;
|
||||
} | null;
|
||||
achievements: unknown[];
|
||||
recent_points: unknown[];
|
||||
} | null;
|
||||
}>(USER_PROFILE_QUERY, { id });
|
||||
|
||||
if (!data.userProfile) {
|
||||
throw redirect(404, "/");
|
||||
}
|
||||
if (!data.userProfile) {
|
||||
throw redirect(404, "/");
|
||||
}
|
||||
|
||||
const gamification = data.userGamification;
|
||||
const gamification = data.userGamification;
|
||||
|
||||
return {
|
||||
user: data.userProfile,
|
||||
stats: {
|
||||
comments_count: gamification?.stats?.comments_count || 0,
|
||||
likes_count: 0,
|
||||
},
|
||||
gamification,
|
||||
isOwnProfile: locals.authStatus.user?.id === id,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to load user profile:", error);
|
||||
throw redirect(404, "/");
|
||||
}
|
||||
return {
|
||||
user: data.userProfile,
|
||||
stats: {
|
||||
comments_count: gamification?.stats?.comments_count || 0,
|
||||
likes_count: 0,
|
||||
},
|
||||
gamification,
|
||||
isOwnProfile: locals.authStatus.user?.id === id,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to load user profile:", error);
|
||||
throw redirect(404, "/");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,229 +1,219 @@
|
||||
<script lang="ts">
|
||||
import { _, locale } from "svelte-i18n";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card, CardContent } from "$lib/components/ui/card";
|
||||
import { getAssetUrl } from "$lib/directus";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||
import { _, locale } from "svelte-i18n";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card, CardContent } from "$lib/components/ui/card";
|
||||
import { getAssetUrl } from "$lib/directus";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||
|
||||
const { data } = $props();
|
||||
const { data } = $props();
|
||||
|
||||
// Format user display name
|
||||
let displayName = $derived(
|
||||
data.user.first_name && data.user.last_name
|
||||
? `${data.user.first_name} ${data.user.last_name}`
|
||||
: data.user.email?.split("@")[0] || "User",
|
||||
);
|
||||
// Format user display name
|
||||
let displayName = $derived(
|
||||
data.user.first_name && data.user.last_name
|
||||
? `${data.user.first_name} ${data.user.last_name}`
|
||||
: data.user.email?.split("@")[0] || "User",
|
||||
);
|
||||
|
||||
// Format join date
|
||||
let joinDate = $derived(
|
||||
new Date(data.user.date_created).toLocaleDateString($locale!, {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}),
|
||||
);
|
||||
// Format join date
|
||||
let joinDate = $derived(
|
||||
new Date(data.user.date_created).toLocaleDateString($locale!, {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}),
|
||||
);
|
||||
</script>
|
||||
|
||||
<Meta
|
||||
title={displayName}
|
||||
description={data.user.description || `${displayName}'s profile`}
|
||||
image={data.user.avatar ? getAssetUrl(data.user.avatar, "thumbnail") : undefined}
|
||||
title={displayName}
|
||||
description={data.user.description || `${displayName}'s profile`}
|
||||
image={data.user.avatar ? getAssetUrl(data.user.avatar, "thumbnail") : undefined}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5"
|
||||
>
|
||||
<PeonyBackground />
|
||||
<div class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5">
|
||||
<PeonyBackground />
|
||||
|
||||
<div class="container mx-auto px-4 py-8 relative z-10">
|
||||
<!-- Profile Card -->
|
||||
<Card
|
||||
class="max-w-3xl mx-auto bg-card/90 backdrop-blur-sm border-border/50"
|
||||
>
|
||||
<CardContent class="p-6 md:p-8">
|
||||
<!-- Header with Back Button -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
href="/"
|
||||
class="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<span class="icon-[ri--arrow-left-line] w-4 h-4 mr-2"></span>
|
||||
{$_("common.back")}
|
||||
</Button>
|
||||
<div class="container mx-auto px-4 py-8 relative z-10">
|
||||
<!-- Profile Card -->
|
||||
<Card class="max-w-3xl mx-auto bg-card/90 backdrop-blur-sm border-border/50">
|
||||
<CardContent class="p-6 md:p-8">
|
||||
<!-- Header with Back Button -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
href="/"
|
||||
class="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<span class="icon-[ri--arrow-left-line] w-4 h-4 mr-2"></span>
|
||||
{$_("common.back")}
|
||||
</Button>
|
||||
|
||||
{#if data.isOwnProfile}
|
||||
<Button variant="outline" size="sm" href="/me">
|
||||
<span class="icon-[ri--settings-3-line] w-4 h-4 mr-2"></span>
|
||||
{$_("profile.edit")}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if data.isOwnProfile}
|
||||
<Button variant="outline" size="sm" href="/me">
|
||||
<span class="icon-[ri--settings-3-line] w-4 h-4 mr-2"></span>
|
||||
{$_("profile.edit")}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Profile Content -->
|
||||
<div class="flex flex-col md:flex-row gap-6 items-start">
|
||||
<!-- Avatar -->
|
||||
<div class="flex-shrink-0">
|
||||
{#if data.user.avatar}
|
||||
<img
|
||||
src={getAssetUrl(data.user.avatar, "thumbnail")}
|
||||
alt={displayName}
|
||||
class="w-24 h-24 rounded-2xl object-cover ring-4 ring-primary/20"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="w-24 h-24 rounded-2xl bg-primary/10 flex items-center justify-center ring-4 ring-primary/20"
|
||||
>
|
||||
<span class="text-3xl font-bold text-primary">
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Profile Content -->
|
||||
<div class="flex flex-col md:flex-row gap-6 items-start">
|
||||
<!-- Avatar -->
|
||||
<div class="flex-shrink-0">
|
||||
{#if data.user.avatar}
|
||||
<img
|
||||
src={getAssetUrl(data.user.avatar, "thumbnail")}
|
||||
alt={displayName}
|
||||
class="w-24 h-24 rounded-2xl object-cover ring-4 ring-primary/20"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="w-24 h-24 rounded-2xl bg-primary/10 flex items-center justify-center ring-4 ring-primary/20"
|
||||
>
|
||||
<span class="text-3xl font-bold text-primary">
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1">
|
||||
<h1 class="text-3xl font-bold mb-2">{displayName}</h1>
|
||||
<!-- Info -->
|
||||
<div class="flex-1">
|
||||
<h1 class="text-3xl font-bold mb-2">{displayName}</h1>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-2 text-muted-foreground mb-4"
|
||||
>
|
||||
<span class="icon-[ri--calendar-line] w-4 h-4"></span>
|
||||
<span
|
||||
>{$_("profile.member_since", {
|
||||
values: { date: joinDate },
|
||||
})}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-muted-foreground mb-4">
|
||||
<span class="icon-[ri--calendar-line] w-4 h-4"></span>
|
||||
<span
|
||||
>{$_("profile.member_since", {
|
||||
values: { date: joinDate },
|
||||
})}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if data.user.location}
|
||||
<div
|
||||
class="flex items-center gap-2 text-muted-foreground mb-4"
|
||||
>
|
||||
<span class="icon-[ri--map-pin-line] w-4 h-4"></span>
|
||||
<span>{data.user.location}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.user.location}
|
||||
<div class="flex items-center gap-2 text-muted-foreground mb-4">
|
||||
<span class="icon-[ri--map-pin-line] w-4 h-4"></span>
|
||||
<span>{data.user.location}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if data.user.description}
|
||||
<p class="text-muted-foreground mb-4">
|
||||
{data.user.description}
|
||||
</p>
|
||||
{/if}
|
||||
{#if data.user.description}
|
||||
<p class="text-muted-foreground mb-4">
|
||||
{data.user.description}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Statistics -->
|
||||
<div
|
||||
class="grid grid-cols-2 gap-4 pt-4 border-t border-border/50"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-primary">
|
||||
{data.stats.comments_count}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{$_("profile.comments")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-primary">
|
||||
{data.stats.likes_count}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{$_("profile.likes")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<!-- Statistics -->
|
||||
<div class="grid grid-cols-2 gap-4 pt-4 border-t border-border/50">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-primary">
|
||||
{data.stats.comments_count}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{$_("profile.comments")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-primary">
|
||||
{data.stats.likes_count}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{$_("profile.likes")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Gamification Card -->
|
||||
{#if data.gamification?.stats}
|
||||
<Card class="max-w-3xl mx-auto mt-6 bg-card/90 backdrop-blur-sm border-border/50">
|
||||
<CardContent class="p-6 md:p-8">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-2xl font-bold flex items-center gap-2">
|
||||
<span class="icon-[ri--trophy-line] w-6 h-6 text-primary"></span>
|
||||
{$_("gamification.stats")}
|
||||
</h2>
|
||||
<Button variant="outline" size="sm" href="/leaderboard">
|
||||
<span class="icon-[ri--bar-chart-line] w-4 h-4 mr-2"></span>
|
||||
{$_("gamification.leaderboard")}
|
||||
</Button>
|
||||
</div>
|
||||
<!-- Gamification Card -->
|
||||
{#if data.gamification?.stats}
|
||||
<Card class="max-w-3xl mx-auto mt-6 bg-card/90 backdrop-blur-sm border-border/50">
|
||||
<CardContent class="p-6 md:p-8">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-2xl font-bold flex items-center gap-2">
|
||||
<span class="icon-[ri--trophy-line] w-6 h-6 text-primary"></span>
|
||||
{$_("gamification.stats")}
|
||||
</h2>
|
||||
<Button variant="outline" size="sm" href="/leaderboard">
|
||||
<span class="icon-[ri--bar-chart-line] w-4 h-4 mr-2"></span>
|
||||
{$_("gamification.leaderboard")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="text-center p-4 rounded-lg bg-accent/10">
|
||||
<div class="text-3xl font-bold text-primary">
|
||||
{Math.round(data.gamification.stats.total_weighted_points)}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground mt-1">
|
||||
{$_("gamification.points")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center p-4 rounded-lg bg-accent/10">
|
||||
<div class="text-3xl font-bold text-primary">
|
||||
#{data.gamification.stats.rank}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground mt-1">
|
||||
{$_("gamification.rank")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center p-4 rounded-lg bg-accent/10">
|
||||
<div class="text-3xl font-bold text-primary">
|
||||
{data.gamification.stats.recordings_count}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground mt-1">
|
||||
{$_("gamification.recordings")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center p-4 rounded-lg bg-accent/10">
|
||||
<div class="text-3xl font-bold text-primary">
|
||||
{data.gamification.stats.playbacks_count}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground mt-1">
|
||||
{$_("gamification.plays")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="text-center p-4 rounded-lg bg-accent/10">
|
||||
<div class="text-3xl font-bold text-primary">
|
||||
{Math.round(data.gamification.stats.total_weighted_points)}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground mt-1">
|
||||
{$_("gamification.points")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center p-4 rounded-lg bg-accent/10">
|
||||
<div class="text-3xl font-bold text-primary">
|
||||
#{data.gamification.stats.rank}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground mt-1">
|
||||
{$_("gamification.rank")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center p-4 rounded-lg bg-accent/10">
|
||||
<div class="text-3xl font-bold text-primary">
|
||||
{data.gamification.stats.recordings_count}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground mt-1">
|
||||
{$_("gamification.recordings")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center p-4 rounded-lg bg-accent/10">
|
||||
<div class="text-3xl font-bold text-primary">
|
||||
{data.gamification.stats.playbacks_count}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground mt-1">
|
||||
{$_("gamification.plays")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Achievements -->
|
||||
{#if data.gamification.achievements?.length > 0}
|
||||
<div class="pt-6 border-t border-border/50">
|
||||
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<span class="icon-[ri--award-line] w-5 h-5 text-primary"></span>
|
||||
{$_("gamification.achievements")} ({data.gamification.achievements.length})
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
{#each data.gamification.achievements as achievement (achievement.id)}
|
||||
<div
|
||||
class="flex flex-col items-center gap-2 p-3 rounded-lg bg-accent/10 border border-border/30 hover:border-primary/50 transition-colors"
|
||||
title={achievement.description}
|
||||
>
|
||||
<span class="text-3xl">{achievement.icon || "🏆"}</span>
|
||||
<span class="text-xs font-medium text-center leading-tight">
|
||||
{achievement.name}
|
||||
</span>
|
||||
{#if achievement.date_unlocked}
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{new Date(achievement.date_unlocked).toLocaleDateString($locale)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="pt-6 border-t border-border/50 text-center text-muted-foreground">
|
||||
<span class="icon-[ri--trophy-line] w-8 h-8 mx-auto mb-2 opacity-50"></span>
|
||||
<p class="text-sm">No achievements unlocked yet</p>
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Achievements -->
|
||||
{#if data.gamification.achievements?.length > 0}
|
||||
<div class="pt-6 border-t border-border/50">
|
||||
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<span class="icon-[ri--award-line] w-5 h-5 text-primary"></span>
|
||||
{$_("gamification.achievements")} ({data.gamification.achievements.length})
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
{#each data.gamification.achievements as achievement (achievement.id)}
|
||||
<div
|
||||
class="flex flex-col items-center gap-2 p-3 rounded-lg bg-accent/10 border border-border/30 hover:border-primary/50 transition-colors"
|
||||
title={achievement.description}
|
||||
>
|
||||
<span class="text-3xl">{achievement.icon || "🏆"}</span>
|
||||
<span class="text-xs font-medium text-center leading-tight">
|
||||
{achievement.name}
|
||||
</span>
|
||||
{#if achievement.date_unlocked}
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{new Date(achievement.date_unlocked).toLocaleDateString($locale)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="pt-6 border-t border-border/50 text-center text-muted-foreground">
|
||||
<span class="icon-[ri--trophy-line] w-8 h-8 mx-auto mb-2 opacity-50"></span>
|
||||
<p class="text-sm">No achievements unlocked yet</p>
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getVideos } from "$lib/services";
|
||||
export async function load({ fetch }) {
|
||||
return {
|
||||
videos: await getVideos(fetch),
|
||||
};
|
||||
return {
|
||||
videos: await getVideos(fetch),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,62 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card, CardContent } from "$lib/components/ui/card";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "$lib/components/ui/select";
|
||||
import { getAssetUrl } from "$lib/directus";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import TimeAgo from "javascript-time-ago";
|
||||
import { formatVideoDuration } from "$lib/utils";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card, CardContent } from "$lib/components/ui/card";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
||||
import { getAssetUrl } from "$lib/directus";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import TimeAgo from "javascript-time-ago";
|
||||
import { formatVideoDuration } from "$lib/utils";
|
||||
|
||||
const timeAgo = new TimeAgo("en");
|
||||
const timeAgo = new TimeAgo("en");
|
||||
|
||||
let searchQuery = $state("");
|
||||
let sortBy = $state("recent");
|
||||
let categoryFilter = $state("all");
|
||||
let durationFilter = $state("all");
|
||||
let searchQuery = $state("");
|
||||
let sortBy = $state("recent");
|
||||
let categoryFilter = $state("all");
|
||||
let durationFilter = $state("all");
|
||||
|
||||
const { data } = $props();
|
||||
const { data } = $props();
|
||||
|
||||
const filteredVideos = $derived(() => {
|
||||
return data.videos
|
||||
.filter((video) => {
|
||||
const matchesSearch = video.title
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase());
|
||||
// ||
|
||||
// video.model.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesCategory = categoryFilter === "all";
|
||||
const matchesDuration =
|
||||
durationFilter === "all" ||
|
||||
(durationFilter === "short" && (video.movie_file?.duration ?? 0) < 10 * 60) ||
|
||||
(durationFilter === "medium" &&
|
||||
(video.movie_file?.duration ?? 0) >= 10 * 60 &&
|
||||
(video.movie_file?.duration ?? 0) < 20 * 60) ||
|
||||
(durationFilter === "long" && (video.movie_file?.duration ?? 0) >= 20 * 60);
|
||||
return matchesSearch && matchesCategory && matchesDuration;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (sortBy === "recent")
|
||||
return (
|
||||
new Date(b.upload_date).getTime() - new Date(a.upload_date).getTime()
|
||||
);
|
||||
if (sortBy === "most_liked")
|
||||
return (b.likes_count || 0) - (a.likes_count || 0);
|
||||
if (sortBy === "most_played")
|
||||
return (b.plays_count || 0) - (a.plays_count || 0);
|
||||
if (sortBy === "duration") return (b.movie_file?.duration ?? 0) - (a.movie_file?.duration ?? 0);
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
});
|
||||
const filteredVideos = $derived(() => {
|
||||
return data.videos
|
||||
.filter((video) => {
|
||||
const matchesSearch = video.title.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
// ||
|
||||
// video.model.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesCategory = categoryFilter === "all";
|
||||
const matchesDuration =
|
||||
durationFilter === "all" ||
|
||||
(durationFilter === "short" && (video.movie_file?.duration ?? 0) < 10 * 60) ||
|
||||
(durationFilter === "medium" &&
|
||||
(video.movie_file?.duration ?? 0) >= 10 * 60 &&
|
||||
(video.movie_file?.duration ?? 0) < 20 * 60) ||
|
||||
(durationFilter === "long" && (video.movie_file?.duration ?? 0) >= 20 * 60);
|
||||
return matchesSearch && matchesCategory && matchesDuration;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (sortBy === "recent")
|
||||
return new Date(b.upload_date).getTime() - new Date(a.upload_date).getTime();
|
||||
if (sortBy === "most_liked") return (b.likes_count || 0) - (a.likes_count || 0);
|
||||
if (sortBy === "most_played") return (b.plays_count || 0) - (a.plays_count || 0);
|
||||
if (sortBy === "duration")
|
||||
return (b.movie_file?.duration ?? 0) - (a.movie_file?.duration ?? 0);
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<Meta title={$_('videos.title')} description={$_('videos.description')} />
|
||||
<Meta title={$_("videos.title")} description={$_("videos.description")} />
|
||||
|
||||
<div
|
||||
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
||||
@@ -83,12 +73,12 @@ const filteredVideos = $derived(() => {
|
||||
<h1
|
||||
class="text-5xl md:text-7xl font-bold mb-8 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
|
||||
>
|
||||
{$_('videos.title')}
|
||||
{$_("videos.title")}
|
||||
</h1>
|
||||
<p
|
||||
class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto"
|
||||
>
|
||||
{$_('videos.description')}
|
||||
{$_("videos.description")}
|
||||
</p>
|
||||
|
||||
<!-- Filters -->
|
||||
@@ -99,7 +89,7 @@ const filteredVideos = $derived(() => {
|
||||
class="icon-[ri--search-line] absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
|
||||
></span>
|
||||
<Input
|
||||
placeholder={$_('videos.search_placeholder')}
|
||||
placeholder={$_("videos.search_placeholder")}
|
||||
bind:value={searchQuery}
|
||||
class="pl-10 bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
@@ -111,30 +101,22 @@ const filteredVideos = $derived(() => {
|
||||
class="w-full lg:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
||||
>
|
||||
<span class="icon-[ri--filter-line] w-4 h-4 mr-2"></span>
|
||||
{categoryFilter === 'all'
|
||||
? $_('videos.categories.all')
|
||||
: categoryFilter === 'romantic'
|
||||
? $_('videos.categories.romantic')
|
||||
: categoryFilter === 'artistic'
|
||||
? $_('videos.categories.artistic')
|
||||
: categoryFilter === 'intimate'
|
||||
? $_('videos.categories.intimate')
|
||||
: $_('videos.categories.performance')}
|
||||
{categoryFilter === "all"
|
||||
? $_("videos.categories.all")
|
||||
: categoryFilter === "romantic"
|
||||
? $_("videos.categories.romantic")
|
||||
: categoryFilter === "artistic"
|
||||
? $_("videos.categories.artistic")
|
||||
: categoryFilter === "intimate"
|
||||
? $_("videos.categories.intimate")
|
||||
: $_("videos.categories.performance")}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{$_('videos.categories.all')}</SelectItem>
|
||||
<SelectItem value="romantic"
|
||||
>{$_('videos.categories.romantic')}</SelectItem
|
||||
>
|
||||
<SelectItem value="artistic"
|
||||
>{$_('videos.categories.artistic')}</SelectItem
|
||||
>
|
||||
<SelectItem value="intimate"
|
||||
>{$_('videos.categories.intimate')}</SelectItem
|
||||
>
|
||||
<SelectItem value="performance"
|
||||
>{$_('videos.categories.performance')}</SelectItem
|
||||
>
|
||||
<SelectItem value="all">{$_("videos.categories.all")}</SelectItem>
|
||||
<SelectItem value="romantic">{$_("videos.categories.romantic")}</SelectItem>
|
||||
<SelectItem value="artistic">{$_("videos.categories.artistic")}</SelectItem>
|
||||
<SelectItem value="intimate">{$_("videos.categories.intimate")}</SelectItem>
|
||||
<SelectItem value="performance">{$_("videos.categories.performance")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -144,23 +126,19 @@ const filteredVideos = $derived(() => {
|
||||
class="w-full lg:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
||||
>
|
||||
<span class="icon-[ri--timer-2-line] w-4 h-4 mr-2"></span>
|
||||
{durationFilter === 'all'
|
||||
? $_('videos.duration.all')
|
||||
: durationFilter === 'short'
|
||||
? $_('videos.duration.short')
|
||||
: durationFilter === 'medium'
|
||||
? $_('videos.duration.medium')
|
||||
: $_('videos.duration.long')}
|
||||
{durationFilter === "all"
|
||||
? $_("videos.duration.all")
|
||||
: durationFilter === "short"
|
||||
? $_("videos.duration.short")
|
||||
: durationFilter === "medium"
|
||||
? $_("videos.duration.medium")
|
||||
: $_("videos.duration.long")}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{$_('videos.duration.all')}</SelectItem>
|
||||
<SelectItem value="short"
|
||||
>{$_('videos.duration.short')}</SelectItem
|
||||
>
|
||||
<SelectItem value="medium"
|
||||
>{$_('videos.duration.medium')}</SelectItem
|
||||
>
|
||||
<SelectItem value="long">{$_('videos.duration.long')}</SelectItem>
|
||||
<SelectItem value="all">{$_("videos.duration.all")}</SelectItem>
|
||||
<SelectItem value="short">{$_("videos.duration.short")}</SelectItem>
|
||||
<SelectItem value="medium">{$_("videos.duration.medium")}</SelectItem>
|
||||
<SelectItem value="long">{$_("videos.duration.long")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -169,28 +147,22 @@ const filteredVideos = $derived(() => {
|
||||
<SelectTrigger
|
||||
class="w-full lg:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
||||
>
|
||||
{sortBy === 'recent'
|
||||
? $_('videos.sort.recent')
|
||||
: sortBy === 'most_liked'
|
||||
? $_('videos.sort.most_liked')
|
||||
: sortBy === 'most_played'
|
||||
? $_('videos.sort.most_played')
|
||||
: sortBy === 'duration'
|
||||
? $_('videos.sort.duration')
|
||||
: $_('videos.sort.name')}
|
||||
{sortBy === "recent"
|
||||
? $_("videos.sort.recent")
|
||||
: sortBy === "most_liked"
|
||||
? $_("videos.sort.most_liked")
|
||||
: sortBy === "most_played"
|
||||
? $_("videos.sort.most_played")
|
||||
: sortBy === "duration"
|
||||
? $_("videos.sort.duration")
|
||||
: $_("videos.sort.name")}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="recent">{$_('videos.sort.recent')}</SelectItem>
|
||||
<SelectItem value="most_liked"
|
||||
>{$_('videos.sort.most_liked')}</SelectItem
|
||||
>
|
||||
<SelectItem value="most_played"
|
||||
>{$_('videos.sort.most_played')}</SelectItem
|
||||
>
|
||||
<SelectItem value="duration"
|
||||
>{$_('videos.sort.duration')}</SelectItem
|
||||
>
|
||||
<SelectItem value="name">{$_('videos.sort.name')}</SelectItem>
|
||||
<SelectItem value="recent">{$_("videos.sort.recent")}</SelectItem>
|
||||
<SelectItem value="most_liked">{$_("videos.sort.most_liked")}</SelectItem>
|
||||
<SelectItem value="most_played">{$_("videos.sort.most_played")}</SelectItem>
|
||||
<SelectItem value="duration">{$_("videos.sort.duration")}</SelectItem>
|
||||
<SelectItem value="name">{$_("videos.sort.name")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -206,7 +178,7 @@ const filteredVideos = $derived(() => {
|
||||
>
|
||||
<div class="relative">
|
||||
<img
|
||||
src={getAssetUrl(video.image, 'preview')}
|
||||
src={getAssetUrl(video.image, "preview")}
|
||||
alt={video.title}
|
||||
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
@@ -228,7 +200,7 @@ const filteredVideos = $derived(() => {
|
||||
<div
|
||||
class="absolute top-3 left-3 bg-gradient-to-r from-primary to-accent text-white text-xs px-2 py-1 rounded-full font-medium"
|
||||
>
|
||||
{$_('videos.premium')}
|
||||
{$_("videos.premium")}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -246,13 +218,12 @@ const filteredVideos = $derived(() => {
|
||||
<a
|
||||
class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
href={`/videos/${video.slug}`}
|
||||
aria-label={$_('videos.watch')}
|
||||
aria-label={$_("videos.watch")}
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 bg-primary/90 rounded-full flex flex-col items-center justify-center shadow-2xl"
|
||||
>
|
||||
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white"
|
||||
></span>
|
||||
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white"></span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -280,9 +251,7 @@ const filteredVideos = $derived(() => {
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div
|
||||
class="flex items-center justify-between text-sm text-muted-foreground mb-4"
|
||||
>
|
||||
<div class="flex items-center justify-between text-sm text-muted-foreground mb-4">
|
||||
<!-- <div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<EyeIcon class="w-4 h-4" />
|
||||
@@ -309,7 +278,7 @@ const filteredVideos = $derived(() => {
|
||||
href={`/videos/${video.slug}`}
|
||||
>
|
||||
<span class="icon-[ri--play-large-fill] w-4 h-4 mr-2"></span>
|
||||
{$_('videos.watch')}
|
||||
{$_("videos.watch")}
|
||||
</Button>
|
||||
<!-- <Button
|
||||
variant="ghost"
|
||||
@@ -327,18 +296,18 @@ const filteredVideos = $derived(() => {
|
||||
{#if filteredVideos().length === 0}
|
||||
<div class="text-center py-12">
|
||||
<p class="text-muted-foreground text-lg mb-4">
|
||||
{$_('videos.no_results')}
|
||||
{$_("videos.no_results")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
searchQuery = '';
|
||||
categoryFilter = 'all';
|
||||
durationFilter = 'all';
|
||||
searchQuery = "";
|
||||
categoryFilter = "all";
|
||||
durationFilter = "all";
|
||||
}}
|
||||
class="border-primary/20 hover:bg-primary/10"
|
||||
>
|
||||
{$_('videos.clear_filters')}
|
||||
{$_("videos.clear_filters")}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -2,26 +2,26 @@ import { error } from "@sveltejs/kit";
|
||||
import { getCommentsForVideo, getVideoBySlug, getVideoLikeStatus } from "$lib/services.js";
|
||||
|
||||
export async function load({ fetch, params, locals }) {
|
||||
const video = await getVideoBySlug(params.slug, fetch);
|
||||
const comments = await getCommentsForVideo(video.id, fetch);
|
||||
const video = await getVideoBySlug(params.slug, fetch);
|
||||
const comments = await getCommentsForVideo(video.id, fetch);
|
||||
|
||||
let likeStatus = { liked: false };
|
||||
if (locals.authStatus.authenticated) {
|
||||
try {
|
||||
likeStatus = await getVideoLikeStatus(video.id, fetch);
|
||||
} catch (error) {
|
||||
console.error("Failed to get like status:", error);
|
||||
}
|
||||
}
|
||||
let likeStatus = { liked: false };
|
||||
if (locals.authStatus.authenticated) {
|
||||
try {
|
||||
likeStatus = await getVideoLikeStatus(video.id, fetch);
|
||||
} catch (error) {
|
||||
console.error("Failed to get like status:", error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
video,
|
||||
comments,
|
||||
authStatus: locals.authStatus,
|
||||
likeStatus
|
||||
};
|
||||
} catch {
|
||||
error(404, "Video not found");
|
||||
}
|
||||
try {
|
||||
return {
|
||||
video,
|
||||
comments,
|
||||
authStatus: locals.authStatus,
|
||||
likeStatus,
|
||||
};
|
||||
} catch {
|
||||
error(404, "Video not found");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,151 +1,157 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card, CardContent } from "$lib/components/ui/card";
|
||||
import "media-chrome";
|
||||
import { getAssetUrl } from "$lib/directus";
|
||||
import TimeAgo from "javascript-time-ago";
|
||||
import { page } from "$app/state";
|
||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import * as Alert from "$lib/components/ui/alert";
|
||||
import Textarea from "$lib/components/ui/textarea/textarea.svelte";
|
||||
import Avatar from "$lib/components/ui/avatar/avatar.svelte";
|
||||
import { AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
|
||||
import { formatVideoDuration, getUserInitials } from "$lib/utils";
|
||||
import { invalidateAll } from "$app/navigation";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { createCommentForVideo, likeVideo, unlikeVideo, recordVideoPlay, updateVideoPlay } from "$lib/services";
|
||||
import SharingPopupButton from "$lib/components/sharing-popup/sharing-popup-button.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card, CardContent } from "$lib/components/ui/card";
|
||||
import "media-chrome";
|
||||
import { getAssetUrl } from "$lib/directus";
|
||||
import TimeAgo from "javascript-time-ago";
|
||||
import { page } from "$app/state";
|
||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import * as Alert from "$lib/components/ui/alert";
|
||||
import Textarea from "$lib/components/ui/textarea/textarea.svelte";
|
||||
import Avatar from "$lib/components/ui/avatar/avatar.svelte";
|
||||
import { AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
|
||||
import { formatVideoDuration, getUserInitials } from "$lib/utils";
|
||||
import { invalidateAll } from "$app/navigation";
|
||||
import { toast } from "svelte-sonner";
|
||||
import {
|
||||
createCommentForVideo,
|
||||
likeVideo,
|
||||
unlikeVideo,
|
||||
recordVideoPlay,
|
||||
updateVideoPlay,
|
||||
} from "$lib/services";
|
||||
import SharingPopupButton from "$lib/components/sharing-popup/sharing-popup-button.svelte";
|
||||
|
||||
const { data } = $props();
|
||||
const { data } = $props();
|
||||
|
||||
const timeAgo = new TimeAgo("en");
|
||||
let isLiked = $state(data.likeStatus.liked);
|
||||
let likesCount = $state(data.video.likes_count || 0);
|
||||
let isLikeLoading = $state(false);
|
||||
let isBookmarked = $state(false);
|
||||
let newComment = $state("");
|
||||
let showComments = $state(true);
|
||||
let isCommentLoading = $state(false);
|
||||
let isCommentError = $state(false);
|
||||
let commentError = $state();
|
||||
let currentPlayId = $state<string | null>(null);
|
||||
let lastTrackedTime = $state(0);
|
||||
const timeAgo = new TimeAgo("en");
|
||||
let isLiked = $state(data.likeStatus.liked);
|
||||
let likesCount = $state(data.video.likes_count || 0);
|
||||
let isLikeLoading = $state(false);
|
||||
let isBookmarked = $state(false);
|
||||
let newComment = $state("");
|
||||
let showComments = $state(true);
|
||||
let isCommentLoading = $state(false);
|
||||
let isCommentError = $state(false);
|
||||
let commentError = $state();
|
||||
let currentPlayId = $state<string | null>(null);
|
||||
let lastTrackedTime = $state(0);
|
||||
|
||||
const _relatedVideos = [
|
||||
{
|
||||
id: 2,
|
||||
title: "Sunset Dreams",
|
||||
thumbnail: "/placeholder.svg?size=wide",
|
||||
duration: "8:45",
|
||||
views: "1.8M",
|
||||
model: "Luna Belle",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Intimate Moments",
|
||||
thumbnail: "/placeholder.svg?size=wide",
|
||||
duration: "15:22",
|
||||
views: "3.2M",
|
||||
model: "Aria Divine",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Morning Light",
|
||||
thumbnail: "/placeholder.svg?size=wide",
|
||||
duration: "10:15",
|
||||
views: "956K",
|
||||
model: "Maya Starlight",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "Passionate Dance",
|
||||
thumbnail: "/placeholder.svg?size=wide",
|
||||
duration: "7:33",
|
||||
views: "1.4M",
|
||||
model: "Zara Moon",
|
||||
},
|
||||
];
|
||||
const _relatedVideos = [
|
||||
{
|
||||
id: 2,
|
||||
title: "Sunset Dreams",
|
||||
thumbnail: "/placeholder.svg?size=wide",
|
||||
duration: "8:45",
|
||||
views: "1.8M",
|
||||
model: "Luna Belle",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Intimate Moments",
|
||||
thumbnail: "/placeholder.svg?size=wide",
|
||||
duration: "15:22",
|
||||
views: "3.2M",
|
||||
model: "Aria Divine",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Morning Light",
|
||||
thumbnail: "/placeholder.svg?size=wide",
|
||||
duration: "10:15",
|
||||
views: "956K",
|
||||
model: "Maya Starlight",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "Passionate Dance",
|
||||
thumbnail: "/placeholder.svg?size=wide",
|
||||
duration: "7:33",
|
||||
views: "1.4M",
|
||||
model: "Zara Moon",
|
||||
},
|
||||
];
|
||||
|
||||
async function handleLike() {
|
||||
if (!data.authStatus.authenticated) {
|
||||
toast.error("Please sign in to like videos");
|
||||
return;
|
||||
}
|
||||
async function handleLike() {
|
||||
if (!data.authStatus.authenticated) {
|
||||
toast.error("Please sign in to like videos");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isLikeLoading = true;
|
||||
if (isLiked) {
|
||||
const result = await unlikeVideo(data.video.id);
|
||||
likesCount = result.likes_count;
|
||||
isLiked = false;
|
||||
toast.success("Removed from liked videos");
|
||||
} else {
|
||||
const result = await likeVideo(data.video.id);
|
||||
likesCount = result.likes_count;
|
||||
isLiked = true;
|
||||
toast.success("Added to liked videos");
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || "Failed to update like");
|
||||
} finally {
|
||||
isLikeLoading = false;
|
||||
}
|
||||
}
|
||||
try {
|
||||
isLikeLoading = true;
|
||||
if (isLiked) {
|
||||
const result = await unlikeVideo(data.video.id);
|
||||
likesCount = result.likes_count;
|
||||
isLiked = false;
|
||||
toast.success("Removed from liked videos");
|
||||
} else {
|
||||
const result = await likeVideo(data.video.id);
|
||||
likesCount = result.likes_count;
|
||||
isLiked = true;
|
||||
toast.success("Added to liked videos");
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || "Failed to update like");
|
||||
} finally {
|
||||
isLikeLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function _handleBookmark() {
|
||||
isBookmarked = !isBookmarked;
|
||||
}
|
||||
function _handleBookmark() {
|
||||
isBookmarked = !isBookmarked;
|
||||
}
|
||||
|
||||
async function handleComment(e: Event) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
isCommentLoading = true;
|
||||
isCommentError = false;
|
||||
commentError = "";
|
||||
await createCommentForVideo(data.video.id, newComment);
|
||||
toast.success($_("videos.toast_comment"));
|
||||
invalidateAll();
|
||||
newComment = "";
|
||||
showComments = true;
|
||||
} catch (err: any) {
|
||||
commentError = err.message;
|
||||
isCommentError = true;
|
||||
} finally {
|
||||
isCommentLoading = false;
|
||||
}
|
||||
}
|
||||
async function handleComment(e: Event) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
isCommentLoading = true;
|
||||
isCommentError = false;
|
||||
commentError = "";
|
||||
await createCommentForVideo(data.video.id, newComment);
|
||||
toast.success($_("videos.toast_comment"));
|
||||
invalidateAll();
|
||||
newComment = "";
|
||||
showComments = true;
|
||||
} catch (err: any) {
|
||||
commentError = err.message;
|
||||
isCommentError = true;
|
||||
} finally {
|
||||
isCommentLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePlay() {
|
||||
showPlayer = true;
|
||||
try {
|
||||
const result = await recordVideoPlay(data.video.id);
|
||||
currentPlayId = result.play_id;
|
||||
} catch (error) {
|
||||
console.error("Failed to record play:", error);
|
||||
}
|
||||
}
|
||||
async function handlePlay() {
|
||||
showPlayer = true;
|
||||
try {
|
||||
const result = await recordVideoPlay(data.video.id);
|
||||
currentPlayId = result.play_id;
|
||||
} catch (error) {
|
||||
console.error("Failed to record play:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTimeUpdate(e: Event) {
|
||||
const video = e.target as HTMLVideoElement;
|
||||
const currentTime = Math.floor(video.currentTime);
|
||||
function handleTimeUpdate(e: Event) {
|
||||
const video = e.target as HTMLVideoElement;
|
||||
const currentTime = Math.floor(video.currentTime);
|
||||
|
||||
// Update every 10 seconds
|
||||
if (currentPlayId && currentTime - lastTrackedTime >= 10) {
|
||||
lastTrackedTime = currentTime;
|
||||
const completed = video.currentTime >= video.duration * 0.9; // 90% watched = completed
|
||||
updateVideoPlay(data.video.id, currentPlayId, currentTime, completed).catch(console.error);
|
||||
}
|
||||
}
|
||||
// Update every 10 seconds
|
||||
if (currentPlayId && currentTime - lastTrackedTime >= 10) {
|
||||
lastTrackedTime = currentTime;
|
||||
const completed = video.currentTime >= video.duration * 0.9; // 90% watched = completed
|
||||
updateVideoPlay(data.video.id, currentPlayId, currentTime, completed).catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
let showPlayer = $state(false);
|
||||
let showPlayer = $state(false);
|
||||
</script>
|
||||
|
||||
<Meta
|
||||
title={data.video.title}
|
||||
description={data.video.description}
|
||||
image={getAssetUrl(data.video.image, 'medium')!}
|
||||
image={getAssetUrl(data.video.image, "medium")!}
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -157,16 +163,14 @@ let showPlayer = $state(false);
|
||||
<!-- Main Video Section -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Video Player -->
|
||||
<Card
|
||||
class="p-0 overflow-hidden bg-gradient-to-br from-card to-card/50"
|
||||
>
|
||||
<Card class="p-0 overflow-hidden bg-gradient-to-br from-card to-card/50">
|
||||
<div class="relative aspect-video bg-black">
|
||||
{#if showPlayer}
|
||||
<media-controller class="absolute inset-0 w-full h-full">
|
||||
<video
|
||||
slot="media"
|
||||
src={getAssetUrl(data.video.movie)}
|
||||
poster={getAssetUrl(data.video.image, 'preview')}
|
||||
poster={getAssetUrl(data.video.image, "preview")}
|
||||
autoplay
|
||||
ontimeupdate={handleTimeUpdate}
|
||||
class="block w-full"
|
||||
@@ -184,13 +188,11 @@ let showPlayer = $state(false);
|
||||
</media-controller>
|
||||
{:else}
|
||||
<img
|
||||
src={getAssetUrl(data.video.image, 'medium')}
|
||||
src={getAssetUrl(data.video.image, "medium")}
|
||||
alt={data.video.title}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 bg-black/20 flex items-center justify-center"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/20 flex items-center justify-center">
|
||||
<button
|
||||
class="cursor-pointer w-20 h-20 bg-primary/90 hover:bg-primary rounded-full flex flex-col items-center justify-center transition-colors shadow-2xl"
|
||||
aria-label={data.video.title}
|
||||
@@ -199,8 +201,7 @@ let showPlayer = $state(false);
|
||||
data-umami-event-id={data.video.movie}
|
||||
onclick={() => (showPlayer = true)}
|
||||
>
|
||||
<span class="icon-[ri--play-large-fill] w-10 h-10 text-white"
|
||||
></span>
|
||||
<span class="icon-[ri--play-large-fill] w-10 h-10 text-white"></span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@@ -214,20 +215,21 @@ let showPlayer = $state(false);
|
||||
<div
|
||||
class="w-20 h-20 bg-primary/90 hover:bg-primary rounded-full flex flex-col items-center justify-center transition-colors shadow-2xl"
|
||||
>
|
||||
<span class="icon-[ri--play-large-fill] w-10 h-10 text-white"
|
||||
></span>
|
||||
<span class="icon-[ri--play-large-fill] w-10 h-10 text-white"></span>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
class="absolute bottom-4 left-4 bg-black/70 text-white px-3 py-1 rounded font-medium"
|
||||
>
|
||||
{#if data.video.movie_file?.duration}{formatVideoDuration(data.video.movie_file.duration)}{/if}
|
||||
{#if data.video.movie_file?.duration}{formatVideoDuration(
|
||||
data.video.movie_file.duration,
|
||||
)}{/if}
|
||||
</div>
|
||||
{#if data.video.premium}
|
||||
<div
|
||||
class="absolute top-4 left-4 bg-gradient-to-r from-primary to-accent text-white px-3 py-1 rounded-full font-medium"
|
||||
>
|
||||
{$_('videos.premium')}
|
||||
{$_("videos.premium")}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -240,13 +242,12 @@ let showPlayer = $state(false);
|
||||
<h1 class="text-2xl md:text-3xl font-bold mb-2">
|
||||
{data.video.title}
|
||||
</h1>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-4 text-sm text-muted-foreground"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
|
||||
{#if data.video.plays_count}
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="icon-[ri--play-fill] w-4 h-4"></span>
|
||||
{data.video.plays_count} {data.video.plays_count === 1 ? 'play' : 'plays'}
|
||||
{data.video.plays_count}
|
||||
{data.video.plays_count === 1 ? "play" : "plays"}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-center gap-1">
|
||||
@@ -274,7 +275,7 @@ let showPlayer = $state(false);
|
||||
title: data.video.title,
|
||||
description: data.video.description,
|
||||
url: page.url.href,
|
||||
type: 'video' as const
|
||||
type: "video" as const,
|
||||
}}
|
||||
/>
|
||||
<!-- <Button
|
||||
@@ -296,7 +297,7 @@ let showPlayer = $state(false);
|
||||
<div class="flex items-center gap-4">
|
||||
<a href={`/models/${model.slug}`}>
|
||||
<img
|
||||
src={getAssetUrl(model.avatar as string, 'thumbnail')}
|
||||
src={getAssetUrl(model.avatar as string, "thumbnail")}
|
||||
alt={model.artist_name}
|
||||
class="w-12 h-12 rounded-full object-cover ring-2 ring-primary/20 hover:ring-primary/40 transition-all"
|
||||
/>
|
||||
@@ -307,14 +308,8 @@ let showPlayer = $state(false);
|
||||
class="font-semibold hover:text-primary transition-colors flex items-center gap-2"
|
||||
>
|
||||
{model.artist_name}
|
||||
<div
|
||||
class="w-4 h-4 bg-primary rounded-full flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
class="w-2.5 h-2.5 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<div class="w-4 h-4 bg-primary rounded-full flex items-center justify-center">
|
||||
<svg class="w-2.5 h-2.5 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
@@ -359,10 +354,10 @@ let showPlayer = $state(false);
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-semibold flex items-center gap-2">
|
||||
<span class="icon-[ri--message-line] w-5 h-5"></span>
|
||||
{$_('videos.comments', {
|
||||
{$_("videos.comments", {
|
||||
values: {
|
||||
comments: data.comments.length
|
||||
}
|
||||
comments: data.comments.length,
|
||||
},
|
||||
})}
|
||||
</h3>
|
||||
{#if data.comments.length > 0}
|
||||
@@ -372,7 +367,7 @@ let showPlayer = $state(false);
|
||||
class="cursor-pointer"
|
||||
onclick={() => (showComments = !showComments)}
|
||||
>
|
||||
{showComments ? $_('videos.hide') : $_('videos.show')}
|
||||
{showComments ? $_("videos.hide") : $_("videos.show")}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -380,11 +375,9 @@ let showPlayer = $state(false);
|
||||
<!-- Add Comment -->
|
||||
{#if data.authStatus.authenticated}
|
||||
<div class="flex gap-3 mb-6">
|
||||
<Avatar
|
||||
class="h-8 w-8 ring-2 ring-accent/20 transition-all duration-200"
|
||||
>
|
||||
<Avatar class="h-8 w-8 ring-2 ring-accent/20 transition-all duration-200">
|
||||
<AvatarImage
|
||||
src={getAssetUrl(data.authStatus.user!.avatar.id, 'mini')}
|
||||
src={getAssetUrl(data.authStatus.user!.avatar.id, "mini")}
|
||||
alt={data.authStatus.user!.artist_name}
|
||||
/>
|
||||
<AvatarFallback
|
||||
@@ -396,7 +389,7 @@ let showPlayer = $state(false);
|
||||
<form class="flex-1 space-y-4" onsubmit={handleComment}>
|
||||
<div class="space-y-2">
|
||||
<Textarea
|
||||
placeholder={$_('videos.add_comment_placeholder')}
|
||||
placeholder={$_("videos.add_comment_placeholder")}
|
||||
bind:value={newComment}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary resize-none"
|
||||
rows={2}
|
||||
@@ -406,9 +399,8 @@ let showPlayer = $state(false);
|
||||
<div class="grid w-full max-w-xl items-start gap-4">
|
||||
<Alert.Root variant="destructive">
|
||||
<Alert.Title class="items-center flex"
|
||||
><span
|
||||
class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"
|
||||
></span>{$_('videos.error')}</Alert.Title
|
||||
><span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"
|
||||
></span>{$_("videos.error")}</Alert.Title
|
||||
>
|
||||
<Alert.Description>{commentError}</Alert.Description>
|
||||
</Alert.Root>
|
||||
@@ -425,9 +417,9 @@ let showPlayer = $state(false);
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
|
||||
></div>
|
||||
{$_('videos.commenting')}
|
||||
{$_("videos.commenting")}
|
||||
{:else}
|
||||
{$_('videos.comment')}
|
||||
{$_("videos.comment")}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -445,10 +437,7 @@ let showPlayer = $state(false);
|
||||
class="h-8 w-8 ring-2 ring-accent/20 hover:ring-primary/40 transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
<AvatarImage
|
||||
src={getAssetUrl(
|
||||
comment.user_created.avatar as string,
|
||||
'mini'
|
||||
)}
|
||||
src={getAssetUrl(comment.user_created.avatar as string, "mini")}
|
||||
alt={comment.user_created.artist_name}
|
||||
/>
|
||||
<AvatarFallback
|
||||
@@ -460,19 +449,17 @@ let showPlayer = $state(false);
|
||||
</a>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<a href="/users/{comment.user_created.id}" class="font-medium text-sm hover:text-primary transition-colors"
|
||||
<a
|
||||
href="/users/{comment.user_created.id}"
|
||||
class="font-medium text-sm hover:text-primary transition-colors"
|
||||
>{comment.user_created.artist_name}</a
|
||||
>
|
||||
<span class="text-xs text-muted-foreground"
|
||||
>{timeAgo.format(
|
||||
new Date(comment.date_created)
|
||||
)}</span
|
||||
>{timeAgo.format(new Date(comment.date_created))}</span
|
||||
>
|
||||
</div>
|
||||
<p class="text-sm mb-2">{comment.comment}</p>
|
||||
<div
|
||||
class="flex items-center gap-4 text-xs text-muted-foreground"
|
||||
>
|
||||
<div class="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<!-- <button
|
||||
class="flex items-center gap-1 hover:text-primary transition-colors"
|
||||
>
|
||||
@@ -543,8 +530,9 @@ let showPlayer = $state(false);
|
||||
variant="outline"
|
||||
href="/videos"
|
||||
class="w-full border-primary/20 hover:bg-primary/10"
|
||||
><span class="icon-[ri--arrow-left-long-line] w-4 h-4 mr-1"
|
||||
></span>{$_('videos.back')}</Button
|
||||
><span class="icon-[ri--arrow-left-long-line] w-4 h-4 mr-1"></span>{$_(
|
||||
"videos.back",
|
||||
)}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user