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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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, "/");
}
};

View File

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

View File

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

View File

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

View File

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

View File

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