Compare commits

...

10 Commits

Author SHA1 Message Date
1c101406f6 fix: match admin background gradient to rest of the app
All checks were successful
Build and Push Backend Image / build (push) Successful in 44s
Build and Push Frontend Image / build (push) Successful in 4m4s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 18:51:40 +01:00
cb7720ca9c fix: smooth hero-to-content transition with transparent gradient fade
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 18:47:24 +01:00
df099b2700 fix: remove extra closing div in models pagination
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 18:42:39 +01:00
291f72381f feat: improve UX across all listing pages and homepage
- Make model/video cards fully clickable on homepage, models, videos, magazine, and tags pages
- Replace inline blob divs with SexyBackground component on magazine and play pages
- Replace magazine hero section with PageHero component for consistency
- Remove redundant action buttons from cards (cards are now the link targets)
- Fix nested anchor/button invalid HTML in magazine featured article
- Convert inner overlay anchors to aria-hidden divs to avoid nested <a> elements
- Add bg-muted skeleton placeholder to all card images
- Update magazine pagination to smart numbered style with ellipsis (matching videos/models)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 18:41:39 +01:00
1a2fab3e37 refactor: UX and styling improvements across frontend
- Fix login spinner (isLoading never set to true before await)
- Extract PageHero component, replace copy-pasted hero sections on videos/models/tags pages
- Replace inline plasma blobs with SexyBackground on videos/models/tags pages
- Make video/model/tag cards fully clickable (wrap in <a>), remove redundant Watch/View Profile buttons
- Convert inner overlay anchors to divs to avoid nested <a> elements
- Fix home page model avatar preset: mini → thumbnail (correct size for 96px display)
- Reduce home hero height: min-h-screen → min-h-[70vh]
- Remove dead hideName prop from Logo, simplify component
- Add brand name to mobile flyout panel header with gradient styling
- Remove dead _relatedVideos array, isBookmarked state, _handleBookmark from video detail page
- Clean up commented-out code blocks in video detail and models pages
- Note: tag card inner tag links converted to spans to avoid nested anchors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 18:33:32 +01:00
56b57486dc fix: add upload/delete file endpoints and wire avatar update through profile
- Add POST /upload and DELETE /assets/:id routes to backend (session auth via session_token cookie)
- Add avatar arg to updateProfile GraphQL mutation and resolver
- Fix frontend to pass avatarId correctly on save, preserve existing avatar when unchanged
- Ignore 404 on file delete (already gone is fine)
- Remove broken folder lookup (getFolders is a stub, backend has no folder concept)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 18:22:22 +01:00
a050e886cb feat: replace slide-to-logout with avatar + name + logout button in header
Removes the drag-to-logout widget in favour of a clean inline layout:
- Desktop: avatar (links to /me), artist name, and a logout icon button
- Mobile flyout: user card with avatar, name, email, and logout button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 18:12:18 +01:00
519fd45d8d feat: rebrand to Sexy, restyle logo with gradient icon and updated assets
- Rename brand from SexyArt to Sexy throughout i18n locale
- Apply primary→accent gradient to SVG icon stroke
- Remove brand name text from logo, icon-only display
- Switch logo font to Noto Sans bold (default), drop Dancing Script
- Update favicons, app icons, webmanifest, and add logo.svg

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 18:04:14 +01:00
0592d27a15 fix: redirect authenticated users away from login, signup, and password pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 15:58:12 +01:00
a38883e631 fix: align admin filter toggle buttons with search input height
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 15:51:13 +01:00
35 changed files with 655 additions and 676 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 } })}
&nbsp;·&nbsp;
{$_("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>

View File

@@ -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();

View File

@@ -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 } })}
&nbsp;·&nbsp;
{$_("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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 } })}
&nbsp;·&nbsp;
{$_("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>

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 493 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -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": "/"
} }