A new start

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

View File

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

View File

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

View File

@@ -0,0 +1,78 @@
<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 { PUBLIC_UMAMI_ID } from "$env/static/public";
onMount(async () => {
await waitLocale();
});
let { children, data } = $props();
</script>
<svelte:head>
{#if import.meta.env.PROD && PUBLIC_UMAMI_ID}
<script
defer
src="https://umami.pivoine.art/script.js"
data-website-id={PUBLIC_UMAMI_ID}
></script>
{/if}
</svelte:head>
<AgeVerificationDialog />
<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>
<!-- 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>
<!-- 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>
<!-- Footer -->
<Footer />
</div>

View File

@@ -0,0 +1,7 @@
import TimeAgo from "javascript-time-ago";
import en from "javascript-time-ago/locale/en";
TimeAgo.addDefaultLocale(en);
export const prerender = false;
export const trailingSlash = "always";

View File

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

View File

@@ -0,0 +1,214 @@
<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";
const { data } = $props();
</script>
<Meta title={$_('home.hero.title')} description={$_('home.hero.description')} />
<!-- Hero Section -->
<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>
<!-- Content -->
<div class="relative z-10 container mx-auto px-4 text-center">
<div class="max-w-5xl mx-auto space-y-12">
<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')}
</h1>
<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">
<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="/videos"
>
<span class="icon-[ri--play-large-fill]"></span>
{$_('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
>
</div>
</div>
</div>
<!-- Floating Elements -->
<div
class="absolute top-20 left-10 w-20 h-20 bg-primary/20 rounded-full blur-xl animate-pulse"
></div>
<div
class="absolute bottom-20 right-10 w-32 h-32 bg-accent/20 rounded-full blur-xl animate-pulse delay-1000"
></div>
</section>
<!-- Featured Models -->
<section class="py-20 bg-card/50">
<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')}
</h2>
<p class="text-muted-foreground text-lg">
{$_('home.featured_models.description')}
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-lg mx-auto">
{#each data.models as model}
<Card
class="p-0 group hover:shadow-2xl hover:shadow-primary/20 transition-all duration-300 hover:-translate-y-2 bg-gradient-to-br from-card to-card/50 border-primary/20"
>
<CardContent class="p-6 text-center">
<div class="relative mb-4">
<img
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"
/>
<!-- <div
class="absolute -bottom-2 -right-2 bg-primary text-primary-foreground rounded-full w-8 h-8 flex items-center justify-center text-sm font-bold"
>
<HeartIcon class="w-4 h-4 fill-current" />
</div> -->
</div>
<h3 class="font-semibold text-lg mb-2">{model.artist_name}</h3>
<!-- <div
class="flex items-center justify-center gap-4 text-sm text-muted-foreground"
>
<div class="flex items-center gap-1">
<StarIcon class="w-4 h-4 text-yellow-500 fill-current" />
{model.rating}
</div>
<div>{model.videos} {$_("home.featured_models.videos")}</div>
</div> -->
<Button
variant="ghost"
size="sm"
class="mt-4 w-full group-hover:bg-primary/10"
href="/models/{model.slug}"
>{$_('home.featured_models.view_profile')}</Button
>
</CardContent>
</Card>
{/each}
</div>
</div>
</section>
<!-- Featured Videos -->
<section class="py-20">
<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')}
</h2>
<!-- <p class="text-muted-foreground text-lg">Most watched romantic content</p> -->
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-6xl mx-auto">
{#each data.videos as video}
<Card
class="p-0 group hover:shadow-2xl hover:shadow-accent/20 transition-all duration-300 hover:-translate-y-2 bg-gradient-to-br from-card to-card/50 border-accent/20 overflow-hidden"
>
<div class="relative">
<img
src={getAssetUrl(video.image, 'thumbnail')}
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"
>
{formatVideoDuration(video.movie.duration)}
</div>
<!-- <div
class="absolute top-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded-full"
>
{video.views}
{$_("home.trending.views")}
</div> -->
<div
class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<a
class="w-16 h-16 bg-primary/90 rounded-full flex items-center justify-center"
href="/videos/{video.slug}"
aria-label={video.title}
>
<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"
>
{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')}
</div>
</CardContent>
</Card>
{/each}
</div>
</div>
</section>
<!-- CTA 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-3xl mx-auto space-y-8">
<h2 class="text-3xl md:text-4xl font-bold">
{$_('home.featured_models.join_community')}
</h2>
<p class="text-lg text-muted-foreground">
{$_('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
>
<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
>
</div>
</div>
</div>
</section>

View File

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

View File

@@ -0,0 +1,312 @@
<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";
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 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"),
},
];
</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"
>
<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"
>
{$_("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}
<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}
<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>
</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}
<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>

View File

@@ -0,0 +1,357 @@
<script lang="ts">
import { _ } from "svelte-i18n";
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 = $state<Set<number>>(new Set());
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 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 Set(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"
>
<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"
>
{$_("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}
<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
>
</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}
<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}
<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>
</div>

View File

@@ -0,0 +1,273 @@
<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";
</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"
>
<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>
<!-- 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>
<h3 class="font-semibold mb-2">Business Address</h3>
<div class="text-muted-foreground">
<p>Sexy.Art Studios</p>
<p>5678 Hollywood Drive</p>
<p>Floor 12</p>
<p>Los Angeles, CA 90028</p>
<p>United States</p>
</div>
</div> -->
</div>
<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>
<!-- Management -->
<!-- <Card
class="mb-8 bg-gradient-to-br from-card to-card/50 border-primary/20"
>
<CardHeader>
<CardTitle>Management & Representation</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div>
<h3 class="font-semibold mb-2">Chief Executive Officer</h3>
<p class="text-muted-foreground">Alexandra Chen</p>
</div>
<div>
<h3 class="font-semibold mb-2">Chief Technology Officer</h3>
<p class="text-muted-foreground">David Kim</p>
</div>
<div>
<h3 class="font-semibold mb-2">Legal Representative</h3>
<p class="text-muted-foreground">
Johnson & Associates Law Firm
<br />
Attorney Michael Johnson
<br />
789 Legal Plaza, Suite 101
<br />
Los Angeles, CA 90210
<br />
Phone: +1 (555) 987-6543
</p>
</div>
</CardContent>
</Card> -->
<!-- Regulatory Information -->
<!-- <Card
class="mb-8 bg-gradient-to-br from-card to-card/50 border-primary/20"
>
<CardHeader>
<CardTitle>Regulatory Information</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div>
<h3 class="font-semibold mb-2">Industry Classification</h3>
<p class="text-muted-foreground">
NAICS Code: 518210 - Data Processing, Hosting, and Related
Services
<br />
SIC Code: 7374 - Computer Processing and Data Preparation Services
</p>
</div>
<div>
<h3 class="font-semibold mb-2">Content Rating</h3>
<p class="text-muted-foreground">
This platform contains adult content and is restricted to users 18
years and older.
<br />
RTA Label: RTA-5042-1996-1400-1577-RTA
</p>
</div>
<div>
<h3 class="font-semibold mb-2">Compliance</h3>
<p class="text-muted-foreground">
• 18 U.S.C. § 2257 Record-Keeping Requirements Compliance
Statement
<br />
• GDPR Compliance (EU General Data Protection Regulation)
<br />
• CCPA Compliance (California Consumer Privacy Act)
<br />
• COPPA Compliance (Children's Online Privacy Protection Act)
</p>
</div>
</CardContent>
</Card> -->
<!-- Technical Information -->
<!-- <Card
class="mb-8 bg-gradient-to-br from-card to-card/50 border-primary/20"
>
<CardHeader>
<CardTitle>Technical Information</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div>
<h3 class="font-semibold mb-2">Hosting Provider</h3>
<p class="text-muted-foreground">
CloudTech Solutions Inc.
<br />
Data Center Location: United States
<br />
Security Certification: SOC 2 Type II, ISO 27001
</p>
</div>
<div>
<h3 class="font-semibold mb-2">Content Delivery Network</h3>
<p class="text-muted-foreground">
Global CDN Services
<br />
SSL Certificate: Extended Validation (EV)
<br />
Encryption: TLS 1.3
</p>
</div>
</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>
</div>
</div>

View File

@@ -0,0 +1,437 @@
<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";
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"
>
<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>
<!-- 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>
<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>
<strong>Payment Information:</strong>
When you make purchases, we collect payment information through
secure third-party processors.
</p> -->
</div>
</div>
<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>
<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>
<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>
<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>
<!-- 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 />
<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 />
<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 />
<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 />
<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 />
<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>
<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>
<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>
<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>
<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>
<!-- 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 />
<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
our website, helping us improve performance and user
experience.
</p>
</div>
<div>
<h4 class="font-medium mb-2">Functionality Cookies</h4>
<p class="text-muted-foreground text-sm">
These cookies remember your preferences and settings to
provide a more personalized experience.
</p>
</div>
<div>
<h4 class="font-medium mb-2">Analytics Cookies</h4>
<p class="text-muted-foreground text-sm">
These cookies help us understand how our website is being
used and how we can improve it.
</p>
</div> -->
</div>
</div>
<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>
<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>
<!-- 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

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

View File

@@ -0,0 +1,224 @@
<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 { Checkbox } from "$lib/components/ui/checkbox";
import PeonyIcon from "$lib/components/icon/peony-icon.svelte";
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);
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();
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"
>
<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.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>
</div>
<!-- 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>
{#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>
<!-- Divider -->
<!-- <div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-border/50"></div>
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-card px-2 text-muted-foreground"
>{$_("auth.login.or_continue")}</span
>
</div>
</div> -->
<!-- Social Login -->
<!-- <div class="grid grid-cols-2 gap-4">
<Button
variant="outline"
class="border-primary/20 hover:bg-primary/10"
>
<svg class="w-4 h-4 mr-2" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Google
</Button>
<Button
variant="outline"
class="border-primary/20 hover:bg-primary/10"
>
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path
d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"
/>
</svg>
Facebook
</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>
</div>

View File

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

View File

@@ -0,0 +1,385 @@
<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 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");
const timeAgo = new TimeAgo("en");
const { data }: { data: { articles: Article[] } } = $props();
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);
});
});
</script>
<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"
>
<!-- 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="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"
>
{$_('magazine.title')}
</h1>
<p
class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto"
>
{$_('magazine.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={$_('magazine.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'
? $_('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
>
</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 === '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="popular"
>{$_("magazine.sort.popular")}</SelectItem
> -->
<SelectItem value="featured"
>{$_('magazine.sort.featured')}</SelectItem
>
<SelectItem value="name">{$_('magazine.sort.name')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</section>
<div class="container mx-auto px-4 py-12">
<!-- Featured Article -->
{#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')}
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')}
</div>
</div>
<CardContent class="p-8 flex flex-col justify-center">
<div class="mb-4">
<span
class="text-sm text-primary font-medium capitalize bg-primary/10 px-2 py-1 rounded-full"
>
{featuredArticle.category}
</span>
</div>
<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
>
</button>
</h2>
<p class="text-muted-foreground mb-6 text-lg leading-relaxed">
{featuredArticle.excerpt}
</p>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<img
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
>
<span></span>
<span
>{$_('magazine.read_time', {
values: {
time: calcReadingTime(featuredArticle.content)
}
})}
</span>
</div>
</div>
</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
>
</div>
</CardContent>
</div>
</Card>
{/if}
<!-- Articles Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{#each filteredArticles() as article}
<Card
class="p-0 group hover:shadow-2xl hover:shadow-primary/25 transition-all duration-300 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(article.image, 'preview')}
alt={article.title}
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300"
/>
<div
class="absolute group-hover:scale-105 transition-transform inset-0 bg-gradient-to-t from-black/40 to-transparent duration-300"
></div>
<!-- Category Badge -->
<div
class="absolute top-3 left-3 bg-primary/90 text-white text-xs px-2 py-1 rounded-full capitalize"
>
{article.category}
</div>
<!-- Featured Badge -->
{#if article.featured}
<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')}
</div>
{/if}
<!-- Views -->
<!-- <div
class="absolute bottom-3 right-3 text-white text-sm flex items-center gap-1"
>
<TrendingUpIcon class="w-4 h-4" />
{article.views}
</div> -->
</div>
<CardContent class="p-6">
<div class="mb-4">
<h3
class="font-semibold text-lg mb-2 group-hover:text-primary transition-colors line-clamp-2"
>
<a href="/magazine/{article.slug}">{article.title}</a>
</h3>
<p
class="text-muted-foreground text-sm line-clamp-3 leading-relaxed"
>
{article.excerpt}
</p>
</div>
<!-- Tags -->
<div class="flex flex-wrap gap-2 mb-4">
{#each article.tags.slice(0, 3) as tag}
<a
class="text-xs bg-primary/10 text-primary px-2 py-1 rounded-full"
href="/tags/{tag}"
>
#{tag}
</a>
{/each}
</div>
<!-- Author & Meta -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<img
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"
>
<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) }
})}
</div>
</div>
<!-- Read More Button -->
<Button
variant="outline"
size="sm"
class="w-full mt-4 border-primary/20 hover:bg-primary/10"
href="/magazine/{article.slug}"
>{$_('magazine.read_article')}</Button
>
</CardContent>
</Card>
{/each}
</div>
{#if filteredArticles().length === 0}
<div class="text-center py-12">
<p class="text-muted-foreground text-lg mb-4">
{$_('magazine.no_results')}
</p>
<Button
variant="outline"
onclick={() => {
searchQuery = '';
categoryFilter = 'all';
}}
class="border-primary/20 hover:bg-primary/10"
>
{$_('magazine.clear_filters')}
</Button>
</div>
{/if}
</div>
</div>

View File

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

View File

@@ -0,0 +1,232 @@
<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 SharingPopup from "$lib/components/sharing-popup/sharing-popup.svelte";
import Meta from "$lib/components/meta/meta.svelte";
import PeonyBackground from "$lib/components/background/peony-background.svelte";
import NewsletterSignup from "$lib/components/newsletter-signup/newsletter-signup-widget.svelte";
import SharingPopupButton from "$lib/components/sharing-popup/sharing-popup-button.svelte";
const { data } = $props();
const timeAgo = new TimeAgo("en");
</script>
<Meta
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"
>
<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
>
<!-- 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>
<!-- 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">
<UserIcon class="w-4 h-4" />
{data.article.views} views
</div> -->
</div>
<!-- Action Buttons -->
<!-- <div class="flex flex-wrap gap-3 mb-8">
<Button
variant={isLiked ? "default" : "outline"}
size="sm"
onclick={handleLike}
class="flex items-center gap-2 {isLiked
? 'bg-gradient-to-r from-primary to-accent'
: 'border-primary/20 hover:bg-primary/10'}"
>
<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>
<!-- 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>
<!-- 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}
<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">
{data.article.author.social.website}
</a> -->
</div>
{/if}
</div>
</div>
</CardContent>
</Card>
</article>
<!-- 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">
<MessageCircleIcon class="w-5 h-5 text-primary" />
Related Articles
</h3>
<div class="space-y-4">
{#each relatedArticles as related}
<button
onclick={() => onNavigate("article")}
class="flex gap-3 w-full text-left hover:bg-primary/5 p-3 rounded-lg transition-colors"
>
<img
src={related.image}
alt={related.title}
class="w-20 h-16 object-cover rounded"
/>
<div class="flex-1 min-w-0">
<h4 class="font-medium text-sm line-clamp-2 mb-2">
{related.title}
</h4>
<div
class="flex items-center gap-2 text-xs text-muted-foreground"
>
<span>{related.author}</span>
<span>•</span>
<span>{related.readTime}</span>
</div>
</div>
</button>
{/each}
</div>
</CardContent>
</Card>
-->
<!-- <NewsletterSignup email={data.authStatus.user?.email}/> -->
<!-- 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

@@ -0,0 +1,8 @@
import { getFolders } from "$lib/services";
export async function load({ locals, fetch }) {
return {
authStatus: locals.authStatus,
folders: await getFolders(fetch),
};
}

View File

@@ -0,0 +1,469 @@
<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 {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "$lib/components/ui/tabs";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import PeonyBackground from "$lib/components/background/peony-background.svelte";
import { onMount } from "svelte";
import { goto, invalidateAll } from "$app/navigation";
import { getAssetUrl, isModel } from "$lib/directus";
import * as Alert from "$lib/components/ui/alert";
import { toast } from "svelte-sonner";
import { removeFile, updateProfile, uploadFile } from "$lib/services";
import { Textarea } from "$lib/components/ui/textarea";
import Meta from "$lib/components/meta/meta.svelte";
import { TagsInput } from "$lib/components/ui/tags-input";
import {
displaySize,
FileDropZone,
MEGABYTE,
} from "$lib/components/ui/file-drop-zone";
import * as Avatar from "$lib/components/ui/avatar";
const { data } = $props();
let activeTab = $state("settings");
let firstName = $state(data.authStatus.user!.first_name);
let lastName = $state(data.authStatus.user!.last_name);
let artistName = $state(data.authStatus.user!.artist_name);
let description = $state(data.authStatus.user!.description);
let tags = $state(data.authStatus.user!.tags);
let email = $state(data.authStatus.user!.email);
let password = $state("");
let confirmPassword = $state("");
let showPassword = $state(false);
let showConfirmPassword = $state(false);
let isProfileLoading = $state(false);
let isProfileError = $state(false);
let profileError = $state("");
let isSecurityLoading = $state(false);
let isSecurityError = $state(false);
let securityError = $state("");
async function handleProfileSubmit(e: Event) {
e.preventDefault();
try {
isProfileLoading = true;
isProfileError = false;
profileError = "";
let avatarId = undefined;
if (!avatar?.id && data.authStatus.user!.avatar?.id) {
await removeFile(data.authStatus.user!.avatar.id);
}
if (avatar?.file) {
const formData = new FormData();
formData.append(
"folder",
data.folders.find((f) => f.name === "avatars")!.id,
);
formData.append("file", avatar.file!);
const result = await uploadFile(formData);
avatarId = result.id;
}
await updateProfile({
first_name: firstName,
last_name: lastName,
artist_name: artistName,
description,
tags,
avatar: avatarId,
});
toast.success($_("me.settings.toast_update"));
invalidateAll();
} catch (err: any) {
profileError = err.message;
isProfileError = true;
} finally {
isProfileLoading = false;
}
}
async function handleSecuritySubmit(e: Event) {
e.preventDefault();
try {
if (password !== confirmPassword) {
throw new Error($_("me.settings.password_error"));
}
isSecurityLoading = true;
isSecurityError = false;
securityError = "";
await updateProfile({
email,
password,
});
toast.success($_("me.settings.toast_update"));
invalidateAll();
password = confirmPassword = "";
} catch (err: any) {
securityError = err.message;
isSecurityError = true;
} finally {
isSecurityLoading = false;
}
}
let avatar = $state<{
id?: string;
url: string;
name: string;
size: number;
file?: File;
}>();
async function handleFilesUpload(files: File[]) {
const file = files[0];
avatar = {
name: file.name,
size: file.size,
url: URL.createObjectURL(file),
file,
};
}
async function handleAvatarRemove() {
if (avatar!.id) {
avatar = undefined;
} else {
setExistingAvatar();
}
}
function setExistingAvatar() {
if (data.authStatus.user!.avatar) {
avatar = {
id: data.authStatus.user!.avatar.id,
url: getAssetUrl(data.authStatus.user!.avatar.id, "mini")!,
name: data.authStatus.user!.artist_name,
size: data.authStatus.user!.avatar.filesize,
};
} else {
avatar = undefined;
}
}
onMount(() => {
if (data.authStatus.authenticated) {
setExistingAvatar();
return;
}
goto("/login");
});
</script>
<Meta
title={$_("me.title")}
description={$_("me.welcome", {
values: { name: data.authStatus.user!.artist_name },
})}
/>
<div
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
>
<PeonyBackground />
<div class="container mx-auto px-4 py-8">
<!-- Header -->
<div class="mb-8">
<div class="flex items-center justify-between mb-8">
<div>
<h1
class="text-4xl md:text-5xl font-bold bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent mb-3"
>
{$_("me.title")}
</h1>
<p class="text-lg text-muted-foreground">
{$_("me.welcome", {
values: { name: data.authStatus.user!.artist_name },
})}
</p>
</div>
{#if isModel(data.authStatus.user!)}
<Button
href={`/models/${data.authStatus.user!.slug}`}
variant="outline"
class="border-primary/20 hover:bg-primary/10"
>{$_("me.view_profile")}</Button
>
{/if}
</div>
</div>
<!-- Dashboard Tabs -->
<Tabs bind:value={activeTab} class="w-full">
<TabsList class="grid w-full grid-cols-4 max-w-2xl mb-8">
<TabsTrigger value="settings" class="flex items-center gap-2">
<span class="icon-[ri--settings-4-line] w-4 h-4"></span>
{$_("me.settings.title")}
</TabsTrigger>
</TabsList>
<!-- Settings Tab -->
<TabsContent value="settings" class="space-y-6">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Profile Settings -->
<Card class="bg-card/50 border-primary/20">
<CardHeader>
<CardTitle>{$_("me.settings.profile_title")}</CardTitle>
<CardDescription>{$_("me.settings.profile_subtitle")}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<form onsubmit={handleProfileSubmit} class="space-y-4">
<div class="space-y-2">
<Label for="avatar">{$_("me.settings.avatar")}</Label>
<FileDropZone
id="avatar"
fileCount={0}
maxFiles={1}
maxFileSize={2 * MEGABYTE}
onUpload={handleFilesUpload}
accept="image/*"
/>
{#if avatar}
<div class="flex place-items-center justify-between gap-2">
<div class="flex place-items-center gap-2">
<div class="relative size-9 overflow-clip">
<img
src={avatar.url}
alt={avatar.name}
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 overflow-clip"
/>
</div>
<div class="flex flex-col">
<span>{avatar.name}</span>
<span class="text-muted-foreground text-xs"
>{displaySize(avatar.size)}</span
>
</div>
</div>
<div class="gap-2">
<Button
variant="outline"
size="icon"
onclick={handleAvatarRemove}
class="cursor-pointer"
><span class="icon-[ri--delete-bin-line]"
></span></Button
>
</div>
</div>
{/if}
</div>
<!-- Name Fields -->
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="firstName">{$_("me.settings.first_name")}</Label
>
<Input
id="firstName"
placeholder={$_("me.settings.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">{$_("me.settings.last_name")}</Label>
<Input
id="lastName"
placeholder={$_("me.settings.last_name_placeholder")}
bind:value={lastName}
required
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
</div>
<div class="space-y-2">
<Label for="artistName">{$_("me.settings.artist_name")}</Label>
<Input
id="artistName"
placeholder={$_("me.settings.artist_name_placeholder")}
bind:value={artistName}
required
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-2">
<Label for="description">{$_("me.settings.description")}</Label>
<Textarea
id="description"
bind:value={description}
placeholder={$_("me.settings.description_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
rows={3}
/>
</div>
<div class="space-y-2">
<Label for="tags">{$_("me.settings.tags")}</Label>
<TagsInput
id="tags"
bind:value={tags}
placeholder={$_("me.settings.tags_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
{#if isProfileError}
<div class="grid w-full 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>{$_("me.settings.error")}</Alert.Title
>
<Alert.Description>{profileError}</Alert.Description>
</Alert.Root>
</div>
{/if}
<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={isProfileLoading}
>
{#if isProfileLoading}
<div
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
></div>
{$_("me.settings.updating_profile")}
{:else}
{$_("me.settings.update_profile")}
{/if}
</Button>
</form>
</CardContent>
</Card>
<!-- Privacy Settings -->
<Card class="bg-card/50 border-primary/20">
<CardHeader>
<CardTitle>{$_("me.settings.privacy_title")}</CardTitle>
<CardDescription>{$_("me.settings.privacy_subtitle")}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<form onsubmit={handleSecuritySubmit} class="space-y-4">
<!-- Email -->
<div class="space-y-2">
<Label for="email">{$_("me.settings.email")}</Label>
<Input
id="email"
type="email"
placeholder={$_("me.settings.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">{$_("me.settings.password")}</Label>
<div class="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder={$_("me.settings.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>
<!-- Confirm Password -->
<div class="space-y-2">
<Label for="confirmPassword"
>{$_("me.settings.confirm_password")}</Label
>
<div class="relative">
<Input
id="confirmPassword"
type={showConfirmPassword ? "text" : "password"}
placeholder={$_(
"me.settings.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="cursor-pointer 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 isSecurityError}
<div class="grid w-full 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>{$_("me.settings.error")}</Alert.Title
>
<Alert.Description>{securityError}</Alert.Description>
</Alert.Root>
</div>
{/if}
<Button
variant="outline"
type="submit"
class="cursor-pointer w-full border-primary/20 hover:bg-primary/10"
disabled={isSecurityLoading}
>
{#if isSecurityLoading}
<div
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
></div>
{$_("me.settings.updating_security")}
{:else}
{$_("me.settings.update_security")}
{/if}
</Button>
</form>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</div>
</div>

View File

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

View File

@@ -0,0 +1,267 @@
<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";
let searchQuery = $state("");
let sortBy = $state("popular");
let categoryFilter = $state("all");
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);
});
});
</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"
>
<!-- 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>
<!-- 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>
</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}
<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}
<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"
>
<div class="w-2 h-2 bg-white rounded-full animate-pulse"></div>
{$_("models.online")}
</div>
{/if} -->
<!-- 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
class="w-5 h-5 text-white group-hover/heart:fill-current"
/>
</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>
<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">
<StarIcon class="w-4 h-4 text-yellow-500 fill-current" />
{model.rating}
</div>
<div>{model.subscribers} followers</div>
</div> -->
</div>
</div>
<!-- Tags -->
<div class="flex flex-wrap gap-2 mb-4">
{#each model.tags as 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>
<!-- 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}
</div>
</div>

View File

@@ -0,0 +1,17 @@
import { error } from "@sveltejs/kit";
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");
}
}

View File

@@ -0,0 +1,291 @@
<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";
let activeTab = $state("videos");
const { data } = $props();
let images = $derived(
data.model.photos.map((p) => ({
...p,
url: getAssetUrl(p.id),
thumbnail: getAssetUrl(p.id, "thumbnail"),
})),
);
</script>
<Meta
title={data.model.artist_name}
description={data.model.description}
image={getAssetUrl(data.model.avatar, 'medium')!}
/>
<div
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
>
<PeonyBackground />
<!-- Cover Section -->
<div class="relative h-64 md:h-80 overflow-hidden bg-gradient-to-br from-primary to-accent">
{#if data.model.banner}
<img
src={getAssetUrl(data.model.banner.id, "banner")}
alt={$_(data.model.artist_name)}
class="w-full h-full object-cover"
/>
<div
class="absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent"
></div>
{/if}
<!-- Back Button -->
<Button
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
>
</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="flex flex-col md:flex-row gap-6">
<!-- Profile Image -->
<div class="relative">
<img
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"
/>
<!-- {#if data.model.isOnline}
<div
class="absolute -bottom-2 -right-2 bg-green-500 text-white text-xs px-2 py-1 rounded-full flex items-center gap-1"
>
<div class="w-2 h-2 bg-white rounded-full animate-pulse"></div>
Online
</div>
{/if} -->
</div>
<!-- Profile Info -->
<div class="flex-1">
<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">
<!-- <div class="flex items-center gap-1">
<StarIcon class="w-4 h-4 text-yellow-500 fill-current" />
{data.model.rating} rating
</div>
<div class="flex items-center gap-1">
<MapPinIcon class="w-4 h-4" />
{data.model.location}
</div> -->
<div class="flex items-center gap-1">
<span class="icon-[ri--calendar-line] w-4 h-4"></span>
{$_('models.joined', {
values: {
join_date: new Date(
data.model.join_date
).toLocaleDateString($locale!, {
day: 'numeric',
month: 'long',
year: 'numeric'
})
}
})}
</div>
</div>
<div class="prose text-muted-foreground mb-4 max-w-2xl">
{data.model.description}
</div>
<!-- Tags -->
<div class="flex flex-wrap gap-2">
{#each data.model.tags as tag}
<a
class="text-xs bg-primary/10 text-primary px-3 py-1 rounded-full"
href="/tags/{tag}"
>
#{tag}
</a>
{/each}
</div>
</div>
<!-- Action Buttons -->
<div class="flex flex-col gap-3 min-w-48">
<!--
<Button
onclick={() => (isFollowing = !isFollowing)}
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
<HeartIcon
class="w-4 h-4 mr-2 {isFollowing ? 'fill-current' : ''}"
/>
{isFollowing ? "Following" : "Follow"}
</Button>
<div class="grid grid-cols-2 gap-2">
<Button
variant="outline"
size="sm"
class="border-primary/20 hover:bg-primary/10"
>
<MessageCircleIcon class="w-4 h-4 mr-1" />
Message
</Button>
-->
<SharingPopupButton
content={{
title: data.model.artist_name,
description: data.model.description,
url: page.url,
type: 'model'
}}
/>
</div>
</div>
</div>
</div>
<!-- Stats -->
<div
class="grid grid-cols-2 md:grid-cols-5 gap-4 mt-6 pt-6 border-t border-border/50"
>
<!-- <div class="text-center">
<div class="text-2xl font-bold text-primary">
{data.model.subscribers}
</div>
<div class="text-sm text-muted-foreground">Followers</div>
</div> -->
<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>
<!-- <div class="text-center">
<div class="text-2xl font-bold text-primary">
{data.model.stats.totalViews}
</div>
<div class="text-sm text-muted-foreground">Total Views</div>
</div> -->
<!-- <div class="text-center">
<div class="text-2xl font-bold text-primary">
{data.model.stats.likes}
</div>
<div class="text-sm text-muted-foreground">Likes</div>
</div> -->
<div class="text-center">
<div class="text-2xl font-bold text-primary">
{data.commentsCount}
</div>
<div class="text-sm text-muted-foreground">
{$_('models.comments')}
</div>
</div>
</div>
</div>
</div>
<!-- Content Tabs -->
<div class="container mx-auto px-4 py-8">
<Tabs bind:value={activeTab} class="w-full">
<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')}
</TabsTrigger>
<TabsTrigger value="photos" class="flex items-center gap-2">
<span class="icon-[ri--camera-fill] w-4 h-4"></span>
{$_('models.photos')}
</TabsTrigger>
</TabsList>
<TabsContent value="videos">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{#each data.videos as video}
<Card
class="p-0 group hover:shadow-2xl hover:shadow-primary/20 transition-all duration-300 hover:-translate-y-2 bg-gradient-to-br from-card to-card/50 border-primary/20 overflow-hidden"
>
<div class="relative">
<img
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"
>
{formatVideoDuration(video.movie.duration)}
</div>
<!-- <div
class="absolute top-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded-full"
>
{video.views} views
</div> -->
<a
class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all group-hover:scale-105 duration-300"
href={`/videos/${video.slug}`}
aria-label={video.title}
>
<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>
</div>
</a>
</div>
<CardContent class="px-4 pb-4 pt-0">
<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"
>
<span>{video.views} views</span>
<div class="flex items-center gap-1">
<HeartIcon class="w-4 h-4" />
{video.likes}
</div>
</div> -->
</CardContent>
</Card>
{/each}
</div>
</TabsContent>
<TabsContent value="photos">
<ImageViewer {images} />
</TabsContent>
</Tabs>
</div>
</div>

View File

@@ -0,0 +1,22 @@
import {
LETTERSPACE_API_KEY,
LETTERSPACE_API_URL,
LETTERSPACE_LIST_ID,
} from "$env/static/private";
import { json } from "@sveltejs/kit";
export async function POST({ request, fetch }) {
const { email } = await request.json();
const lists = [LETTERSPACE_LIST_ID];
await fetch(`${LETTERSPACE_API_URL}/subscribers`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": LETTERSPACE_API_KEY,
},
body: JSON.stringify({ email, lists }),
});
return json({ email }, { status: 201 });
}

View File

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

View File

@@ -0,0 +1,127 @@
<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 PeonyIcon from "$lib/components/icon/peony-icon.svelte";
import * as Alert from "$lib/components/ui/alert";
import { goto } from "$app/navigation";
import { login, 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);
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();
onMount(() => {
if (!data.authStatus.authenticated) {
return;
}
goto("/");
});
</script>
<Meta
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"
>
<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>
</div>

View File

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

View File

@@ -0,0 +1,172 @@
<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 PeonyIcon from "$lib/components/icon/peony-icon.svelte";
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);
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();
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"
>
<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>
</div>

View File

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

View File

@@ -0,0 +1,210 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import Meta from "$lib/components/meta/meta.svelte";
import {
ButtplugClient,
ButtplugMessage,
ButtplugWasmClientConnector,
DeviceList,
SensorReadCmd,
StopDeviceCmd,
SensorReading,
ScalarCmd,
ScalarSubcommand,
ButtplugDeviceMessage,
ButtplugClientDevice,
SensorType,
} from "@sexy.pivoine.art/buttplug";
import Button from "$lib/components/ui/button/button.svelte";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import DeviceCard from "$lib/components/device-card/device-card.svelte";
import type { BluetoothDevice } from "$lib/types";
const client = new ButtplugClient("Sexy.Art");
let connected = $state(client.connected);
let scanning = $state(false);
let devices = $state<BluetoothDevice[]>([]);
async function init() {
const connector = new ButtplugWasmClientConnector();
// await ButtplugWasmClientConnector.activateLogging("info");
await client.connect(connector);
client.on("deviceadded", onDeviceAdded);
client.on("deviceremoved", (msg: ButtplugDeviceMessage) =>
devices.splice(msg.DeviceIndex, 1),
);
client.on("scanningfinished", () => (scanning = false));
connector.on("message", handleMessages);
connected = client.connected;
}
async function startScanning() {
await client.startScanning();
scanning = true;
}
async function onDeviceAdded(
msg: ButtplugDeviceMessage,
dev: ButtplugClientDevice,
) {
const device = convertDevice(dev);
devices.push(device);
const cmds = device.info.messageAttributes.SensorReadCmd;
cmds?.forEach(async (cmd) => {
await client.sendDeviceMessage(
{ index: device.info.index },
new SensorReadCmd(device.info.index, cmd.Index, cmd.SensorType),
);
});
}
async function handleMessages(messages: ButtplugMessage[]) {
messages.forEach(async (msg) => {
await handleMessage(msg);
});
}
async function handleMessage(msg: ButtplugMessage) {
if (msg instanceof SensorReading) {
const device = devices[msg.DeviceIndex];
if (msg.SensorType === SensorType.Battery) {
device.batteryLevel = msg.Data[0];
}
device.sensorValues[msg.Index] = msg.Data[0];
device.lastSeen = new Date();
} else if (msg instanceof DeviceList) {
devices = client.devices.map(convertDevice);
}
}
async function handleChange(
device: BluetoothDevice,
scalarIndex: number,
value: number,
) {
const vibrateCmd = device.info.messageAttributes.ScalarCmd[scalarIndex];
await client.sendDeviceMessage(
{ index: device.info.index },
new ScalarCmd(
[
new ScalarSubcommand(
vibrateCmd.Index,
(device.actuatorValues[scalarIndex] = value),
vibrateCmd.ActuatorType,
),
],
device.info.index,
),
);
}
async function handleStop(device: BluetoothDevice) {
await client.sendDeviceMessage(
{ index: device.info.index },
new StopDeviceCmd(device.info.index),
);
device.actuatorValues = device.info.messageAttributes.ScalarCmd.map(() => 0);
}
function convertDevice(device: ButtplugClientDevice): BluetoothDevice {
console.log(device);
return {
id: device.index as string,
name: device.name as string,
batteryLevel: 0,
isConnected: true,
lastSeen: new Date(),
sensorValues: device.messageAttributes.SensorReadCmd
? device.messageAttributes.SensorReadCmd.map(() => 0)
: [],
actuatorValues: device.messageAttributes.ScalarCmd.map(() => 0),
info: device,
};
}
const { data } = $props();
onMount(() => {
if (data.authStatus.authenticated) {
init();
return;
}
goto("/login");
});
</script>
<Meta title={$_("play.title")} description={$_("play.description")} />
<div
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>
<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"
>
{$_("play.title")}
</h1>
<p class="text-lg text-muted-foreground mb-10">
{$_("play.description")}
</p>
<div class="flex justify-center">
<Button
size="lg"
disabled={!connected || scanning}
onclick={startScanning}
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
{#if scanning}
<div
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
></div>
{$_("play.scanning")}
{:else}
{$_("play.scan")}
{/if}
</Button>
</div>
</div>
</div>
</div>
<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">
{#if devices}
{#each devices as device}
<DeviceCard
{device}
onChange={(scalarIndex, val) => handleChange(device, scalarIndex, val)}
onStop={() => handleStop(device)}
/>
{/each}
{/if}
</div>
{#if devices?.length === 0}
<div class="text-center py-12">
<p class="text-muted-foreground text-lg mb-4">
{$_("play.no_results")}
</p>
</div>
{/if}
</div>
</div>

View File

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

View File

@@ -0,0 +1,244 @@
<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 PeonyIcon from "$lib/components/icon/peony-icon.svelte";
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("");
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();
onMount(() => {
if (!data.authStatus.authenticated) {
return;
}
goto("/me");
});
</script>
<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"
>
<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.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>
</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>
<Input
id="firstName"
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>
<Input
id="lastName"
placeholder={$_('auth.signup.last_name_placeholder')}
bind:value={lastName}
required
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
</div>
<!-- Email -->
<div class="space-y-2">
<Label for="email">{$_('auth.signup.email')}</Label>
<Input
id="email"
type="email"
placeholder={$_('auth.signup.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.signup.password')}</Label>
<div class="relative">
<Input
id="password"
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"
/>
<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>
<!-- Confirm Password -->
<div class="space-y-2">
<Label for="confirmPassword"
>{$_('auth.signup.confirm_password')}</Label
>
<div class="relative">
<Input
id="confirmPassword"
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"
/>
<button
type="button"
onclick={() => (showConfirmPassword = !showConfirmPassword)}
class="cursor-pointer 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>
<!-- Terms Agreement -->
<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', {
values: {
terms: $_('auth.signup.terms_of_service'),
privacy: $_('auth.signup.privacy_policy')
}
})}
</Label>
</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.signup.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.signup.creating_account')}
{:else}
{$_('auth.signup.create_account')}
{/if}
</Button>
</form>
<!-- Sign In Link -->
<div class="text-center">
<p class="text-sm text-muted-foreground">
{$_('auth.signup.have_account')}
<a href="/login" class="text-primary hover:underline font-medium"
>{$_('auth.signup.sign_in_link')}</a
>
</p>
</div>
</CardContent>
</Card>
</div>
</div>

View File

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

View File

@@ -0,0 +1,11 @@
<script lang="ts">
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");
});
</script>

View File

@@ -0,0 +1,23 @@
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.
});
};

View File

@@ -0,0 +1,25 @@
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"] })),
);
};
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");
}
}

View File

@@ -0,0 +1,229 @@
<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 PeonyBackground from "$lib/components/background/peony-background.svelte";
import Meta from "$lib/components/meta/meta.svelte";
let searchQuery = $state("");
let categoryFilter = $state("all");
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}`;
}
}
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 } })}
/>
<div
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>
<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"
>
{$_('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 } })}
</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={$_('tags.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'
? $_('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
>
</SelectContent>
</Select>
</div>
</div>
</div>
</section>
<!-- Items 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 filteredItems() as item}
<Card
class="py-0 group hover:shadow-2xl hover:shadow-primary/25 transition-all duration-300 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(item['image'] || item['avatar'], 'preview')}
alt={item.title}
class="w-full h-64 object-cover group-hover:scale-105 transition-transform duration-300"
/>
<div
class="absolute group-hover:scale-105 transition-transform inset-0 bg-gradient-to-t from-black/40 to-transparent duration-300"
></div>
<!-- Category Badge -->
<div
class="absolute top-3 left-3 bg-primary/90 text-white text-xs px-2 py-1 rounded-full capitalize"
>
{item.category}
</div>
</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"
>
{item.title}
</h3>
<!-- <div
class="flex items-center gap-4 text-sm text-muted-foreground"
>
<div class="flex items-center gap-1">
<StarIcon class="w-4 h-4 text-yellow-500 fill-current" />
{model.rating}
</div>
<div>{model.subscribers} followers</div>
</div> -->
</div>
</div>
<!-- Tags -->
<div class="flex flex-wrap gap-2 mb-4">
{#each item.tags as tag}
<a
class="text-xs bg-primary/10 text-primary px-2 py-1 rounded-full"
href="/tags/{tag}"
>
{tag}
</a>
{/each}
</div>
<!-- Action Buttons -->
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
class="flex-1 border-primary/20 hover:bg-primary/10"
href={getUrlForItem(item)}
>{$_('tags.view', {
values: { category: item.category }
})}</Button
>
<!-- <Button
size="sm"
class="flex-1 bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>{$_("tags.follow")}</Button
> -->
</div>
</CardContent>
</Card>
{/each}
</div>
{#if filteredItems().length === 0}
<div class="text-center py-12">
<p class="text-muted-foreground text-lg">{$_('tags.no_results')}</p>
<Button
variant="outline"
onclick={() => {
searchQuery = '';
categoryFilter = 'all';
}}
class="mt-4"
>
{$_('tags.clear_filters')}
</Button>
</div>
{/if}
</div>
</div>

View File

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

View File

@@ -0,0 +1,350 @@
<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";
const timeAgo = new TimeAgo("en");
let searchQuery = $state("");
let sortBy = $state("trending");
let categoryFilter = $state("all");
let durationFilter = $state("all");
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.duration < 10 * 60) ||
(durationFilter === "medium" &&
video.movie.duration >= 10 * 60 &&
video.movie.duration < 20 * 60) ||
(durationFilter === "long" && video.movie.duration >= 20 * 60);
return matchesSearch && matchesCategory && matchesDuration;
})
.sort((a, b) => {
// if (sortBy === "trending")
// return (
// parseInt(b.views.replace(/[^\d]/g, "")) -
// parseInt(a.views.replace(/[^\d]/g, ""))
// );
if (sortBy === "recent")
return (
new Date(b.upload_date).getTime() - new Date(a.upload_date).getTime()
);
// if (sortBy === "popular")
// return (
// parseInt(b.likes.replace(/[^\d]/g, "")) -
// parseInt(a.likes.replace(/[^\d]/g, ""))
// );
if (sortBy === "duration") return b.movie.duration - a.movie.duration;
return a.title.localeCompare(b.title);
});
});
</script>
<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"
>
<!-- 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="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"
>
{$_('videos.title')}
</h1>
<p
class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto"
>
{$_('videos.description')}
</p>
<!-- Filters -->
<div class="flex flex-col lg:flex-row gap-4 max-w-6xl 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={$_('videos.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 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')}
</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
>
</SelectContent>
</Select>
<!-- Duration Filter -->
<Select type="single" bind:value={durationFilter}>
<SelectTrigger
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')}
</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>
</SelectContent>
</Select>
<!-- Sort -->
<Select type="single" bind:value={sortBy}>
<SelectTrigger
class="w-full lg:w-48 bg-background/50 border-primary/20 focus:border-primary"
>
{sortBy === 'trending'
? $_('videos.sort.trending')
: sortBy === 'recent'
? $_('videos.sort.recent')
: sortBy === 'popular'
? $_('videos.sort.popular')
: sortBy === 'duration'
? $_('videos.sort.duration')
: $_('videos.sort.name')}
</SelectTrigger>
<SelectContent>
<SelectItem value="trending"
>{$_('videos.sort.trending')}</SelectItem
>
<SelectItem value="recent">{$_('videos.sort.recent')}</SelectItem>
<SelectItem value="popular"
>{$_('videos.sort.popular')}</SelectItem
>
<SelectItem value="duration"
>{$_('videos.sort.duration')}</SelectItem
>
<SelectItem value="name">{$_('videos.sort.name')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</section>
<!-- Videos 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 filteredVideos() as video}
<Card
class="p-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(video.image, 'preview')}
alt={video.title}
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300"
/>
<!-- Overlay Gradient -->
<div
class="absolute inset-0 group-hover:scale-105 bg-gradient-to-t from-black/60 via-transparent to-black/20 duration-300"
></div>
<!-- Duration -->
<div
class="absolute bottom-3 left-3 bg-black/70 text-white text-sm px-2 py-1 rounded font-medium"
>
{formatVideoDuration(video.movie.duration)}
</div>
<!-- Premium Badge -->
{#if video.premium}
<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')}
</div>
{/if}
<!-- Views -->
<!-- <div
class="absolute top-3 right-3 bg-black/70 text-white text-xs px-2 py-1 rounded-full flex items-center gap-1"
>
<EyeIcon class="w-3 h-3" />
{video.views}
</div> -->
<!-- Play Overlay -->
<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')}
>
<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>
</div>
</a>
<!-- Model Info -->
<!-- <div class="absolute bottom-3 right-3 text-white text-sm">
<button
onclick={() => onNavigate("model")}
class="hover:text-primary transition-colors"
>
{video.model}
</button>
</div> -->
</div>
<CardContent class="p-6">
<div class="mb-3">
<h3
class="font-semibold text-lg mb-2 group-hover:text-primary transition-colors line-clamp-2"
>
{video.title}
</h3>
<p class="text-sm text-muted-foreground">
{timeAgo.format(new Date(video.upload_date))}
</p>
</div>
<!-- Stats -->
<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" />
{video.views}
</div>
<div class="flex items-center gap-1">
<HeartIcon class="w-4 h-4" />
{video.likes}
</div>
</div> -->
<!-- <span
class="capitalize bg-primary/10 text-primary px-2 py-1 rounded-full text-xs"
>
{video.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={`/videos/${video.slug}`}
>
<span class="icon-[ri--play-large-fill] w-4 h-4 mr-2"></span>
{$_('videos.watch')}
</Button>
<!-- <Button
variant="ghost"
size="sm"
class="px-3 hover:bg-primary/10"
>
<HeartIcon class="w-4 h-4" />
</Button> -->
</div>
</CardContent>
</Card>
{/each}
</div>
{#if filteredVideos().length === 0}
<div class="text-center py-12">
<p class="text-muted-foreground text-lg mb-4">
{$_('videos.no_results')}
</p>
<Button
variant="outline"
onclick={() => {
searchQuery = '';
categoryFilter = 'all';
durationFilter = 'all';
}}
class="border-primary/20 hover:bg-primary/10"
>
{$_('videos.clear_filters')}
</Button>
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,11 @@
import { error } from "@sveltejs/kit";
import { getCommentsForVideo, getVideoBySlug } 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);
try {
return { video, comments, authStatus: locals.authStatus };
} catch {
error(404, "Video not found");
}
}

View File

@@ -0,0 +1,491 @@
<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 } from "$lib/services";
import NewsletterSignup from "$lib/components/newsletter-signup/newsletter-signup-widget.svelte";
import SharingPopupButton from "$lib/components/sharing-popup/sharing-popup-button.svelte";
const timeAgo = new TimeAgo("en");
let isLiked = $state(false);
let isBookmarked = $state(false);
let newComment = $state("");
let showComments = $state(true);
let isCommentLoading = $state(false);
let isCommentError = $state(false);
let commentError = $state();
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",
},
];
function handleLike() {
isLiked = !isLiked;
}
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;
}
}
let showPlayer = $state(false);
const { data } = $props();
</script>
<Meta
title={data.video.title}
description={data.video.description}
image={getAssetUrl(data.video.image, 'medium')!}
/>
<div
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
>
<PeonyBackground />
<div class="container mx-auto px-4 py-8">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- 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"
>
<div class="relative aspect-video bg-black">
{#if showPlayer}
<media-controller>
<video
slot="media"
src={getAssetUrl(data.video.movie.id)}
poster={getAssetUrl(data.video.image, 'preview')}
autoplay
class="inline"
>
<track kind="captions" />
</video>
<media-control-bar>
<media-play-button></media-play-button>
<media-mute-button></media-mute-button>
<media-volume-range></media-volume-range>
<media-time-range></media-time-range>
<media-pip-button></media-pip-button>
<media-fullscreen-button></media-fullscreen-button>
</media-control-bar>
</media-controller>
{:else}
<img
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"
>
<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}
data-umami-event="play-video"
data-umami-event-title={data.video.title}
data-umami-event-id={data.video.movie.id}
onclick={() => (showPlayer = true)}
>
<span class="icon-[ri--play-large-fill] w-10 h-10 text-white"
></span>
</button>
</div>
<div
class="absolute bottom-4 left-4 bg-black/70 text-white px-3 py-1 rounded font-medium"
>
{formatVideoDuration(data.video.movie.duration)}
</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')}
</div>
{/if}
{/if}
</div>
</Card>
<!-- Video Info -->
<div class="space-y-4">
<div>
<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 items-center gap-1">
<EyeIcon class="w-4 h-4" />
{data.video.views} views
</div> -->
<div class="flex items-center gap-1">
<span class="icon-[ri--calendar-line] w-4 h-4"></span>
{timeAgo.format(new Date(data.video.upload_date))}
</div>
<!-- <span class="bg-primary/10 text-primary px-2 py-1 rounded-full">
{data.video.category}
</span> -->
</div>
</div>
<!-- Action Buttons -->
<div class="flex flex-wrap gap-3">
<!-- <Button
variant={isLiked ? "default" : "outline"}
onclick={handleLike}
class="flex items-center gap-2 {isLiked
? 'bg-gradient-to-r from-primary to-accent'
: 'border-primary/20 hover:bg-primary/10'}"
>
<ThumbsUpIcon class="w-4 h-4 {isLiked ? 'fill-current' : ''}" />
{data.video.likes}
</Button> -->
<SharingPopupButton
content={{
title: data.video.title,
description: data.video.description,
url: page.url.href,
type: 'video' as const
}}
/>
<!-- <Button
variant={isBookmarked ? "default" : "outline"}
onclick={handleBookmark}
class="flex items-center gap-2 {isBookmarked
? 'bg-gradient-to-r from-primary to-accent'
: 'border-primary/20 hover:bg-primary/10'}"
>
<BookmarkIcon
class="w-4 h-4 {isBookmarked ? 'fill-current' : ''}"
/>
Save
</Button> -->
</div>
<!-- Model Info -->
<div class="grid grid-cols-1 gap-4">
{#each data.video.models as model}
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<a href={`/models/${model.slug}`}>
<img
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"
/>
</a>
<div>
<a
href={`/models/${model.slug}`}
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"
>
<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"
clip-rule="evenodd"
/>
</svg>
</div>
</a>
<!-- <p class="text-sm text-muted-foreground">
{data.video.model.subscribers} subscribers
</p> -->
</div>
</div>
<!-- <Button
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>Subscribe</Button
> -->
</div>
{/each}
</div>
<!-- Description -->
<Card class="p-0 bg-card/50">
<CardContent class="p-4">
<p class="text-muted-foreground mb-4">{data.video.description}</p>
<div class="flex flex-wrap gap-2">
{#each data.video.tags as tag}
<a
class="text-xs bg-primary/10 text-primary px-2 py-1 rounded-full"
href="/tags/{tag}"
>
#{tag}
</a>
{/each}
</div>
</CardContent>
</Card>
<!-- Comments Section -->
<Card class="p-0 bg-card/50">
<CardContent class="p-4">
<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', {
values: {
comments: data.comments.length
}
})}
</h3>
{#if data.comments.length > 0}
<Button
variant="link"
size="sm"
class="cursor-pointer"
onclick={() => (showComments = !showComments)}
>
{showComments ? $_('videos.hide') : $_('videos.show')}
</Button>
{/if}
</div>
<!-- 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"
>
<AvatarImage
src={getAssetUrl(data.authStatus.user!.avatar.id, 'mini')}
alt={data.authStatus.user!.artist_name}
/>
<AvatarFallback
class="bg-gradient-to-br from-primary to-accent text-primary-foreground text-xs font-semibold transition-all duration-200"
>
{getUserInitials(data.authStatus.user!.artist_name)}
</AvatarFallback>
</Avatar>
<form class="flex-1 space-y-4" onsubmit={handleComment}>
<div class="space-y-2">
<Textarea
placeholder={$_('videos.add_comment_placeholder')}
bind:value={newComment}
class="bg-background/50 border-primary/20 focus:border-primary resize-none"
rows={2}
></Textarea>
</div>
{#if isCommentError}
<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
>
<Alert.Description>{commentError}</Alert.Description>
</Alert.Root>
</div>
{/if}
<div class="flex justify-end space">
<Button
size="sm"
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
disabled={!newComment.trim() || isCommentLoading}
type="submit"
>
{#if isCommentLoading}
<div
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
></div>
{$_('videos.commenting')}
{:else}
{$_('videos.comment')}
{/if}
</Button>
</div>
</form>
</div>
{/if}
{#if showComments}
<!-- Comments List -->
<div class="space-y-4">
{#each data.comments as comment}
<div class="flex gap-3">
<Avatar
class="h-8 w-8 ring-2 ring-accent/20 transition-all duration-200"
>
<AvatarImage
src={getAssetUrl(
comment.user_created.avatar as string,
'mini'
)}
alt={comment.user_created.artist_name}
/>
<AvatarFallback
class="bg-gradient-to-br from-primary to-accent text-primary-foreground text-xs font-semibold transition-all duration-200"
>
{getUserInitials(data.authStatus.user!.artist_name)}
</AvatarFallback>
</Avatar>
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-sm"
>{comment.user_created.artist_name}</span
>
<span class="text-xs text-muted-foreground"
>{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"
>
<!-- <button
class="flex items-center gap-1 hover:text-primary transition-colors"
>
<ThumbsUpIcon class="w-3 h-3" />
{comment.likes}
</button> -->
<!-- {#if comment.replies > 0}
<button
class="hover:text-primary transition-colors"
>
{comment.replies} replies
</button>
{/if} -->
</div>
</div>
</div>
{/each}
</div>
{/if}
</CardContent>
</Card>
</div>
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Related Videos -->
<!-- <Card class="bg-card/50">
<CardContent class="p-4">
<h3 class="font-semibold mb-4">Related Videos</h3>
<div class="space-y-4">
{#each relatedVideos as relatedVideo}
<button
onclick={() => onNavigate('video')}
class="flex gap-3 w-full text-left hover:bg-primary/5 p-2 rounded-lg transition-colors"
>
<div class="relative">
<img
src={relatedData.video.thumbnail}
alt={relatedData.video.title}
class="w-24 h-16 object-cover rounded"
/>
<div
class="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded"
>
{relatedData.video.duration}
</div>
</div>
<div class="flex-1 min-w-0">
<h4 class="font-medium text-sm line-clamp-2 mb-1">
{relatedData.video.title}
</h4>
<p class="text-xs text-muted-foreground">
{relatedData.video.model}
</p>
<p class="text-xs text-muted-foreground">
{relatedData.video.views} views
</p>
</div>
</button>
{/each}
</div>
</CardContent>
</Card> -->
<!-- <NewsletterSignup /> -->
<!-- Back to Videos -->
<Button
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
>
</div>
</div>
</div>
</div>