A new start
This commit is contained in:
123
packages/frontend/src/routes/+error.svelte
Normal file
123
packages/frontend/src/routes/+error.svelte
Normal 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>
|
||||
5
packages/frontend/src/routes/+layout.server.ts
Normal file
5
packages/frontend/src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export async function load({ locals }) {
|
||||
return {
|
||||
authStatus: locals.authStatus,
|
||||
};
|
||||
}
|
||||
78
packages/frontend/src/routes/+layout.svelte
Normal file
78
packages/frontend/src/routes/+layout.svelte
Normal 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>
|
||||
7
packages/frontend/src/routes/+layout.ts
Normal file
7
packages/frontend/src/routes/+layout.ts
Normal 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";
|
||||
7
packages/frontend/src/routes/+page.server.ts
Normal file
7
packages/frontend/src/routes/+page.server.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
214
packages/frontend/src/routes/+page.svelte
Normal file
214
packages/frontend/src/routes/+page.svelte
Normal 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>
|
||||
6
packages/frontend/src/routes/about/+page.server.ts
Normal file
6
packages/frontend/src/routes/about/+page.server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { getStats } from "$lib/services";
|
||||
export async function load({ fetch }) {
|
||||
return {
|
||||
stats: await getStats(fetch),
|
||||
};
|
||||
}
|
||||
312
packages/frontend/src/routes/about/+page.svelte
Normal file
312
packages/frontend/src/routes/about/+page.svelte
Normal 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>
|
||||
357
packages/frontend/src/routes/faq/+page.svelte
Normal file
357
packages/frontend/src/routes/faq/+page.svelte
Normal 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>
|
||||
273
packages/frontend/src/routes/imprint/+page.svelte
Normal file
273
packages/frontend/src/routes/imprint/+page.svelte
Normal 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>
|
||||
437
packages/frontend/src/routes/legal/+page.svelte
Normal file
437
packages/frontend/src/routes/legal/+page.svelte
Normal 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>
|
||||
5
packages/frontend/src/routes/login/+page.server.ts
Normal file
5
packages/frontend/src/routes/login/+page.server.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export async function load({ locals }) {
|
||||
return {
|
||||
authStatus: locals.authStatus,
|
||||
};
|
||||
}
|
||||
224
packages/frontend/src/routes/login/+page.svelte
Normal file
224
packages/frontend/src/routes/login/+page.svelte
Normal 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>
|
||||
6
packages/frontend/src/routes/magazine/+page.server.ts
Normal file
6
packages/frontend/src/routes/magazine/+page.server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { getArticles } from "$lib/services";
|
||||
export async function load({ fetch }) {
|
||||
return {
|
||||
articles: await getArticles(fetch),
|
||||
};
|
||||
}
|
||||
385
packages/frontend/src/routes/magazine/+page.svelte
Normal file
385
packages/frontend/src/routes/magazine/+page.svelte
Normal 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>
|
||||
12
packages/frontend/src/routes/magazine/[slug]/+page.server.ts
Normal file
12
packages/frontend/src/routes/magazine/[slug]/+page.server.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
232
packages/frontend/src/routes/magazine/[slug]/+page.svelte
Normal file
232
packages/frontend/src/routes/magazine/[slug]/+page.svelte
Normal 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>
|
||||
8
packages/frontend/src/routes/me/+page.server.ts
Normal file
8
packages/frontend/src/routes/me/+page.server.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { getFolders } from "$lib/services";
|
||||
|
||||
export async function load({ locals, fetch }) {
|
||||
return {
|
||||
authStatus: locals.authStatus,
|
||||
folders: await getFolders(fetch),
|
||||
};
|
||||
}
|
||||
469
packages/frontend/src/routes/me/+page.svelte
Normal file
469
packages/frontend/src/routes/me/+page.svelte
Normal 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>
|
||||
6
packages/frontend/src/routes/models/+page.server.ts
Normal file
6
packages/frontend/src/routes/models/+page.server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { getModels } from "$lib/services";
|
||||
export async function load({ fetch }) {
|
||||
return {
|
||||
models: await getModels(fetch),
|
||||
};
|
||||
}
|
||||
267
packages/frontend/src/routes/models/+page.svelte
Normal file
267
packages/frontend/src/routes/models/+page.svelte
Normal 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>
|
||||
17
packages/frontend/src/routes/models/[slug]/+page.server.ts
Normal file
17
packages/frontend/src/routes/models/[slug]/+page.server.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
291
packages/frontend/src/routes/models/[slug]/+page.svelte
Normal file
291
packages/frontend/src/routes/models/[slug]/+page.svelte
Normal 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>
|
||||
22
packages/frontend/src/routes/newsletter/+server.ts
Normal file
22
packages/frontend/src/routes/newsletter/+server.ts
Normal 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 });
|
||||
}
|
||||
5
packages/frontend/src/routes/password/+page.server.ts
Normal file
5
packages/frontend/src/routes/password/+page.server.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export async function load({ locals }) {
|
||||
return {
|
||||
authStatus: locals.authStatus,
|
||||
};
|
||||
}
|
||||
127
packages/frontend/src/routes/password/+page.svelte
Normal file
127
packages/frontend/src/routes/password/+page.svelte
Normal 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>
|
||||
12
packages/frontend/src/routes/password/reset/+page.server.ts
Normal file
12
packages/frontend/src/routes/password/reset/+page.server.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
172
packages/frontend/src/routes/password/reset/+page.svelte
Normal file
172
packages/frontend/src/routes/password/reset/+page.svelte
Normal 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>
|
||||
5
packages/frontend/src/routes/play/+page.server.ts
Normal file
5
packages/frontend/src/routes/play/+page.server.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export async function load({ locals }) {
|
||||
return {
|
||||
authStatus: locals.authStatus,
|
||||
};
|
||||
}
|
||||
210
packages/frontend/src/routes/play/+page.svelte
Normal file
210
packages/frontend/src/routes/play/+page.svelte
Normal 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>
|
||||
5
packages/frontend/src/routes/signup/+page.server.ts
Normal file
5
packages/frontend/src/routes/signup/+page.server.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export async function load({ locals }) {
|
||||
return {
|
||||
authStatus: locals.authStatus,
|
||||
};
|
||||
}
|
||||
244
packages/frontend/src/routes/signup/+page.svelte
Normal file
244
packages/frontend/src/routes/signup/+page.svelte
Normal 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>
|
||||
18
packages/frontend/src/routes/signup/verify/+page.server.ts
Normal file
18
packages/frontend/src/routes/signup/verify/+page.server.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
11
packages/frontend/src/routes/signup/verify/+page.svelte
Normal file
11
packages/frontend/src/routes/signup/verify/+page.svelte
Normal 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>
|
||||
23
packages/frontend/src/routes/sitemap.xml/+server.ts
Normal file
23
packages/frontend/src/routes/sitemap.xml/+server.ts
Normal 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.
|
||||
});
|
||||
};
|
||||
25
packages/frontend/src/routes/tags/[tag]/+page.server.ts
Normal file
25
packages/frontend/src/routes/tags/[tag]/+page.server.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
229
packages/frontend/src/routes/tags/[tag]/+page.svelte
Normal file
229
packages/frontend/src/routes/tags/[tag]/+page.svelte
Normal 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>
|
||||
6
packages/frontend/src/routes/videos/+page.server.ts
Normal file
6
packages/frontend/src/routes/videos/+page.server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { getVideos } from "$lib/services";
|
||||
export async function load({ fetch }) {
|
||||
return {
|
||||
videos: await getVideos(fetch),
|
||||
};
|
||||
}
|
||||
350
packages/frontend/src/routes/videos/+page.svelte
Normal file
350
packages/frontend/src/routes/videos/+page.svelte
Normal 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>
|
||||
11
packages/frontend/src/routes/videos/[slug]/+page.server.ts
Normal file
11
packages/frontend/src/routes/videos/[slug]/+page.server.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
491
packages/frontend/src/routes/videos/[slug]/+page.svelte
Normal file
491
packages/frontend/src/routes/videos/[slug]/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user