Compare commits
10 Commits
798495c3d6
...
1c101406f6
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c101406f6 | |||
| cb7720ca9c | |||
| df099b2700 | |||
| 291f72381f | |||
| 1a2fab3e37 | |||
| 56b57486dc | |||
| a050e886cb | |||
| 519fd45d8d | |||
| 0592d27a15 | |||
| a38883e631 |
@@ -45,6 +45,7 @@ builder.mutationField("updateProfile", (t) =>
|
|||||||
artistName: t.arg.string(),
|
artistName: t.arg.string(),
|
||||||
description: t.arg.string(),
|
description: t.arg.string(),
|
||||||
tags: t.arg.stringList(),
|
tags: t.arg.stringList(),
|
||||||
|
avatar: t.arg.string(),
|
||||||
},
|
},
|
||||||
resolve: async (_root, args, ctx) => {
|
resolve: async (_root, args, ctx) => {
|
||||||
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
|
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
|
||||||
@@ -58,6 +59,7 @@ builder.mutationField("updateProfile", (t) =>
|
|||||||
if (args.description !== undefined && args.description !== null)
|
if (args.description !== undefined && args.description !== null)
|
||||||
updates.description = args.description;
|
updates.description = args.description;
|
||||||
if (args.tags !== undefined && args.tags !== null) updates.tags = args.tags;
|
if (args.tags !== undefined && args.tags !== null) updates.tags = args.tags;
|
||||||
|
if (args.avatar !== undefined) updates.avatar = args.avatar;
|
||||||
|
|
||||||
await ctx.db
|
await ctx.db
|
||||||
.update(users)
|
.update(users)
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { createYoga } from "graphql-yoga";
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { files } from "./db/schema/index";
|
import { files } from "./db/schema/index";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { existsSync } from "fs";
|
import { existsSync, mkdirSync } from "fs";
|
||||||
|
import { writeFile, rm } from "fs/promises";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { schema } from "./graphql/index";
|
import { schema } from "./graphql/index";
|
||||||
import { buildContext } from "./graphql/context";
|
import { buildContext } from "./graphql/context";
|
||||||
@@ -120,6 +121,54 @@ async function main() {
|
|||||||
return reply.sendFile(path.join(id, filename));
|
return reply.sendFile(path.join(id, filename));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Upload a file: POST /upload (multipart, requires session)
|
||||||
|
fastify.post("/upload", async (request, reply) => {
|
||||||
|
const token = request.cookies["session_token"];
|
||||||
|
if (!token) return reply.status(401).send({ error: "Unauthorized" });
|
||||||
|
|
||||||
|
const sessionData = await redis.get(`session:${token}`);
|
||||||
|
if (!sessionData) return reply.status(401).send({ error: "Unauthorized" });
|
||||||
|
const { id: userId } = JSON.parse(sessionData);
|
||||||
|
|
||||||
|
const data = await request.file();
|
||||||
|
if (!data) return reply.status(400).send({ error: "No file provided" });
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const filename = data.filename;
|
||||||
|
const mime_type = data.mimetype;
|
||||||
|
const dir = path.join(UPLOAD_DIR, id);
|
||||||
|
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
const buffer = await data.toBuffer();
|
||||||
|
await writeFile(path.join(dir, filename), buffer);
|
||||||
|
|
||||||
|
const [file] = await db
|
||||||
|
.insert(files)
|
||||||
|
.values({ id, filename, mime_type, filesize: buffer.byteLength, uploaded_by: userId })
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return reply.status(201).send(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete a file: DELETE /assets/:id (requires session)
|
||||||
|
fastify.delete("/assets/:id", async (request, reply) => {
|
||||||
|
const token = request.cookies["session_token"];
|
||||||
|
if (!token) return reply.status(401).send({ error: "Unauthorized" });
|
||||||
|
|
||||||
|
const sessionData = await redis.get(`session:${token}`);
|
||||||
|
if (!sessionData) return reply.status(401).send({ error: "Unauthorized" });
|
||||||
|
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const result = await db.select().from(files).where(eq(files.id, id)).limit(1);
|
||||||
|
if (!result[0]) return reply.status(404).send({ error: "File not found" });
|
||||||
|
|
||||||
|
await db.delete(files).where(eq(files.id, id));
|
||||||
|
const dir = path.join(UPLOAD_DIR, id);
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
return reply.status(200).send({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
fastify.get("/health", async (_request, reply) => {
|
fastify.get("/health", async (_request, reply) => {
|
||||||
return reply.send({ status: "ok", timestamp: new Date().toISOString() });
|
return reply.send({ status: "ok", timestamp: new Date().toISOString() });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -194,7 +194,7 @@
|
|||||||
--card-foreground: oklch(0.95 0.01 280);
|
--card-foreground: oklch(0.95 0.01 280);
|
||||||
--border: oklch(0.2 0.05 280);
|
--border: oklch(0.2 0.05 280);
|
||||||
--input: oklch(1 0 0 / 0.15);
|
--input: oklch(1 0 0 / 0.15);
|
||||||
--primary: oklch(0.65 0.25 320);
|
--primary: oklch(65.054% 0.25033 319.934);
|
||||||
--primary-foreground: oklch(0.98 0.01 320);
|
--primary-foreground: oklch(0.98 0.01 320);
|
||||||
--secondary: oklch(0.15 0.05 260);
|
--secondary: oklch(0.15 0.05 260);
|
||||||
--secondary-foreground: oklch(0.9 0.02 260);
|
--secondary-foreground: oklch(0.9 0.02 260);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@400..700&family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
import { logout } from "$lib/services";
|
import { logout } from "$lib/services";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { getAssetUrl } from "$lib/api";
|
import { getAssetUrl } from "$lib/api";
|
||||||
import LogoutButton from "../logout-button/logout-button.svelte";
|
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
|
||||||
|
import { getUserInitials } from "$lib/utils";
|
||||||
import Separator from "../ui/separator/separator.svelte";
|
import Separator from "../ui/separator/separator.svelte";
|
||||||
import BurgerMenuButton from "../burger-menu-button/burger-menu-button.svelte";
|
import BurgerMenuButton from "../burger-menu-button/burger-menu-button.svelte";
|
||||||
import Logo from "../logo/logo.svelte";
|
import Logo from "../logo/logo.svelte";
|
||||||
@@ -55,7 +56,7 @@
|
|||||||
href="/"
|
href="/"
|
||||||
class="flex w-full items-center gap-3 hover:scale-105 transition-all duration-300"
|
class="flex w-full items-center gap-3 hover:scale-105 transition-all duration-300"
|
||||||
>
|
>
|
||||||
<Logo hideName={true} />
|
<Logo />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Desktop Navigation -->
|
<!-- Desktop Navigation -->
|
||||||
@@ -125,15 +126,32 @@
|
|||||||
|
|
||||||
<Separator orientation="vertical" class="mx-1 h-6 bg-border/50" />
|
<Separator orientation="vertical" class="mx-1 h-6 bg-border/50" />
|
||||||
|
|
||||||
<LogoutButton
|
<a href="/me" class="flex items-center gap-2 px-1 hover:opacity-80 transition-opacity">
|
||||||
user={{
|
<Avatar class="h-7 w-7 ring-2 ring-primary/20">
|
||||||
name:
|
<AvatarImage
|
||||||
authStatus.user!.artist_name || authStatus.user!.email.split("@")[0] || "User",
|
src={getAssetUrl(authStatus.user!.avatar, "mini")!}
|
||||||
avatar: getAssetUrl(authStatus.user!.avatar, "mini")!,
|
alt={authStatus.user!.artist_name || authStatus.user!.email}
|
||||||
email: authStatus.user!.email,
|
/>
|
||||||
}}
|
<AvatarFallback
|
||||||
onLogout={handleLogout}
|
class="bg-gradient-to-br from-primary to-accent text-primary-foreground text-xs font-semibold"
|
||||||
/>
|
>
|
||||||
|
{getUserInitials(authStatus.user!.artist_name || authStatus.user!.email)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span class="text-sm font-medium text-foreground/90 max-w-24 truncate">
|
||||||
|
{authStatus.user!.artist_name || authStatus.user!.email.split("@")[0]}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8 rounded-full text-foreground hover:text-destructive hover:bg-destructive/10"
|
||||||
|
onclick={handleLogout}
|
||||||
|
title={$_("header.logout")}
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--logout-circle-r-line] h-4 w-4"></span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -173,22 +191,46 @@
|
|||||||
inert={!isMobileMenuOpen || undefined}
|
inert={!isMobileMenuOpen || undefined}
|
||||||
>
|
>
|
||||||
<!-- Panel header -->
|
<!-- Panel header -->
|
||||||
<div class="flex items-center px-5 h-16 shrink-0 border-b border-border/30">
|
<div class="flex items-center gap-3 px-5 h-16 shrink-0 border-b border-border/30">
|
||||||
<Logo hideName={true} />
|
<Logo />
|
||||||
|
<span
|
||||||
|
class="text-xl font-extrabold bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent"
|
||||||
|
>
|
||||||
|
{$_("brand.name")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 py-6 px-5 space-y-6">
|
<div class="flex-1 py-6 px-5 space-y-6">
|
||||||
<!-- User logout slider -->
|
<!-- User card -->
|
||||||
{#if authStatus.authenticated}
|
{#if authStatus.authenticated}
|
||||||
<LogoutButton
|
<div class="flex items-center gap-3 rounded-xl border border-border/40 bg-card/50 px-4 py-3">
|
||||||
user={{
|
<Avatar class="h-10 w-10 ring-2 ring-primary/20 shrink-0">
|
||||||
name: authStatus.user!.artist_name || authStatus.user!.email.split("@")[0] || "User",
|
<AvatarImage
|
||||||
avatar: getAssetUrl(authStatus.user!.avatar, "mini")!,
|
src={getAssetUrl(authStatus.user!.avatar, "mini")!}
|
||||||
email: authStatus.user!.email,
|
alt={authStatus.user!.artist_name || authStatus.user!.email}
|
||||||
}}
|
/>
|
||||||
onLogout={handleLogout}
|
<AvatarFallback
|
||||||
class="w-full"
|
class="bg-gradient-to-br from-primary to-accent text-primary-foreground text-sm font-semibold"
|
||||||
/>
|
>
|
||||||
|
{getUserInitials(authStatus.user!.artist_name || authStatus.user!.email)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div class="flex flex-col min-w-0 flex-1">
|
||||||
|
<span class="text-sm font-semibold text-foreground truncate">
|
||||||
|
{authStatus.user!.artist_name || authStatus.user!.email.split("@")[0]}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-muted-foreground truncate">{authStatus.user!.email}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8 rounded-full text-muted-foreground hover:text-destructive hover:bg-destructive/10 shrink-0"
|
||||||
|
onclick={handleLogout}
|
||||||
|
title={$_("header.logout")}
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--logout-circle-r-line] h-4 w-4"></span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
|
|||||||
@@ -1,21 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { _ } from "svelte-i18n";
|
|
||||||
import SexyIcon from "../icon/icon.svelte";
|
import SexyIcon from "../icon/icon.svelte";
|
||||||
|
|
||||||
const { hideName = false } = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative">
|
<SexyIcon class="w-12 h-12" />
|
||||||
<SexyIcon class="w-8 h-8 text-primary" />
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class={`logo text-3xl text-foreground opacity-90 tracking-wide font-extrabold drop-shadow-x ${hideName ? "hidden sm:inline-block" : ""}`}
|
|
||||||
>
|
|
||||||
{$_("brand.name")}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.logo {
|
|
||||||
font-family: "Dancing Script", cursive;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { title, description, children }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="relative py-12 md:py-20 overflow-hidden">
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-b from-primary/12 via-accent/6 to-transparent"></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-6 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{#if description}
|
||||||
|
<p class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -50,7 +50,7 @@ export default {
|
|||||||
account: "Account",
|
account: "Account",
|
||||||
},
|
},
|
||||||
brand: {
|
brand: {
|
||||||
name: "SexyArt",
|
name: "Sexy",
|
||||||
tagline: "Where Love Meets Artistry",
|
tagline: "Where Love Meets Artistry",
|
||||||
description:
|
description:
|
||||||
"The premier destination for artistic adult content, intimate storytelling, and creative expression through video and magazine content.",
|
"The premier destination for artistic adult content, intimate storytelling, and creative expression through video and magazine content.",
|
||||||
@@ -310,7 +310,7 @@ export default {
|
|||||||
back: "Back to Videos",
|
back: "Back to Videos",
|
||||||
},
|
},
|
||||||
magazine: {
|
magazine: {
|
||||||
title: "SexyArt Magazine",
|
title: "Sexy Magazine",
|
||||||
description:
|
description:
|
||||||
"Insights, stories, and inspiration from the world of love, art, and intimate expression",
|
"Insights, stories, and inspiration from the world of love, art, and intimate expression",
|
||||||
search_placeholder: "Search articles...",
|
search_placeholder: "Search articles...",
|
||||||
@@ -387,7 +387,7 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
about: {
|
about: {
|
||||||
title: "About SexyArt",
|
title: "About Sexy",
|
||||||
subtitle:
|
subtitle:
|
||||||
"Where passion meets artistry, and intimate storytelling becomes a celebration of human connection.",
|
"Where passion meets artistry, and intimate storytelling becomes a celebration of human connection.",
|
||||||
join_community: "Join Our Community",
|
join_community: "Join Our Community",
|
||||||
@@ -403,11 +403,11 @@ export default {
|
|||||||
subtitle:
|
subtitle:
|
||||||
"Born from a vision to transform how intimate content is created, shared, and appreciated",
|
"Born from a vision to transform how intimate content is created, shared, and appreciated",
|
||||||
description_part1:
|
description_part1:
|
||||||
"SexyArt was founded in 2019 with a simple yet powerful mission: to create a platform where intimate content could be appreciated as an art form, where creators could express their authentic selves, and where viewers could connect with content that celebrates love, passion, and human connection.",
|
"Sexy was founded in 2019 with a simple yet powerful mission: to create a platform where intimate content could be appreciated as an art form, where creators could express their authentic selves, and where viewers could connect with content that celebrates love, passion, and human connection.",
|
||||||
description_part2:
|
description_part2:
|
||||||
"We recognized that the adult content industry needed a platform that prioritized artistic expression, creator empowerment, and community building. Our founders, coming from backgrounds in photography, digital media, and community management, set out to build something different.",
|
"We recognized that the adult content industry needed a platform that prioritized artistic expression, creator empowerment, and community building. Our founders, coming from backgrounds in photography, digital media, and community management, set out to build something different.",
|
||||||
description_part3:
|
description_part3:
|
||||||
"Today, SexyArt is home to hundreds of talented creators and thousands of passionate community members who share our vision of elevating intimate content to new artistic heights.",
|
"Today, Sexy is home to hundreds of talented creators and thousands of passionate community members who share our vision of elevating intimate content to new artistic heights.",
|
||||||
},
|
},
|
||||||
values: {
|
values: {
|
||||||
title: "Our Values",
|
title: "Our Values",
|
||||||
@@ -447,7 +447,7 @@ export default {
|
|||||||
image: "/img/valknar.gif",
|
image: "/img/valknar.gif",
|
||||||
bio: "DJ and visual storyteller specializing in diffusion AI art.",
|
bio: "DJ and visual storyteller specializing in diffusion AI art.",
|
||||||
},
|
},
|
||||||
subtitle: "The passionate individuals behind SexyArt's success",
|
subtitle: "The passionate individuals behind Sexy's success",
|
||||||
},
|
},
|
||||||
mission: {
|
mission: {
|
||||||
title: "Our Mission",
|
title: "Our Mission",
|
||||||
@@ -474,7 +474,7 @@ export default {
|
|||||||
},
|
},
|
||||||
faq: {
|
faq: {
|
||||||
title: "Frequently Asked Questions",
|
title: "Frequently Asked Questions",
|
||||||
description: "Find answers to common questions about SexyArt, our platform, and services",
|
description: "Find answers to common questions about Sexy, our platform, and services",
|
||||||
search_placeholder: "Search frequently asked questions...",
|
search_placeholder: "Search frequently asked questions...",
|
||||||
search_results: "Search Results ({count})",
|
search_results: "Search Results ({count})",
|
||||||
no_results: "No questions found matching your search.",
|
no_results: "No questions found matching your search.",
|
||||||
@@ -483,24 +483,24 @@ export default {
|
|||||||
title: "Getting Started",
|
title: "Getting Started",
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
question: "How do I create an account on SexyArt?",
|
question: "How do I create an account on Sexy?",
|
||||||
answer:
|
answer:
|
||||||
"Creating an account is simple! Click the 'Join Now' button in the top navigation, fill out the registration form with your email and basic information, verify you're 18+, and agree to our terms. You'll receive a confirmation email to activate your account.",
|
"Creating an account is simple! Click the 'Join Now' button in the top navigation, fill out the registration form with your email and basic information, verify you're 18+, and agree to our terms. You'll receive a confirmation email to activate your account.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "What types of content can I find on SexyArt?",
|
question: "What types of content can I find on Sexy?",
|
||||||
answer:
|
answer:
|
||||||
"SexyArt features high-quality artistic adult content including intimate photography, romantic videos, artistic nude content, and creative adult entertainment. All content is created by verified models and creators who focus on artistic expression and storytelling.",
|
"Sexy features high-quality artistic adult content including intimate photography, romantic videos, artistic nude content, and creative adult entertainment. All content is created by verified models and creators who focus on artistic expression and storytelling.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "Is SexyArt safe and secure?",
|
question: "Is Sexy safe and secure?",
|
||||||
answer:
|
answer:
|
||||||
"Yes! We use industry-standard encryption, secure payment processing, and strict privacy measures. All creators are verified, and we have comprehensive content moderation. Your personal information and viewing habits are kept completely private.",
|
"Yes! We use industry-standard encryption, secure payment processing, and strict privacy measures. All creators are verified, and we have comprehensive content moderation. Your personal information and viewing habits are kept completely private.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "Can I access SexyArt on mobile devices?",
|
question: "Can I access Sexy on mobile devices?",
|
||||||
answer:
|
answer:
|
||||||
"Absolutely! SexyArt is fully responsive and works perfectly on smartphones, tablets, and desktop computers. You can enjoy the same high-quality experience across all your devices.",
|
"Absolutely! Sexy is fully responsive and works perfectly on smartphones, tablets, and desktop computers. You can enjoy the same high-quality experience across all your devices.",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -508,7 +508,7 @@ export default {
|
|||||||
title: "For Creators & Models",
|
title: "For Creators & Models",
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
question: "How do I become a creator on SexyArt?",
|
question: "How do I become a creator on Sexy?",
|
||||||
answer:
|
answer:
|
||||||
"To become a creator, sign up for a Creator account during registration or upgrade your existing account. You'll need to verify your identity, provide tax information, and agree to our creator terms. Once approved, you can start uploading content and building your audience.",
|
"To become a creator, sign up for a Creator account during registration or upgrade your existing account. You'll need to verify your identity, provide tax information, and agree to our creator terms. Once approved, you can start uploading content and building your audience.",
|
||||||
},
|
},
|
||||||
@@ -597,7 +597,7 @@ export default {
|
|||||||
company_information: "Company Information",
|
company_information: "Company Information",
|
||||||
company_name: {
|
company_name: {
|
||||||
title: "Company Name",
|
title: "Company Name",
|
||||||
value: "SexyArt",
|
value: "Sexy",
|
||||||
},
|
},
|
||||||
legal_form: {
|
legal_form: {
|
||||||
title: "Legal Form",
|
title: "Legal Form",
|
||||||
@@ -614,7 +614,7 @@ export default {
|
|||||||
contact_information: "Contact Information",
|
contact_information: "Contact Information",
|
||||||
registered_address: "Registered Address",
|
registered_address: "Registered Address",
|
||||||
address: {
|
address: {
|
||||||
company: "SexyArt",
|
company: "Sexy",
|
||||||
name: "Sebastian Krüger",
|
name: "Sebastian Krüger",
|
||||||
street: "Berlingerstraße 48",
|
street: "Berlingerstraße 48",
|
||||||
city: "78333 Stockach",
|
city: "78333 Stockach",
|
||||||
@@ -688,7 +688,7 @@ export default {
|
|||||||
acceptance: {
|
acceptance: {
|
||||||
title: "1. Acceptance of Terms",
|
title: "1. Acceptance of Terms",
|
||||||
text: [
|
text: [
|
||||||
"By accessing and using SexyArt, you accept and agree to be bound by the terms and provision of this agreement. If you do not agree to abide by the above, please do not use this service.",
|
"By accessing and using Sexy, you accept and agree to be bound by the terms and provision of this agreement. If you do not agree to abide by the above, please do not use this service.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
age: {
|
age: {
|
||||||
@@ -732,7 +732,7 @@ export default {
|
|||||||
values: {
|
values: {
|
||||||
title: "Our Community Values",
|
title: "Our Community Values",
|
||||||
text: [
|
text: [
|
||||||
"SexyArt is built on respect, consent, and artistic expression. We believe in creating a space where creators and viewers can connect through shared appreciation for intimate art and storytelling.",
|
"Sexy is built on respect, consent, and artistic expression. We believe in creating a space where creators and viewers can connect through shared appreciation for intimate art and storytelling.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
respect: {
|
respect: {
|
||||||
@@ -901,7 +901,7 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
head: {
|
head: {
|
||||||
title: "SexyArt | {title}",
|
title: "Sexy | {title}",
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
nav: {
|
nav: {
|
||||||
|
|||||||
@@ -573,6 +573,7 @@ const UPDATE_PROFILE_MUTATION = gql`
|
|||||||
$artistName: String
|
$artistName: String
|
||||||
$description: String
|
$description: String
|
||||||
$tags: [String!]
|
$tags: [String!]
|
||||||
|
$avatar: String
|
||||||
) {
|
) {
|
||||||
updateProfile(
|
updateProfile(
|
||||||
firstName: $firstName
|
firstName: $firstName
|
||||||
@@ -580,6 +581,7 @@ const UPDATE_PROFILE_MUTATION = gql`
|
|||||||
artistName: $artistName
|
artistName: $artistName
|
||||||
description: $description
|
description: $description
|
||||||
tags: $tags
|
tags: $tags
|
||||||
|
avatar: $avatar
|
||||||
) {
|
) {
|
||||||
id
|
id
|
||||||
email
|
email
|
||||||
@@ -609,6 +611,7 @@ export async function updateProfile(user: Partial<User> & { password?: string })
|
|||||||
artistName: user.artist_name,
|
artistName: user.artist_name,
|
||||||
description: user.description,
|
description: user.description,
|
||||||
tags: user.tags,
|
tags: user.tags,
|
||||||
|
avatar: user.avatar,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return data.updateProfile;
|
return data.updateProfile;
|
||||||
@@ -652,7 +655,8 @@ export async function removeFile(id: string) {
|
|||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(`Failed to delete file: ${response.statusText}`);
|
if (!response.ok && response.status !== 404)
|
||||||
|
throw new Error(`Failed to delete file: ${response.statusText}`);
|
||||||
},
|
},
|
||||||
{ fileId: id },
|
{ fileId: id },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { getAssetUrl } from "$lib/api";
|
import { getAssetUrl } from "$lib/api";
|
||||||
import Meta from "$lib/components/meta/meta.svelte";
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
import { formatVideoDuration } from "$lib/utils.js";
|
import { formatVideoDuration } from "$lib/utils.js";
|
||||||
|
import SexyBackground from "$lib/components/background/background.svelte";
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
</script>
|
</script>
|
||||||
@@ -13,10 +14,9 @@
|
|||||||
|
|
||||||
<!-- Hero Section -->
|
<!-- Hero Section -->
|
||||||
<section class="relative min-h-screen flex items-center justify-center overflow-hidden">
|
<section class="relative min-h-screen flex items-center justify-center overflow-hidden">
|
||||||
<!-- Background Gradient -->
|
|
||||||
<div class="absolute inset-0 bg-gradient-to-br from-primary/20 via-accent/10 to-background"></div>
|
<div class="absolute inset-0 bg-gradient-to-br from-primary/20 via-accent/10 to-background"></div>
|
||||||
|
<SexyBackground />
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<div class="relative z-10 container mx-auto px-4 text-center">
|
<div class="relative z-10 container mx-auto px-4 text-center">
|
||||||
<div class="max-w-5xl mx-auto space-y-12">
|
<div class="max-w-5xl mx-auto space-y-12">
|
||||||
<h1
|
<h1
|
||||||
@@ -47,14 +47,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
|
|
||||||
<!-- Featured Models -->
|
<!-- Featured Models -->
|
||||||
@@ -71,40 +63,22 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-3xl mx-auto">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-3xl mx-auto">
|
||||||
{#each data.models as model (model.slug)}
|
{#each data.models as model (model.slug)}
|
||||||
<Card
|
<a href="/models/{model.slug}" class="block group">
|
||||||
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"
|
<Card
|
||||||
>
|
class="p-0 h-full 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">
|
<CardContent class="p-6 text-center">
|
||||||
<img
|
<div class="relative mb-4">
|
||||||
src={getAssetUrl(model.avatar, "mini")}
|
<img
|
||||||
alt={model.artist_name}
|
src={getAssetUrl(model.avatar, "thumbnail")}
|
||||||
class="w-24 h-24 rounded-full mx-auto object-cover ring-4 ring-primary/20 group-hover:ring-primary/40 transition-all"
|
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 bg-muted"
|
||||||
<!-- <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>
|
||||||
<div>{model.videos} {$_("home.featured_models.videos")}</div>
|
<h3 class="font-semibold text-lg group-hover:text-primary transition-colors">{model.artist_name}</h3>
|
||||||
</div> -->
|
</CardContent>
|
||||||
<Button
|
</Card>
|
||||||
variant="ghost"
|
</a>
|
||||||
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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,50 +96,42 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-6xl mx-auto">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-6xl mx-auto">
|
||||||
{#each data.videos as video (video.slug)}
|
{#each data.videos as video (video.slug)}
|
||||||
<Card
|
<a href="/videos/{video.slug}" class="block group">
|
||||||
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"
|
<Card
|
||||||
>
|
class="p-0 h-full 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
|
<div class="relative">
|
||||||
src={getAssetUrl(video.image, "preview")}
|
<img
|
||||||
alt={video.title}
|
src={getAssetUrl(video.image, "preview")}
|
||||||
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300"
|
alt={video.title}
|
||||||
/>
|
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300 bg-muted"
|
||||||
<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 inset-0 bg-gradient-to-t from-black/60 to-transparent group-hover:scale-105 transition-transform duration-300"
|
||||||
<div class="absolute bottom-2 left-2 text-white text-sm font-medium">
|
></div>
|
||||||
{#if video.movie_file?.duration}{formatVideoDuration(video.movie_file.duration)}{/if}
|
<div class="absolute bottom-2 left-2 text-white text-sm font-medium">
|
||||||
</div>
|
{#if video.movie_file?.duration}{formatVideoDuration(video.movie_file.duration)}{/if}
|
||||||
<!-- <div
|
</div>
|
||||||
class="absolute top-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded-full"
|
<div
|
||||||
>
|
class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
{video.views}
|
aria-hidden="true"
|
||||||
{$_("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>
|
<div class="w-16 h-16 bg-primary/90 rounded-full flex items-center justify-center">
|
||||||
</a>
|
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<CardContent class="px-4 pb-4 pt-0">
|
||||||
<CardContent class="px-4 pb-4 pt-0">
|
<h3 class="font-semibold mb-2 group-hover:text-primary transition-colors">
|
||||||
<h3 class="font-semibold mb-2 group-hover:text-primary transition-colors">
|
{video.title}
|
||||||
{video.title}
|
</h3>
|
||||||
</h3>
|
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span class="icon-[ri--fire-line] w-4 h-4"></span>
|
||||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
{$_("home.trending.trending")}
|
||||||
<span class="icon-[ri--fire-line] w-4 h-4"></span>
|
</div>
|
||||||
{$_("home.trending.trending")}
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</CardContent>
|
</a>
|
||||||
</Card>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen bg-background">
|
<div class="min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5">
|
||||||
<div class="container mx-auto px-4">
|
<div class="container mx-auto px-4">
|
||||||
<!-- Mobile top nav -->
|
<!-- Mobile top nav -->
|
||||||
<div class="lg:hidden flex items-center gap-2 py-3 border-b border-border/40">
|
<div class="lg:hidden flex items-center gap-2 py-3 border-b border-border/40">
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="flex flex-wrap gap-3 mb-4 px-3 sm:px-0">
|
<div class="flex flex-wrap items-center gap-3 mb-4 px-3 sm:px-0">
|
||||||
<Input
|
<Input
|
||||||
placeholder={$_("admin.articles.search_placeholder")}
|
placeholder={$_("admin.articles.search_placeholder")}
|
||||||
class="max-w-xs"
|
class="max-w-xs"
|
||||||
@@ -110,7 +110,6 @@
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
|
||||||
variant={data.featured === true ? "default" : "outline"}
|
variant={data.featured === true ? "default" : "outline"}
|
||||||
onclick={() => setFilter("featured", data.featured === true ? null : "true")}
|
onclick={() => setFilter("featured", data.featured === true ? null : "true")}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3 mb-4 px-3 sm:px-0">
|
<div class="flex flex-wrap items-center gap-3 mb-4 px-3 sm:px-0">
|
||||||
<Input
|
<Input
|
||||||
placeholder={$_("admin.recordings.search_placeholder")}
|
placeholder={$_("admin.recordings.search_placeholder")}
|
||||||
class="max-w-xs"
|
class="max-w-xs"
|
||||||
@@ -83,17 +83,14 @@
|
|||||||
/>
|
/>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
|
||||||
variant={data.status === undefined ? "default" : "outline"}
|
variant={data.status === undefined ? "default" : "outline"}
|
||||||
onclick={() => setFilter("status", null)}>{$_("admin.common.all")}</Button
|
onclick={() => setFilter("status", null)}>{$_("admin.common.all")}</Button
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
|
||||||
variant={data.status === "published" ? "default" : "outline"}
|
variant={data.status === "published" ? "default" : "outline"}
|
||||||
onclick={() => setFilter("status", "published")}>{$_("admin.recordings.published")}</Button
|
onclick={() => setFilter("status", "published")}>{$_("admin.recordings.published")}</Button
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
|
||||||
variant={data.status === "draft" ? "default" : "outline"}
|
variant={data.status === "draft" ? "default" : "outline"}
|
||||||
onclick={() => setFilter("status", "draft")}>{$_("admin.recordings.draft")}</Button
|
onclick={() => setFilter("status", "draft")}>{$_("admin.recordings.draft")}</Button
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="flex flex-wrap gap-3 mb-4 px-3 sm:px-0">
|
<div class="flex flex-wrap items-center gap-3 mb-4 px-3 sm:px-0">
|
||||||
<Input
|
<Input
|
||||||
placeholder={$_("admin.users.search_placeholder")}
|
placeholder={$_("admin.users.search_placeholder")}
|
||||||
class="max-w-xs"
|
class="max-w-xs"
|
||||||
@@ -107,7 +107,6 @@
|
|||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
{#each roles as role (role)}
|
{#each roles as role (role)}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
|
||||||
variant={data.role === role || (!data.role && role === "") ? "default" : "outline"}
|
variant={data.role === role || (!data.role && role === "") ? "default" : "outline"}
|
||||||
onclick={() => setRole(role)}
|
onclick={() => setRole(role)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="flex flex-wrap gap-3 mb-4 px-3 sm:px-0">
|
<div class="flex flex-wrap items-center gap-3 mb-4 px-3 sm:px-0">
|
||||||
<Input
|
<Input
|
||||||
placeholder={$_("admin.videos.search_placeholder")}
|
placeholder={$_("admin.videos.search_placeholder")}
|
||||||
class="max-w-xs"
|
class="max-w-xs"
|
||||||
@@ -90,21 +90,18 @@
|
|||||||
/>
|
/>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
|
||||||
variant={data.featured === undefined ? "default" : "outline"}
|
variant={data.featured === undefined ? "default" : "outline"}
|
||||||
onclick={() => setFilter("featured", null)}
|
onclick={() => setFilter("featured", null)}
|
||||||
>
|
>
|
||||||
{$_("admin.common.all")}
|
{$_("admin.common.all")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
|
||||||
variant={data.featured === true ? "default" : "outline"}
|
variant={data.featured === true ? "default" : "outline"}
|
||||||
onclick={() => setFilter("featured", "true")}
|
onclick={() => setFilter("featured", "true")}
|
||||||
>
|
>
|
||||||
{$_("admin.common.featured")}
|
{$_("admin.common.featured")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
|
||||||
variant={data.premium === true ? "default" : "outline"}
|
variant={data.premium === true ? "default" : "outline"}
|
||||||
onclick={() => setFilter("premium", data.premium === true ? null : "true")}
|
onclick={() => setFilter("premium", data.premium === true ? null : "true")}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
export async function load({ locals }) {
|
export async function load({ locals }) {
|
||||||
|
if (locals.authStatus?.authenticated) {
|
||||||
|
redirect(302, "/me");
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
authStatus: locals.authStatus,
|
authStatus: locals.authStatus,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
async function handleSubmit(e: Event) {
|
async function handleSubmit(e: Event) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
|
isLoading = true;
|
||||||
await login(email, password);
|
await login(email, password);
|
||||||
goto("/videos", { invalidateAll: true });
|
goto("/videos", { invalidateAll: true });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
import { getAssetUrl } from "$lib/api";
|
import { getAssetUrl } from "$lib/api";
|
||||||
import { calcReadingTime } from "$lib/utils.js";
|
import { calcReadingTime } from "$lib/utils.js";
|
||||||
import Meta from "$lib/components/meta/meta.svelte";
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
import SexyBackground from "$lib/components/background/background.svelte";
|
||||||
|
import PageHero from "$lib/components/page-hero/page-hero.svelte";
|
||||||
|
|
||||||
const timeAgo = new TimeAgo("en");
|
const timeAgo = new TimeAgo("en");
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
@@ -48,6 +50,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const totalPages = $derived(Math.ceil(data.total / data.limit));
|
const totalPages = $derived(Math.ceil(data.total / data.limit));
|
||||||
|
|
||||||
|
const pageNumbers = $derived(() => {
|
||||||
|
const pages: (number | -1)[] = [];
|
||||||
|
if (totalPages <= 7) {
|
||||||
|
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||||
|
} else {
|
||||||
|
pages.push(1);
|
||||||
|
if (data.page > 3) pages.push(-1);
|
||||||
|
for (let i = Math.max(2, data.page - 1); i <= Math.min(totalPages - 1, data.page + 1); i++)
|
||||||
|
pages.push(i);
|
||||||
|
if (data.page < totalPages - 2) pages.push(-1);
|
||||||
|
pages.push(totalPages);
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Meta title={$_("magazine.title")} description={$_("magazine.description")} />
|
<Meta title={$_("magazine.title")} description={$_("magazine.description")} />
|
||||||
@@ -55,109 +72,80 @@
|
|||||||
<div
|
<div
|
||||||
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
||||||
>
|
>
|
||||||
<!-- Global Plasma Background -->
|
<SexyBackground />
|
||||||
<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">
|
<PageHero title={$_("magazine.title")} description={$_("magazine.description")}>
|
||||||
<div
|
<div class="flex flex-col md:flex-row gap-4 max-w-4xl mx-auto">
|
||||||
class="absolute inset-0 bg-gradient-to-br from-primary/10 via-accent/5 to-background"
|
<!-- Search -->
|
||||||
></div>
|
<div class="relative flex-1">
|
||||||
<div class="relative container mx-auto px-4 text-center">
|
<span
|
||||||
<div class="max-w-5xl mx-auto">
|
class="icon-[ri--search-line] absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
|
||||||
<h1
|
></span>
|
||||||
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"
|
<Input
|
||||||
>
|
placeholder={$_("magazine.search_placeholder")}
|
||||||
{$_("magazine.title")}
|
value={searchValue}
|
||||||
</h1>
|
oninput={(e) => {
|
||||||
<p
|
searchValue = (e.target as HTMLInputElement).value;
|
||||||
class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto"
|
debounceSearch(searchValue);
|
||||||
>
|
}}
|
||||||
{$_("magazine.description")}
|
class="pl-10 bg-background/50 border-primary/20 focus:border-primary"
|
||||||
</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")}
|
|
||||||
value={searchValue}
|
|
||||||
oninput={(e) => {
|
|
||||||
searchValue = (e.target as HTMLInputElement).value;
|
|
||||||
debounceSearch(searchValue);
|
|
||||||
}}
|
|
||||||
class="pl-10 bg-background/50 border-primary/20 focus:border-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Category Filter -->
|
|
||||||
<Select
|
|
||||||
type="single"
|
|
||||||
value={data.category ?? "all"}
|
|
||||||
onValueChange={(v) => v && setParam("category", v)}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
{!data.category
|
|
||||||
? $_("magazine.categories.all")
|
|
||||||
: data.category === "photography"
|
|
||||||
? $_("magazine.categories.photography")
|
|
||||||
: data.category === "production"
|
|
||||||
? $_("magazine.categories.production")
|
|
||||||
: data.category === "interview"
|
|
||||||
? $_("magazine.categories.interview")
|
|
||||||
: data.category === "psychology"
|
|
||||||
? $_("magazine.categories.psychology")
|
|
||||||
: data.category === "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" value={data.sort} onValueChange={(v) => v && setParam("sort", v)}>
|
|
||||||
<SelectTrigger
|
|
||||||
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
|
||||||
>
|
|
||||||
{data.sort === "featured"
|
|
||||||
? $_("magazine.sort.featured")
|
|
||||||
: data.sort === "name"
|
|
||||||
? $_("magazine.sort.name")
|
|
||||||
: $_("magazine.sort.recent")}
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="recent">{$_("magazine.sort.recent")}</SelectItem>
|
|
||||||
<SelectItem value="featured">{$_("magazine.sort.featured")}</SelectItem>
|
|
||||||
<SelectItem value="name">{$_("magazine.sort.name")}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Category Filter -->
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={data.category ?? "all"}
|
||||||
|
onValueChange={(v) => v && setParam("category", v)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
{!data.category
|
||||||
|
? $_("magazine.categories.all")
|
||||||
|
: data.category === "photography"
|
||||||
|
? $_("magazine.categories.photography")
|
||||||
|
: data.category === "production"
|
||||||
|
? $_("magazine.categories.production")
|
||||||
|
: data.category === "interview"
|
||||||
|
? $_("magazine.categories.interview")
|
||||||
|
: data.category === "psychology"
|
||||||
|
? $_("magazine.categories.psychology")
|
||||||
|
: data.category === "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" value={data.sort} onValueChange={(v) => v && setParam("sort", v)}>
|
||||||
|
<SelectTrigger
|
||||||
|
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
>
|
||||||
|
{data.sort === "featured"
|
||||||
|
? $_("magazine.sort.featured")
|
||||||
|
: data.sort === "name"
|
||||||
|
? $_("magazine.sort.name")
|
||||||
|
: $_("magazine.sort.recent")}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="recent">{$_("magazine.sort.recent")}</SelectItem>
|
||||||
|
<SelectItem value="featured">{$_("magazine.sort.featured")}</SelectItem>
|
||||||
|
<SelectItem value="name">{$_("magazine.sort.name")}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</PageHero>
|
||||||
|
|
||||||
<div class="container mx-auto px-4 py-12">
|
<div class="container mx-auto px-4 py-12">
|
||||||
<!-- Featured Article -->
|
<!-- Featured Article -->
|
||||||
@@ -187,9 +175,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="text-2xl md:text-3xl font-bold mb-4 hover:text-primary transition-colors">
|
<h2 class="text-2xl md:text-3xl font-bold mb-4 hover:text-primary transition-colors">
|
||||||
<button class="text-left">
|
<a href="/magazine/{featuredArticle.slug}">{featuredArticle.title}</a>
|
||||||
<a href="/article/{featuredArticle.slug}">{featuredArticle.title}</a>
|
|
||||||
</button>
|
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-muted-foreground mb-6 text-lg leading-relaxed">
|
<p class="text-muted-foreground mb-6 text-lg leading-relaxed">
|
||||||
{featuredArticle.excerpt}
|
{featuredArticle.excerpt}
|
||||||
@@ -229,100 +215,83 @@
|
|||||||
<!-- Articles Grid -->
|
<!-- Articles Grid -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
{#each data.items as article (article.slug)}
|
{#each data.items as article (article.slug)}
|
||||||
<Card
|
<a href="/magazine/{article.slug}" class="block group">
|
||||||
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"
|
<Card
|
||||||
>
|
class="p-0 h-full 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
|
<div class="relative">
|
||||||
src={getAssetUrl(article.image, "preview")}
|
<img
|
||||||
alt={article.title}
|
src={getAssetUrl(article.image, "preview")}
|
||||||
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300"
|
alt={article.title}
|
||||||
/>
|
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300 bg-muted"
|
||||||
<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
|
<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"
|
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"
|
||||||
>
|
>
|
||||||
{$_("magazine.featured")}
|
{article.category}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Views -->
|
<!-- Featured Badge -->
|
||||||
<!-- <div
|
{#if article.featured}
|
||||||
class="absolute bottom-3 right-3 text-white text-sm flex items-center gap-1"
|
<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"
|
||||||
<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 (tag)}
|
|
||||||
<a
|
|
||||||
class="text-xs bg-primary/10 text-primary px-2 py-1 rounded-full"
|
|
||||||
href="/tags/{tag}"
|
|
||||||
>
|
>
|
||||||
#{tag}
|
{$_("magazine.featured")}
|
||||||
</a>
|
</div>
|
||||||
{/each}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Author & Meta -->
|
<CardContent class="p-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="mb-4">
|
||||||
<div class="flex items-center gap-2">
|
<h3
|
||||||
<img
|
class="font-semibold text-lg mb-2 group-hover:text-primary transition-colors line-clamp-2"
|
||||||
src={getAssetUrl(article.author?.avatar, "mini")}
|
>
|
||||||
alt={article.author?.artist_name}
|
{article.title}
|
||||||
class="w-8 h-8 rounded-full object-cover"
|
</h3>
|
||||||
/>
|
<p class="text-muted-foreground text-sm line-clamp-3 leading-relaxed">
|
||||||
<div>
|
{article.excerpt}
|
||||||
<p class="text-sm font-medium">{article.author?.artist_name}</p>
|
</p>
|
||||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
</div>
|
||||||
<span class="icon-[ri--calendar-line] w-4 h-4"></span>
|
|
||||||
{timeAgo.format(new Date(article.publish_date))}
|
<!-- Tags -->
|
||||||
|
<div class="flex flex-wrap gap-2 mb-4">
|
||||||
|
{#each (article.tags ?? []).slice(0, 3) as tag (tag)}
|
||||||
|
<span class="text-xs bg-primary/10 text-primary px-2 py-1 rounded-full">
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
{/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?.artist_name}
|
||||||
|
class="w-8 h-8 rounded-full object-cover bg-muted"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium">{article.author?.artist_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>
|
</div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
{$_("magazine.read_time", {
|
||||||
|
values: { time: calcReadingTime(article.content) },
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-muted-foreground">
|
</CardContent>
|
||||||
{$_("magazine.read_time", {
|
</Card>
|
||||||
values: { time: calcReadingTime(article.content) },
|
</a>
|
||||||
})}
|
|
||||||
</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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -339,32 +308,40 @@
|
|||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
{#if totalPages > 1}
|
{#if totalPages > 1}
|
||||||
<div class="flex items-center justify-between mt-10">
|
<div class="flex flex-col items-center gap-3 mt-10">
|
||||||
<span class="text-sm text-muted-foreground">
|
<div class="flex items-center gap-1">
|
||||||
{$_("common.page_of", { values: { page: data.page, total: totalPages } })}
|
|
||||||
·
|
|
||||||
{$_("common.total_results", { values: { total: data.total } })}
|
|
||||||
</span>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={data.page <= 1}
|
disabled={data.page <= 1}
|
||||||
onclick={() => goToPage(data.page - 1)}
|
onclick={() => goToPage(data.page - 1)}
|
||||||
class="border-primary/20 hover:bg-primary/10"
|
class="border-primary/20 hover:bg-primary/10"
|
||||||
>
|
>{$_("common.previous")}</Button>
|
||||||
{$_("common.previous")}
|
{#each pageNumbers() as p}
|
||||||
</Button>
|
{#if p === -1}
|
||||||
|
<span class="px-2 text-muted-foreground select-none">…</span>
|
||||||
|
{:else}
|
||||||
|
<Button
|
||||||
|
variant={p === data.page ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onclick={() => goToPage(p)}
|
||||||
|
class={p === data.page
|
||||||
|
? "bg-gradient-to-r from-primary to-accent min-w-9"
|
||||||
|
: "border-primary/20 hover:bg-primary/10 min-w-9"}
|
||||||
|
>{p}</Button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={data.page >= totalPages}
|
disabled={data.page >= totalPages}
|
||||||
onclick={() => goToPage(data.page + 1)}
|
onclick={() => goToPage(data.page + 1)}
|
||||||
class="border-primary/20 hover:bg-primary/10"
|
class="border-primary/20 hover:bg-primary/10"
|
||||||
>
|
>{$_("common.next")}</Button>
|
||||||
{$_("common.next")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{$_("common.total_results", { values: { total: data.total } })}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -62,16 +62,20 @@
|
|||||||
isProfileError = false;
|
isProfileError = false;
|
||||||
profileError = "";
|
profileError = "";
|
||||||
|
|
||||||
let avatarId = undefined;
|
let avatarId: string | null | undefined = undefined;
|
||||||
|
|
||||||
if (!avatar?.id && data.authStatus.user!.avatar) {
|
if (!avatar?.id && data.authStatus.user!.avatar) {
|
||||||
|
// User removed their avatar
|
||||||
await removeFile(data.authStatus.user!.avatar);
|
await removeFile(data.authStatus.user!.avatar);
|
||||||
|
avatarId = null;
|
||||||
|
} else if (avatar?.id) {
|
||||||
|
// Keep existing avatar
|
||||||
|
avatarId = avatar.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (avatar?.file) {
|
if (avatar?.file) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("folder", data.folders.find((f) => f.name === "avatars")!.id);
|
formData.append("file", avatar.file);
|
||||||
formData.append("file", avatar.file!);
|
|
||||||
const result = await uploadFile(formData);
|
const result = await uploadFile(formData);
|
||||||
avatarId = result.id;
|
avatarId = result.id;
|
||||||
}
|
}
|
||||||
@@ -82,7 +86,7 @@
|
|||||||
artist_name: artistName,
|
artist_name: artistName,
|
||||||
description,
|
description,
|
||||||
tags,
|
tags,
|
||||||
avatar: avatarId,
|
avatar: avatarId ?? undefined,
|
||||||
});
|
});
|
||||||
toast.success($_("me.settings.toast_update"));
|
toast.success($_("me.settings.toast_update"));
|
||||||
invalidateAll();
|
invalidateAll();
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
||||||
import { getAssetUrl } from "$lib/api";
|
import { getAssetUrl } from "$lib/api";
|
||||||
import Meta from "$lib/components/meta/meta.svelte";
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
import SexyBackground from "$lib/components/background/background.svelte";
|
||||||
|
import PageHero from "$lib/components/page-hero/page-hero.svelte";
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
@@ -42,6 +44,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const totalPages = $derived(Math.ceil(data.total / data.limit));
|
const totalPages = $derived(Math.ceil(data.total / data.limit));
|
||||||
|
|
||||||
|
const pageNumbers = $derived(() => {
|
||||||
|
const pages: (number | -1)[] = [];
|
||||||
|
if (totalPages <= 7) {
|
||||||
|
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||||
|
} else {
|
||||||
|
pages.push(1);
|
||||||
|
if (data.page > 3) pages.push(-1);
|
||||||
|
for (let i = Math.max(2, data.page - 1); i <= Math.min(totalPages - 1, data.page + 1); i++)
|
||||||
|
pages.push(i);
|
||||||
|
if (data.page < totalPages - 2) pages.push(-1);
|
||||||
|
pages.push(totalPages);
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Meta title={$_("models.title")} description={$_("models.description")} />
|
<Meta title={$_("models.title")} description={$_("models.description")} />
|
||||||
@@ -49,34 +66,10 @@
|
|||||||
<div
|
<div
|
||||||
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
||||||
>
|
>
|
||||||
<!-- Global Plasma Background -->
|
<SexyBackground />
|
||||||
<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">
|
<PageHero title={$_("models.title")} description={$_("models.description")}>
|
||||||
<div class="relative container mx-auto px-4 text-center">
|
<div class="flex flex-col md:flex-row gap-4 max-w-4xl mx-auto">
|
||||||
<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 -->
|
<!-- Search -->
|
||||||
<div class="relative flex-1">
|
<div class="relative flex-1">
|
||||||
<span
|
<span
|
||||||
@@ -105,22 +98,21 @@
|
|||||||
<SelectItem value="recent">{$_("models.sort.recent")}</SelectItem>
|
<SelectItem value="recent">{$_("models.sort.recent")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</PageHero>
|
||||||
<!-- Models Grid -->
|
<!-- Models Grid -->
|
||||||
<div class="container mx-auto px-4 py-12">
|
<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">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
{#each data.items as model (model.slug)}
|
{#each data.items as model (model.slug)}
|
||||||
|
<a href="/models/{model.slug}" class="block group">
|
||||||
<Card
|
<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"
|
class="py-0 h-full 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">
|
<div class="relative">
|
||||||
<img
|
<img
|
||||||
src={getAssetUrl(model.avatar, "preview")}
|
src={getAssetUrl(model.avatar, "preview")}
|
||||||
alt={model.artist_name}
|
alt={model.artist_name}
|
||||||
class="w-full aspect-square object-cover group-hover:scale-105 transition-transform duration-300"
|
class="w-full aspect-square object-cover group-hover:scale-105 transition-transform duration-300 bg-muted"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Online Status -->
|
<!-- Online Status -->
|
||||||
@@ -142,16 +134,15 @@
|
|||||||
/>
|
/>
|
||||||
</button> -->
|
</button> -->
|
||||||
|
|
||||||
<!-- Play Overlay -->
|
<!-- Hover Overlay -->
|
||||||
<a
|
<div
|
||||||
href="/models/{model.slug}"
|
aria-hidden="true"
|
||||||
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"
|
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">
|
<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>
|
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white ml-1"></span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardContent class="p-6">
|
<CardContent class="p-6">
|
||||||
@@ -190,22 +181,9 @@
|
|||||||
<!-- category not available -->
|
<!-- category not available -->
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -220,32 +198,40 @@
|
|||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
{#if totalPages > 1}
|
{#if totalPages > 1}
|
||||||
<div class="flex items-center justify-between mt-10">
|
<div class="flex flex-col items-center gap-3 mt-10">
|
||||||
<span class="text-sm text-muted-foreground">
|
<div class="flex items-center gap-1">
|
||||||
{$_("common.page_of", { values: { page: data.page, total: totalPages } })}
|
|
||||||
·
|
|
||||||
{$_("common.total_results", { values: { total: data.total } })}
|
|
||||||
</span>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={data.page <= 1}
|
disabled={data.page <= 1}
|
||||||
onclick={() => goToPage(data.page - 1)}
|
onclick={() => goToPage(data.page - 1)}
|
||||||
class="border-primary/20 hover:bg-primary/10"
|
class="border-primary/20 hover:bg-primary/10"
|
||||||
>
|
>{$_("common.previous")}</Button>
|
||||||
{$_("common.previous")}
|
{#each pageNumbers() as p}
|
||||||
</Button>
|
{#if p === -1}
|
||||||
|
<span class="px-2 text-muted-foreground select-none">…</span>
|
||||||
|
{:else}
|
||||||
|
<Button
|
||||||
|
variant={p === data.page ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onclick={() => goToPage(p)}
|
||||||
|
class={p === data.page
|
||||||
|
? "bg-gradient-to-r from-primary to-accent min-w-9"
|
||||||
|
: "border-primary/20 hover:bg-primary/10 min-w-9"}
|
||||||
|
>{p}</Button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={data.page >= totalPages}
|
disabled={data.page >= totalPages}
|
||||||
onclick={() => goToPage(data.page + 1)}
|
onclick={() => goToPage(data.page + 1)}
|
||||||
class="border-primary/20 hover:bg-primary/10"
|
class="border-primary/20 hover:bg-primary/10"
|
||||||
>
|
>{$_("common.next")}</Button>
|
||||||
{$_("common.next")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{$_("common.total_results", { values: { total: data.total } })}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
export async function load({ locals }) {
|
export async function load({ locals }) {
|
||||||
|
if (locals.authStatus?.authenticated) {
|
||||||
|
redirect(302, "/me");
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
authStatus: locals.authStatus,
|
authStatus: locals.authStatus,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
import DeviceMappingDialog from "./components/device-mapping-dialog.svelte";
|
import DeviceMappingDialog from "./components/device-mapping-dialog.svelte";
|
||||||
import type { BluetoothDevice, RecordedEvent, DeviceInfo } from "$lib/types";
|
import type { BluetoothDevice, RecordedEvent, DeviceInfo } from "$lib/types";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
|
import SexyBackground from "$lib/components/background/background.svelte";
|
||||||
|
|
||||||
const client = new ButtplugClient("Sexy.Art");
|
const client = new ButtplugClient("Sexy.Art");
|
||||||
let connected = $state(client.connected);
|
let connected = $state(client.connected);
|
||||||
@@ -378,18 +379,7 @@
|
|||||||
<div
|
<div
|
||||||
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
||||||
>
|
>
|
||||||
<!-- Global Plasma Background -->
|
<SexyBackground />
|
||||||
<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="container mx-auto py-20 relative px-4">
|
||||||
<div class="max-w-4xl mx-auto">
|
<div class="max-w-4xl mx-auto">
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
export async function load({ locals }) {
|
export async function load({ locals }) {
|
||||||
|
if (locals.authStatus?.authenticated) {
|
||||||
|
redirect(302, "/me");
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
authStatus: locals.authStatus,
|
authStatus: locals.authStatus,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
||||||
import { getAssetUrl } from "$lib/api";
|
import { getAssetUrl } from "$lib/api";
|
||||||
import Meta from "$lib/components/meta/meta.svelte";
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
import SexyBackground from "$lib/components/background/background.svelte";
|
||||||
|
import PageHero from "$lib/components/page-hero/page-hero.svelte";
|
||||||
|
|
||||||
let searchQuery = $state("");
|
let searchQuery = $state("");
|
||||||
let categoryFilter = $state("all");
|
let categoryFilter = $state("all");
|
||||||
@@ -52,77 +54,50 @@
|
|||||||
<div
|
<div
|
||||||
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
||||||
>
|
>
|
||||||
<!-- Global Plasma Background -->
|
<SexyBackground />
|
||||||
<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">
|
<PageHero
|
||||||
<div class="relative container mx-auto px-4 text-center">
|
title={$_("tags.title", { values: { tag: data.tag } })}
|
||||||
<div class="max-w-5xl mx-auto">
|
description={$_("tags.description", { values: { tag: data.tag } })}
|
||||||
<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"
|
<div class="flex flex-col md:flex-row gap-4 max-w-4xl mx-auto">
|
||||||
>
|
<div class="relative flex-1">
|
||||||
{$_("tags.title", { values: { tag: data.tag } })}
|
<span
|
||||||
</h1>
|
class="icon-[ri--search-line] absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
|
||||||
<p
|
></span>
|
||||||
class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto"
|
<Input
|
||||||
>
|
placeholder={$_("tags.search_placeholder")}
|
||||||
{$_("tags.description", { values: { tag: data.tag } })}
|
bind:value={searchQuery}
|
||||||
</p>
|
class="pl-10 bg-background/50 border-primary/20 focus:border-primary"
|
||||||
<!-- 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>
|
||||||
|
<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>
|
||||||
</section>
|
</PageHero>
|
||||||
<!-- Items Grid -->
|
<!-- Items Grid -->
|
||||||
<div class="container mx-auto px-4 py-12">
|
<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">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
{#each filteredItems() as item (item.slug)}
|
{#each filteredItems() as item (item.slug)}
|
||||||
|
<a href={getUrlForItem(item)} class="block group">
|
||||||
<Card
|
<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"
|
class="py-0 h-full 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">
|
<div class="relative">
|
||||||
<img
|
<img
|
||||||
@@ -148,49 +123,20 @@
|
|||||||
<h3 class="font-semibold text-lg mb-1 group-hover:text-primary transition-colors">
|
<h3 class="font-semibold text-lg mb-1 group-hover:text-primary transition-colors">
|
||||||
{item.title}
|
{item.title}
|
||||||
</h3>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tags -->
|
<!-- Tags -->
|
||||||
<div class="flex flex-wrap gap-2 mb-4">
|
<div class="flex flex-wrap gap-2">
|
||||||
{#each item.tags as tag (tag)}
|
{#each item.tags as tag (tag)}
|
||||||
<a
|
<span class="text-xs bg-primary/10 text-primary px-2 py-1 rounded-full">
|
||||||
class="text-xs bg-primary/10 text-primary px-2 py-1 rounded-full"
|
|
||||||
href="/tags/{tag}"
|
|
||||||
>
|
|
||||||
{tag}
|
{tag}
|
||||||
</a>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
||||||
import { getAssetUrl } from "$lib/api";
|
import { getAssetUrl } from "$lib/api";
|
||||||
import Meta from "$lib/components/meta/meta.svelte";
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
import SexyBackground from "$lib/components/background/background.svelte";
|
||||||
|
import PageHero from "$lib/components/page-hero/page-hero.svelte";
|
||||||
import TimeAgo from "javascript-time-ago";
|
import TimeAgo from "javascript-time-ago";
|
||||||
import { formatVideoDuration } from "$lib/utils";
|
import { formatVideoDuration } from "$lib/utils";
|
||||||
|
|
||||||
@@ -45,6 +47,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const totalPages = $derived(Math.ceil(data.total / data.limit));
|
const totalPages = $derived(Math.ceil(data.total / data.limit));
|
||||||
|
|
||||||
|
const pageNumbers = $derived(() => {
|
||||||
|
const pages: (number | -1)[] = [];
|
||||||
|
if (totalPages <= 7) {
|
||||||
|
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||||
|
} else {
|
||||||
|
pages.push(1);
|
||||||
|
if (data.page > 3) pages.push(-1);
|
||||||
|
for (let i = Math.max(2, data.page - 1); i <= Math.min(totalPages - 1, data.page + 1); i++)
|
||||||
|
pages.push(i);
|
||||||
|
if (data.page < totalPages - 2) pages.push(-1);
|
||||||
|
pages.push(totalPages);
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Meta title={$_("videos.title")} description={$_("videos.description")} />
|
<Meta title={$_("videos.title")} description={$_("videos.description")} />
|
||||||
@@ -52,38 +69,10 @@
|
|||||||
<div
|
<div
|
||||||
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
||||||
>
|
>
|
||||||
<!-- Global Plasma Background -->
|
<SexyBackground />
|
||||||
<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">
|
<PageHero title={$_("videos.title")} description={$_("videos.description")}>
|
||||||
<div
|
<div class="flex flex-col lg:flex-row gap-4 max-w-6xl mx-auto">
|
||||||
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 -->
|
<!-- Search -->
|
||||||
<div class="relative flex-1">
|
<div class="relative flex-1">
|
||||||
<span
|
<span
|
||||||
@@ -146,22 +135,21 @@
|
|||||||
<SelectItem value="name">{$_("videos.sort.name")}</SelectItem>
|
<SelectItem value="name">{$_("videos.sort.name")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</PageHero>
|
||||||
<!-- Videos Grid -->
|
<!-- Videos Grid -->
|
||||||
<div class="container mx-auto px-4 py-12">
|
<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">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
{#each data.items as video (video.slug)}
|
{#each data.items as video (video.slug)}
|
||||||
|
<a href={`/videos/${video.slug}`} class="block group">
|
||||||
<Card
|
<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"
|
class="p-0 h-full 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">
|
<div class="relative">
|
||||||
<img
|
<img
|
||||||
src={getAssetUrl(video.image, "preview")}
|
src={getAssetUrl(video.image, "preview")}
|
||||||
alt={video.title}
|
alt={video.title}
|
||||||
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300"
|
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300 bg-muted"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Overlay Gradient -->
|
<!-- Overlay Gradient -->
|
||||||
@@ -196,17 +184,16 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Play Overlay -->
|
<!-- Play Overlay -->
|
||||||
<a
|
<div
|
||||||
class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
href={`/videos/${video.slug}`}
|
aria-hidden="true"
|
||||||
aria-label={$_("videos.watch")}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-16 h-16 bg-primary/90 rounded-full flex flex-col items-center justify-center shadow-2xl"
|
class="w-16 h-16 bg-primary/90 rounded-full flex flex-col items-center justify-center shadow-2xl"
|
||||||
>
|
>
|
||||||
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white"></span>
|
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white"></span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</div>
|
||||||
|
|
||||||
<!-- Model Info -->
|
<!-- Model Info -->
|
||||||
<!-- <div class="absolute bottom-3 right-3 text-white text-sm">
|
<!-- <div class="absolute bottom-3 right-3 text-white text-sm">
|
||||||
@@ -250,27 +237,9 @@
|
|||||||
</span> -->
|
</span> -->
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -287,32 +256,40 @@
|
|||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
{#if totalPages > 1}
|
{#if totalPages > 1}
|
||||||
<div class="flex items-center justify-between mt-10">
|
<div class="flex flex-col items-center gap-3 mt-10">
|
||||||
<span class="text-sm text-muted-foreground">
|
<div class="flex items-center gap-1">
|
||||||
{$_("common.page_of", { values: { page: data.page, total: totalPages } })}
|
|
||||||
·
|
|
||||||
{$_("common.total_results", { values: { total: data.total } })}
|
|
||||||
</span>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={data.page <= 1}
|
disabled={data.page <= 1}
|
||||||
onclick={() => goToPage(data.page - 1)}
|
onclick={() => goToPage(data.page - 1)}
|
||||||
class="border-primary/20 hover:bg-primary/10"
|
class="border-primary/20 hover:bg-primary/10"
|
||||||
>
|
>{$_("common.previous")}</Button>
|
||||||
{$_("common.previous")}
|
{#each pageNumbers() as p}
|
||||||
</Button>
|
{#if p === -1}
|
||||||
|
<span class="px-2 text-muted-foreground select-none">…</span>
|
||||||
|
{:else}
|
||||||
|
<Button
|
||||||
|
variant={p === data.page ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onclick={() => goToPage(p)}
|
||||||
|
class={p === data.page
|
||||||
|
? "bg-gradient-to-r from-primary to-accent min-w-9"
|
||||||
|
: "border-primary/20 hover:bg-primary/10 min-w-9"}
|
||||||
|
>{p}</Button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={data.page >= totalPages}
|
disabled={data.page >= totalPages}
|
||||||
onclick={() => goToPage(data.page + 1)}
|
onclick={() => goToPage(data.page + 1)}
|
||||||
class="border-primary/20 hover:bg-primary/10"
|
class="border-primary/20 hover:bg-primary/10"
|
||||||
>
|
>{$_("common.next")}</Button>
|
||||||
{$_("common.next")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{$_("common.total_results", { values: { total: data.total } })}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,7 +31,6 @@
|
|||||||
let isLiked = $state(data.likeStatus.liked);
|
let isLiked = $state(data.likeStatus.liked);
|
||||||
let likesCount = $state(data.video.likes_count || 0);
|
let likesCount = $state(data.video.likes_count || 0);
|
||||||
let isLikeLoading = $state(false);
|
let isLikeLoading = $state(false);
|
||||||
let isBookmarked = $state(false);
|
|
||||||
let newComment = $state("");
|
let newComment = $state("");
|
||||||
let showComments = $state(true);
|
let showComments = $state(true);
|
||||||
let isCommentLoading = $state(false);
|
let isCommentLoading = $state(false);
|
||||||
@@ -40,41 +39,6 @@
|
|||||||
let currentPlayId = $state<string | null>(null);
|
let currentPlayId = $state<string | null>(null);
|
||||||
let lastTrackedTime = $state(0);
|
let lastTrackedTime = $state(0);
|
||||||
|
|
||||||
const _relatedVideos = [
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: "Sunset Dreams",
|
|
||||||
thumbnail: "/placeholder.svg?size=wide",
|
|
||||||
duration: "8:45",
|
|
||||||
views: "1.8M",
|
|
||||||
model: "Luna Belle",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: "Intimate Moments",
|
|
||||||
thumbnail: "/placeholder.svg?size=wide",
|
|
||||||
duration: "15:22",
|
|
||||||
views: "3.2M",
|
|
||||||
model: "Aria Divine",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
title: "Morning Light",
|
|
||||||
thumbnail: "/placeholder.svg?size=wide",
|
|
||||||
duration: "10:15",
|
|
||||||
views: "956K",
|
|
||||||
model: "Maya Starlight",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
title: "Passionate Dance",
|
|
||||||
thumbnail: "/placeholder.svg?size=wide",
|
|
||||||
duration: "7:33",
|
|
||||||
views: "1.4M",
|
|
||||||
model: "Zara Moon",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
async function handleLike() {
|
async function handleLike() {
|
||||||
if (!data.authStatus.authenticated) {
|
if (!data.authStatus.authenticated) {
|
||||||
toast.error("Please sign in to like videos");
|
toast.error("Please sign in to like videos");
|
||||||
@@ -101,10 +65,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _handleBookmark() {
|
|
||||||
isBookmarked = !isBookmarked;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteComment(id: number) {
|
async function handleDeleteComment(id: number) {
|
||||||
try {
|
try {
|
||||||
await deleteComment(id);
|
await deleteComment(id);
|
||||||
@@ -289,16 +249,6 @@
|
|||||||
type: "video" as const,
|
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'}"
|
|
||||||
>
|
|
||||||
<span class="icon-[ri--bookmark-{isBookmarked ? 'fill' : 'line'}] w-4 h-4"></span>
|
|
||||||
Save
|
|
||||||
</Button> -->
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Model Info -->
|
<!-- Model Info -->
|
||||||
@@ -329,15 +279,8 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<!-- <p class="text-sm text-muted-foreground">
|
|
||||||
{data.video.model.subscribers} subscribers
|
|
||||||
</p> -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- <Button
|
|
||||||
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
|
||||||
>Subscribe</Button
|
|
||||||
> -->
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 493 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 4.9 KiB |
42
packages/frontend/static/logo.svg
Normal file
|
After Width: | Height: | Size: 18 KiB |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Sexy.Art",
|
"name": "Sexy",
|
||||||
"short_name": "Sexy.Art",
|
"short_name": "Sexy",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/android-chrome-192x192.png",
|
"src": "/android-chrome-192x192.png",
|
||||||
@@ -13,8 +13,8 @@
|
|||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"theme_color": "#ffffff",
|
"theme_color": "#ce47eb",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#000000",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"start_url": "/"
|
"start_url": "/"
|
||||||
}
|
}
|
||||||
|
|||||||