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(),
description: t.arg.string(),
tags: t.arg.stringList(),
avatar: t.arg.string(),
},
resolve: async (_root, args, ctx) => {
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
@@ -58,6 +59,7 @@ builder.mutationField("updateProfile", (t) =>
if (args.description !== undefined && args.description !== null)
updates.description = args.description;
if (args.tags !== undefined && args.tags !== null) updates.tags = args.tags;
if (args.avatar !== undefined) updates.avatar = args.avatar;
await ctx.db
.update(users)

View File

@@ -7,7 +7,8 @@ import { createYoga } from "graphql-yoga";
import { eq } from "drizzle-orm";
import { files } from "./db/schema/index";
import path from "path";
import { existsSync } from "fs";
import { existsSync, mkdirSync } from "fs";
import { writeFile, rm } from "fs/promises";
import sharp from "sharp";
import { schema } from "./graphql/index";
import { buildContext } from "./graphql/context";
@@ -120,6 +121,54 @@ async function main() {
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) => {
return reply.send({ status: "ok", timestamp: new Date().toISOString() });
});

View File

@@ -194,7 +194,7 @@
--card-foreground: oklch(0.95 0.01 280);
--border: oklch(0.2 0.05 280);
--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);
--secondary: oklch(0.15 0.05 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.gstatic.com" crossorigin />
<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"
/>

View File

@@ -6,7 +6,8 @@
import { logout } from "$lib/services";
import { goto } from "$app/navigation";
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 BurgerMenuButton from "../burger-menu-button/burger-menu-button.svelte";
import Logo from "../logo/logo.svelte";
@@ -55,7 +56,7 @@
href="/"
class="flex w-full items-center gap-3 hover:scale-105 transition-all duration-300"
>
<Logo hideName={true} />
<Logo />
</a>
<!-- Desktop Navigation -->
@@ -125,15 +126,32 @@
<Separator orientation="vertical" class="mx-1 h-6 bg-border/50" />
<LogoutButton
user={{
name:
authStatus.user!.artist_name || authStatus.user!.email.split("@")[0] || "User",
avatar: getAssetUrl(authStatus.user!.avatar, "mini")!,
email: authStatus.user!.email,
}}
onLogout={handleLogout}
<a href="/me" class="flex items-center gap-2 px-1 hover:opacity-80 transition-opacity">
<Avatar class="h-7 w-7 ring-2 ring-primary/20">
<AvatarImage
src={getAssetUrl(authStatus.user!.avatar, "mini")!}
alt={authStatus.user!.artist_name || authStatus.user!.email}
/>
<AvatarFallback
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>
{:else}
@@ -173,22 +191,46 @@
inert={!isMobileMenuOpen || undefined}
>
<!-- Panel header -->
<div class="flex items-center px-5 h-16 shrink-0 border-b border-border/30">
<Logo hideName={true} />
<div class="flex items-center gap-3 px-5 h-16 shrink-0 border-b border-border/30">
<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 class="flex-1 py-6 px-5 space-y-6">
<!-- User logout slider -->
<!-- User card -->
{#if authStatus.authenticated}
<LogoutButton
user={{
name: authStatus.user!.artist_name || authStatus.user!.email.split("@")[0] || "User",
avatar: getAssetUrl(authStatus.user!.avatar, "mini")!,
email: authStatus.user!.email,
}}
onLogout={handleLogout}
class="w-full"
<div class="flex items-center gap-3 rounded-xl border border-border/40 bg-card/50 px-4 py-3">
<Avatar class="h-10 w-10 ring-2 ring-primary/20 shrink-0">
<AvatarImage
src={getAssetUrl(authStatus.user!.avatar, "mini")!}
alt={authStatus.user!.artist_name || authStatus.user!.email}
/>
<AvatarFallback
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}
<!-- Navigation -->

File diff suppressed because one or more lines are too long

View File

@@ -1,21 +1,5 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import SexyIcon from "../icon/icon.svelte";
const { hideName = false } = $props();
</script>
<div class="relative">
<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>
<SexyIcon class="w-12 h-12" />

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",
},
brand: {
name: "SexyArt",
name: "Sexy",
tagline: "Where Love Meets Artistry",
description:
"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",
},
magazine: {
title: "SexyArt Magazine",
title: "Sexy Magazine",
description:
"Insights, stories, and inspiration from the world of love, art, and intimate expression",
search_placeholder: "Search articles...",
@@ -387,7 +387,7 @@ export default {
},
},
about: {
title: "About SexyArt",
title: "About Sexy",
subtitle:
"Where passion meets artistry, and intimate storytelling becomes a celebration of human connection.",
join_community: "Join Our Community",
@@ -403,11 +403,11 @@ export default {
subtitle:
"Born from a vision to transform how intimate content is created, shared, and appreciated",
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:
"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:
"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: {
title: "Our Values",
@@ -447,7 +447,7 @@ export default {
image: "/img/valknar.gif",
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: {
title: "Our Mission",
@@ -474,7 +474,7 @@ export default {
},
faq: {
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_results: "Search Results ({count})",
no_results: "No questions found matching your search.",
@@ -483,24 +483,24 @@ export default {
title: "Getting Started",
questions: [
{
question: "How do I create an account on SexyArt?",
question: "How do I create an account on Sexy?",
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.",
},
{
question: "What types of content can I find on SexyArt?",
question: "What types of content can I find on Sexy?",
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:
"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:
"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",
questions: [
{
question: "How do I become a creator on SexyArt?",
question: "How do I become a creator on Sexy?",
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.",
},
@@ -597,7 +597,7 @@ export default {
company_information: "Company Information",
company_name: {
title: "Company Name",
value: "SexyArt",
value: "Sexy",
},
legal_form: {
title: "Legal Form",
@@ -614,7 +614,7 @@ export default {
contact_information: "Contact Information",
registered_address: "Registered Address",
address: {
company: "SexyArt",
company: "Sexy",
name: "Sebastian Krüger",
street: "Berlingerstraße 48",
city: "78333 Stockach",
@@ -688,7 +688,7 @@ export default {
acceptance: {
title: "1. Acceptance of Terms",
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: {
@@ -732,7 +732,7 @@ export default {
values: {
title: "Our Community Values",
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: {
@@ -901,7 +901,7 @@ export default {
},
},
head: {
title: "SexyArt | {title}",
title: "Sexy | {title}",
},
admin: {
nav: {

View File

@@ -573,6 +573,7 @@ const UPDATE_PROFILE_MUTATION = gql`
$artistName: String
$description: String
$tags: [String!]
$avatar: String
) {
updateProfile(
firstName: $firstName
@@ -580,6 +581,7 @@ const UPDATE_PROFILE_MUTATION = gql`
artistName: $artistName
description: $description
tags: $tags
avatar: $avatar
) {
id
email
@@ -609,6 +611,7 @@ export async function updateProfile(user: Partial<User> & { password?: string })
artistName: user.artist_name,
description: user.description,
tags: user.tags,
avatar: user.avatar,
},
);
return data.updateProfile;
@@ -652,7 +655,8 @@ export async function removeFile(id: string) {
method: "DELETE",
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 },
);

View File

@@ -5,6 +5,7 @@
import { getAssetUrl } from "$lib/api";
import Meta from "$lib/components/meta/meta.svelte";
import { formatVideoDuration } from "$lib/utils.js";
import SexyBackground from "$lib/components/background/background.svelte";
const { data } = $props();
</script>
@@ -13,10 +14,9 @@
<!-- Hero Section -->
<section class="relative min-h-screen flex items-center justify-center overflow-hidden">
<!-- Background Gradient -->
<div class="absolute inset-0 bg-gradient-to-br from-primary/20 via-accent/10 to-background"></div>
<SexyBackground />
<!-- Content -->
<div class="relative z-10 container mx-auto px-4 text-center">
<div class="max-w-5xl mx-auto space-y-12">
<h1
@@ -47,14 +47,6 @@
</div>
</div>
</div>
<!-- Floating Elements -->
<div
class="absolute top-20 left-10 w-20 h-20 bg-primary/20 rounded-full blur-xl animate-pulse"
></div>
<div
class="absolute bottom-20 right-10 w-32 h-32 bg-accent/20 rounded-full blur-xl animate-pulse delay-1000"
></div>
</section>
<!-- Featured Models -->
@@ -71,40 +63,22 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-3xl mx-auto">
{#each data.models as model (model.slug)}
<a href="/models/{model.slug}" class="block group">
<Card
class="p-0 group hover:shadow-2xl hover:shadow-primary/20 transition-all duration-300 hover:-translate-y-2 bg-gradient-to-br from-card to-card/50 border-primary/20"
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">
<img
src={getAssetUrl(model.avatar, "mini")}
src={getAssetUrl(model.avatar, "thumbnail")}
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"
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>{model.videos} {$_("home.featured_models.videos")}</div>
</div> -->
<Button
variant="ghost"
size="sm"
class="mt-4 w-full group-hover:bg-primary/10"
href="/models/{model.slug}">{$_("home.featured_models.view_profile")}</Button
>
<h3 class="font-semibold text-lg group-hover:text-primary transition-colors">{model.artist_name}</h3>
</CardContent>
</Card>
</a>
{/each}
</div>
</div>
@@ -122,14 +96,15 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-6xl mx-auto">
{#each data.videos as video (video.slug)}
<a href="/videos/{video.slug}" class="block group">
<Card
class="p-0 group hover:shadow-2xl hover:shadow-accent/20 transition-all duration-300 hover:-translate-y-2 bg-gradient-to-br from-card to-card/50 border-accent/20 overflow-hidden"
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
src={getAssetUrl(video.image, "preview")}
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"
/>
<div
class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent group-hover:scale-105 transition-transform duration-300"
@@ -137,35 +112,26 @@
<div class="absolute bottom-2 left-2 text-white text-sm font-medium">
{#if video.movie_file?.duration}{formatVideoDuration(video.movie_file.duration)}{/if}
</div>
<!-- <div
class="absolute top-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded-full"
>
{video.views}
{$_("home.trending.views")}
</div> -->
<div
class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
aria-hidden="true"
>
<a
class="w-16 h-16 bg-primary/90 rounded-full flex items-center justify-center"
href="/videos/{video.slug}"
aria-label={video.title}
>
<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"></span>
</a>
</div>
</div>
</div>
<CardContent class="px-4 pb-4 pt-0">
<h3 class="font-semibold mb-2 group-hover:text-primary transition-colors">
{video.title}
</h3>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<span class="icon-[ri--fire-line] w-4 h-4"></span>
{$_("home.trending.trending")}
</div>
</CardContent>
</Card>
</a>
{/each}
</div>
</div>

View File

@@ -21,7 +21,7 @@
}
</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">
<!-- Mobile top nav -->
<div class="lg:hidden flex items-center gap-2 py-3 border-b border-border/40">

View File

@@ -81,7 +81,7 @@
</div>
<!-- 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
placeholder={$_("admin.articles.search_placeholder")}
class="max-w-xs"
@@ -110,7 +110,6 @@
</SelectContent>
</Select>
<Button
size="sm"
variant={data.featured === true ? "default" : "outline"}
onclick={() => setFilter("featured", data.featured === true ? null : "true")}
>

View File

@@ -71,7 +71,7 @@
>
</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
placeholder={$_("admin.recordings.search_placeholder")}
class="max-w-xs"
@@ -83,17 +83,14 @@
/>
<div class="flex gap-1">
<Button
size="sm"
variant={data.status === undefined ? "default" : "outline"}
onclick={() => setFilter("status", null)}>{$_("admin.common.all")}</Button
>
<Button
size="sm"
variant={data.status === "published" ? "default" : "outline"}
onclick={() => setFilter("status", "published")}>{$_("admin.recordings.published")}</Button
>
<Button
size="sm"
variant={data.status === "draft" ? "default" : "outline"}
onclick={() => setFilter("status", "draft")}>{$_("admin.recordings.draft")}</Button
>

View File

@@ -93,7 +93,7 @@
</div>
<!-- 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
placeholder={$_("admin.users.search_placeholder")}
class="max-w-xs"
@@ -107,7 +107,6 @@
<div class="flex gap-1">
{#each roles as role (role)}
<Button
size="sm"
variant={data.role === role || (!data.role && role === "") ? "default" : "outline"}
onclick={() => setRole(role)}
>

View File

@@ -78,7 +78,7 @@
</div>
<!-- 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
placeholder={$_("admin.videos.search_placeholder")}
class="max-w-xs"
@@ -90,21 +90,18 @@
/>
<div class="flex gap-1">
<Button
size="sm"
variant={data.featured === undefined ? "default" : "outline"}
onclick={() => setFilter("featured", null)}
>
{$_("admin.common.all")}
</Button>
<Button
size="sm"
variant={data.featured === true ? "default" : "outline"}
onclick={() => setFilter("featured", "true")}
>
{$_("admin.common.featured")}
</Button>
<Button
size="sm"
variant={data.premium === true ? "default" : "outline"}
onclick={() => setFilter("premium", data.premium === true ? null : "true")}
>

View File

@@ -1,4 +1,9 @@
import { redirect } from "@sveltejs/kit";
export async function load({ locals }) {
if (locals.authStatus?.authenticated) {
redirect(302, "/me");
}
return {
authStatus: locals.authStatus,
};

View File

@@ -29,6 +29,7 @@
async function handleSubmit(e: Event) {
e.preventDefault();
try {
isLoading = true;
await login(email, password);
goto("/videos", { invalidateAll: true });
} catch (err: any) {

View File

@@ -11,6 +11,8 @@
import { getAssetUrl } from "$lib/api";
import { calcReadingTime } from "$lib/utils.js";
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 { data } = $props();
@@ -48,6 +50,21 @@
}
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>
<Meta title={$_("magazine.title")} description={$_("magazine.description")} />
@@ -55,36 +72,9 @@
<div
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
>
<!-- Global Plasma Background -->
<div class="absolute inset-0 pointer-events-none">
<div
class="absolute top-40 left-1/4 w-80 h-80 bg-gradient-to-r from-primary/16 via-accent/20 to-primary/12 rounded-full blur-3xl animate-blob-slow"
></div>
<div
class="absolute bottom-40 right-1/4 w-96 h-96 bg-gradient-to-r from-accent/16 via-primary/20 to-accent/12 rounded-full blur-3xl animate-blob-slow animation-delay-5000"
></div>
<div
class="absolute top-1/3 right-1/3 w-64 h-64 bg-gradient-to-r from-primary/14 via-accent/18 to-primary/10 rounded-full blur-2xl animate-blob-reverse animation-delay-2500"
></div>
</div>
<SexyBackground />
<section class="relative py-20 overflow-hidden">
<div
class="absolute inset-0 bg-gradient-to-br from-primary/10 via-accent/5 to-background"
></div>
<div class="relative container mx-auto px-4 text-center">
<div class="max-w-5xl mx-auto">
<h1
class="text-5xl md:text-7xl font-bold mb-8 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
>
{$_("magazine.title")}
</h1>
<p
class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto"
>
{$_("magazine.description")}
</p>
<!-- Filters -->
<PageHero title={$_("magazine.title")} description={$_("magazine.description")}>
<div class="flex flex-col md:flex-row gap-4 max-w-4xl mx-auto">
<!-- Search -->
<div class="relative flex-1">
@@ -155,9 +145,7 @@
</SelectContent>
</Select>
</div>
</div>
</div>
</section>
</PageHero>
<div class="container mx-auto px-4 py-12">
<!-- Featured Article -->
@@ -187,9 +175,7 @@
</span>
</div>
<h2 class="text-2xl md:text-3xl font-bold mb-4 hover:text-primary transition-colors">
<button class="text-left">
<a href="/article/{featuredArticle.slug}">{featuredArticle.title}</a>
</button>
<a href="/magazine/{featuredArticle.slug}">{featuredArticle.title}</a>
</h2>
<p class="text-muted-foreground mb-6 text-lg leading-relaxed">
{featuredArticle.excerpt}
@@ -229,14 +215,15 @@
<!-- Articles Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{#each data.items as article (article.slug)}
<a href="/magazine/{article.slug}" class="block group">
<Card
class="p-0 group hover:shadow-2xl hover:shadow-primary/25 transition-all duration-300 hover:-translate-y-3 bg-gradient-to-br from-card/90 via-card/95 to-card/85 backdrop-blur-xl shadow-lg shadow-primary/10 overflow-hidden"
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
src={getAssetUrl(article.image, "preview")}
alt={article.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"
/>
<div
class="absolute group-hover:scale-105 transition-transform inset-0 bg-gradient-to-t from-black/40 to-transparent duration-300"
@@ -257,14 +244,6 @@
{$_("magazine.featured")}
</div>
{/if}
<!-- Views -->
<!-- <div
class="absolute bottom-3 right-3 text-white text-sm flex items-center gap-1"
>
<TrendingUpIcon class="w-4 h-4" />
{article.views}
</div> -->
</div>
<CardContent class="p-6">
@@ -272,7 +251,7 @@
<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>
{article.title}
</h3>
<p class="text-muted-foreground text-sm line-clamp-3 leading-relaxed">
{article.excerpt}
@@ -282,12 +261,9 @@
<!-- 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}"
>
<span class="text-xs bg-primary/10 text-primary px-2 py-1 rounded-full">
#{tag}
</a>
</span>
{/each}
</div>
@@ -297,7 +273,7 @@
<img
src={getAssetUrl(article.author?.avatar, "mini")}
alt={article.author?.artist_name}
class="w-8 h-8 rounded-full object-cover"
class="w-8 h-8 rounded-full object-cover bg-muted"
/>
<div>
<p class="text-sm font-medium">{article.author?.artist_name}</p>
@@ -313,16 +289,9 @@
})}
</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>
</a>
{/each}
</div>
@@ -339,32 +308,40 @@
<!-- Pagination -->
{#if totalPages > 1}
<div class="flex items-center justify-between mt-10">
<span class="text-sm text-muted-foreground">
{$_("common.page_of", { values: { page: data.page, total: totalPages } })}
&nbsp;·&nbsp;
{$_("common.total_results", { values: { total: data.total } })}
</span>
<div class="flex gap-2">
<div class="flex flex-col items-center gap-3 mt-10">
<div class="flex items-center gap-1">
<Button
variant="outline"
size="sm"
disabled={data.page <= 1}
onclick={() => goToPage(data.page - 1)}
class="border-primary/20 hover:bg-primary/10"
>
{$_("common.previous")}
</Button>
>{$_("common.previous")}</Button>
{#each pageNumbers() as p}
{#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
variant="outline"
size="sm"
disabled={data.page >= totalPages}
onclick={() => goToPage(data.page + 1)}
class="border-primary/20 hover:bg-primary/10"
>
{$_("common.next")}
</Button>
>{$_("common.next")}</Button>
</div>
<p class="text-sm text-muted-foreground">
{$_("common.total_results", { values: { total: data.total } })}
</p>
</div>
{/if}
</div>

View File

@@ -62,16 +62,20 @@
isProfileError = false;
profileError = "";
let avatarId = undefined;
let avatarId: string | null | undefined = undefined;
if (!avatar?.id && data.authStatus.user!.avatar) {
// User removed their avatar
await removeFile(data.authStatus.user!.avatar);
avatarId = null;
} else if (avatar?.id) {
// Keep existing avatar
avatarId = avatar.id;
}
if (avatar?.file) {
const formData = new FormData();
formData.append("folder", data.folders.find((f) => f.name === "avatars")!.id);
formData.append("file", avatar.file!);
formData.append("file", avatar.file);
const result = await uploadFile(formData);
avatarId = result.id;
}
@@ -82,7 +86,7 @@
artist_name: artistName,
description,
tags,
avatar: avatarId,
avatar: avatarId ?? undefined,
});
toast.success($_("me.settings.toast_update"));
invalidateAll();

View File

@@ -9,6 +9,8 @@
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
import { getAssetUrl } from "$lib/api";
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();
@@ -42,6 +44,21 @@
}
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>
<Meta title={$_("models.title")} description={$_("models.description")} />
@@ -49,33 +66,9 @@
<div
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
>
<!-- Global Plasma Background -->
<div class="absolute inset-0 pointer-events-none">
<div
class="absolute top-40 left-1/4 w-80 h-80 bg-gradient-to-r from-primary/16 via-accent/20 to-primary/12 rounded-full blur-3xl animate-blob-slow"
></div>
<div
class="absolute bottom-40 right-1/4 w-96 h-96 bg-gradient-to-r from-accent/16 via-primary/20 to-accent/12 rounded-full blur-3xl animate-blob-slow animation-delay-5000"
></div>
<div
class="absolute top-1/3 right-1/3 w-64 h-64 bg-gradient-to-r from-primary/14 via-accent/18 to-primary/10 rounded-full blur-2xl animate-blob-reverse animation-delay-2500"
></div>
</div>
<SexyBackground />
<section class="relative py-20 overflow-hidden">
<div class="relative container mx-auto px-4 text-center">
<div class="max-w-5xl mx-auto">
<h1
class="text-5xl md:text-7xl font-bold mb-8 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
>
{$_("models.title")}
</h1>
<p
class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto"
>
{$_("models.description")}
</p>
<!-- Filters -->
<PageHero title={$_("models.title")} description={$_("models.description")}>
<div class="flex flex-col md:flex-row gap-4 max-w-4xl mx-auto">
<!-- Search -->
<div class="relative flex-1">
@@ -106,21 +99,20 @@
</SelectContent>
</Select>
</div>
</div>
</div>
</section>
</PageHero>
<!-- Models Grid -->
<div class="container mx-auto px-4 py-12">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{#each data.items as model (model.slug)}
<a href="/models/{model.slug}" class="block group">
<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">
<img
src={getAssetUrl(model.avatar, "preview")}
alt={model.artist_name}
class="w-full aspect-square object-cover group-hover:scale-105 transition-transform duration-300"
class="w-full aspect-square object-cover group-hover:scale-105 transition-transform duration-300 bg-muted"
/>
<!-- Online Status -->
@@ -142,16 +134,15 @@
/>
</button> -->
<!-- Play Overlay -->
<a
href="/models/{model.slug}"
aria-label={model.artist_name}
<!-- Hover Overlay -->
<div
aria-hidden="true"
class="absolute inset-0 group-hover:scale-105 transition bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 flex items-center justify-center"
>
<div class="w-16 h-16 bg-primary/90 rounded-full flex items-center justify-center">
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white ml-1"></span>
</div>
</a>
</div>
</div>
<CardContent class="p-6">
@@ -190,22 +181,9 @@
<!-- category not available -->
</div>
<!-- Action Buttons -->
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
class="flex-1 border-primary/20 hover:bg-primary/10"
href="/models/{model.slug}">{$_("models.view_profile")}</Button
>
<!-- <Button
size="sm"
class="flex-1 bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>{$_("models.follow")}</Button
> -->
</div>
</CardContent>
</Card>
</a>
{/each}
</div>
@@ -220,32 +198,40 @@
<!-- Pagination -->
{#if totalPages > 1}
<div class="flex items-center justify-between mt-10">
<span class="text-sm text-muted-foreground">
{$_("common.page_of", { values: { page: data.page, total: totalPages } })}
&nbsp;·&nbsp;
{$_("common.total_results", { values: { total: data.total } })}
</span>
<div class="flex gap-2">
<div class="flex flex-col items-center gap-3 mt-10">
<div class="flex items-center gap-1">
<Button
variant="outline"
size="sm"
disabled={data.page <= 1}
onclick={() => goToPage(data.page - 1)}
class="border-primary/20 hover:bg-primary/10"
>
{$_("common.previous")}
</Button>
>{$_("common.previous")}</Button>
{#each pageNumbers() as p}
{#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
variant="outline"
size="sm"
disabled={data.page >= totalPages}
onclick={() => goToPage(data.page + 1)}
class="border-primary/20 hover:bg-primary/10"
>
{$_("common.next")}
</Button>
>{$_("common.next")}</Button>
</div>
<p class="text-sm text-muted-foreground">
{$_("common.total_results", { values: { total: data.total } })}
</p>
</div>
{/if}
</div>

View File

@@ -1,4 +1,9 @@
import { redirect } from "@sveltejs/kit";
export async function load({ locals }) {
if (locals.authStatus?.authenticated) {
redirect(302, "/me");
}
return {
authStatus: locals.authStatus,
};

View File

@@ -17,6 +17,7 @@
import DeviceMappingDialog from "./components/device-mapping-dialog.svelte";
import type { BluetoothDevice, RecordedEvent, DeviceInfo } from "$lib/types";
import { toast } from "svelte-sonner";
import SexyBackground from "$lib/components/background/background.svelte";
const client = new ButtplugClient("Sexy.Art");
let connected = $state(client.connected);
@@ -378,18 +379,7 @@
<div
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
>
<!-- Global Plasma Background -->
<div class="absolute inset-0 pointer-events-none">
<div
class="absolute top-40 left-1/4 w-80 h-80 bg-gradient-to-r from-primary/16 via-accent/20 to-primary/12 rounded-full blur-3xl animate-blob-slow"
></div>
<div
class="absolute bottom-40 right-1/4 w-96 h-96 bg-gradient-to-r from-accent/16 via-primary/20 to-accent/12 rounded-full blur-3xl animate-blob-slow animation-delay-5000"
></div>
<div
class="absolute top-1/3 right-1/3 w-64 h-64 bg-gradient-to-r from-primary/14 via-accent/18 to-primary/10 rounded-full blur-2xl animate-blob-reverse animation-delay-2500"
></div>
</div>
<SexyBackground />
<div class="container mx-auto py-20 relative px-4">
<div class="max-w-4xl mx-auto">

View File

@@ -1,4 +1,9 @@
import { redirect } from "@sveltejs/kit";
export async function load({ locals }) {
if (locals.authStatus?.authenticated) {
redirect(302, "/me");
}
return {
authStatus: locals.authStatus,
};

View File

@@ -6,6 +6,8 @@
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
import { getAssetUrl } from "$lib/api";
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 categoryFilter = $state("all");
@@ -52,35 +54,13 @@
<div
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
>
<!-- Global Plasma Background -->
<div class="absolute inset-0 pointer-events-none">
<div
class="absolute top-40 left-1/4 w-80 h-80 bg-gradient-to-r from-primary/16 via-accent/20 to-primary/12 rounded-full blur-3xl animate-blob-slow"
></div>
<div
class="absolute bottom-40 right-1/4 w-96 h-96 bg-gradient-to-r from-accent/16 via-primary/20 to-accent/12 rounded-full blur-3xl animate-blob-slow animation-delay-5000"
></div>
<div
class="absolute top-1/3 right-1/3 w-64 h-64 bg-gradient-to-r from-primary/14 via-accent/18 to-primary/10 rounded-full blur-2xl animate-blob-reverse animation-delay-2500"
></div>
</div>
<SexyBackground />
<section class="relative py-20 overflow-hidden">
<div class="relative container mx-auto px-4 text-center">
<div class="max-w-5xl mx-auto">
<h1
class="text-5xl md:text-7xl font-bold mb-8 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
<PageHero
title={$_("tags.title", { values: { tag: data.tag } })}
description={$_("tags.description", { values: { tag: data.tag } })}
>
{$_("tags.title", { values: { tag: data.tag } })}
</h1>
<p
class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto"
>
{$_("tags.description", { values: { tag: data.tag } })}
</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"
@@ -91,12 +71,8 @@
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"
>
<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")
@@ -114,15 +90,14 @@
</SelectContent>
</Select>
</div>
</div>
</div>
</section>
</PageHero>
<!-- Items Grid -->
<div class="container mx-auto px-4 py-12">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{#each filteredItems() as item (item.slug)}
<a href={getUrlForItem(item)} class="block group">
<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">
<img
@@ -148,49 +123,20 @@
<h3 class="font-semibold text-lg mb-1 group-hover:text-primary transition-colors">
{item.title}
</h3>
<!-- <div
class="flex items-center gap-4 text-sm text-muted-foreground"
>
<div class="flex items-center gap-1">
<StarIcon class="w-4 h-4 text-yellow-500 fill-current" />
{model.rating}
</div>
<div>{model.subscribers} followers</div>
</div> -->
</div>
</div>
<!-- Tags -->
<div class="flex flex-wrap gap-2 mb-4">
<div class="flex flex-wrap gap-2">
{#each item.tags as tag (tag)}
<a
class="text-xs bg-primary/10 text-primary px-2 py-1 rounded-full"
href="/tags/{tag}"
>
<span class="text-xs bg-primary/10 text-primary px-2 py-1 rounded-full">
{tag}
</a>
</span>
{/each}
</div>
<!-- Action Buttons -->
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
class="flex-1 border-primary/20 hover:bg-primary/10"
href={getUrlForItem(item)}
>{$_("tags.view", {
values: { category: item.category },
})}</Button
>
<!-- <Button
size="sm"
class="flex-1 bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>{$_("tags.follow")}</Button
> -->
</div>
</CardContent>
</Card>
</a>
{/each}
</div>

View File

@@ -9,6 +9,8 @@
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
import { getAssetUrl } from "$lib/api";
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 { formatVideoDuration } from "$lib/utils";
@@ -45,6 +47,21 @@
}
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>
<Meta title={$_("videos.title")} description={$_("videos.description")} />
@@ -52,37 +69,9 @@
<div
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
>
<!-- Global Plasma Background -->
<div class="absolute inset-0 pointer-events-none">
<div
class="absolute top-40 left-1/4 w-80 h-80 bg-gradient-to-r from-primary/16 via-accent/20 to-primary/12 rounded-full blur-3xl animate-blob-slow"
></div>
<div
class="absolute bottom-40 right-1/4 w-96 h-96 bg-gradient-to-r from-accent/16 via-primary/20 to-accent/12 rounded-full blur-3xl animate-blob-slow animation-delay-5000"
></div>
<div
class="absolute top-1/3 right-1/3 w-64 h-64 bg-gradient-to-r from-primary/14 via-accent/18 to-primary/10 rounded-full blur-2xl animate-blob-reverse animation-delay-2500"
></div>
</div>
<SexyBackground />
<section class="relative py-20 overflow-hidden">
<div
class="absolute inset-0 bg-gradient-to-br from-primary/10 via-accent/5 to-background"
></div>
<div class="relative container mx-auto px-4 text-center">
<div class="max-w-5xl mx-auto">
<h1
class="text-5xl md:text-7xl font-bold mb-8 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
>
{$_("videos.title")}
</h1>
<p
class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto"
>
{$_("videos.description")}
</p>
<!-- Filters -->
<PageHero title={$_("videos.title")} description={$_("videos.description")}>
<div class="flex flex-col lg:flex-row gap-4 max-w-6xl mx-auto">
<!-- Search -->
<div class="relative flex-1">
@@ -147,21 +136,20 @@
</SelectContent>
</Select>
</div>
</div>
</div>
</section>
</PageHero>
<!-- Videos Grid -->
<div class="container mx-auto px-4 py-12">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{#each data.items as video (video.slug)}
<a href={`/videos/${video.slug}`} class="block group">
<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">
<img
src={getAssetUrl(video.image, "preview")}
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 -->
@@ -196,17 +184,16 @@
{/if}
<!-- Play Overlay -->
<a
<div
class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
href={`/videos/${video.slug}`}
aria-label={$_("videos.watch")}
aria-hidden="true"
>
<div
class="w-16 h-16 bg-primary/90 rounded-full flex flex-col items-center justify-center shadow-2xl"
>
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white"></span>
</div>
</a>
</div>
<!-- Model Info -->
<!-- <div class="absolute bottom-3 right-3 text-white text-sm">
@@ -250,27 +237,9 @@
</span> -->
</div>
<!-- Action Buttons -->
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
class="flex-1 border-primary/20 hover:bg-primary/10"
href={`/videos/${video.slug}`}
>
<span class="icon-[ri--play-large-fill] w-4 h-4 mr-2"></span>
{$_("videos.watch")}
</Button>
<!-- <Button
variant="ghost"
size="sm"
class="px-3 hover:bg-primary/10"
>
<HeartIcon class="w-4 h-4" />
</Button> -->
</div>
</CardContent>
</Card>
</a>
{/each}
</div>
@@ -287,32 +256,40 @@
<!-- Pagination -->
{#if totalPages > 1}
<div class="flex items-center justify-between mt-10">
<span class="text-sm text-muted-foreground">
{$_("common.page_of", { values: { page: data.page, total: totalPages } })}
&nbsp;·&nbsp;
{$_("common.total_results", { values: { total: data.total } })}
</span>
<div class="flex gap-2">
<div class="flex flex-col items-center gap-3 mt-10">
<div class="flex items-center gap-1">
<Button
variant="outline"
size="sm"
disabled={data.page <= 1}
onclick={() => goToPage(data.page - 1)}
class="border-primary/20 hover:bg-primary/10"
>
{$_("common.previous")}
</Button>
>{$_("common.previous")}</Button>
{#each pageNumbers() as p}
{#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
variant="outline"
size="sm"
disabled={data.page >= totalPages}
onclick={() => goToPage(data.page + 1)}
class="border-primary/20 hover:bg-primary/10"
>
{$_("common.next")}
</Button>
>{$_("common.next")}</Button>
</div>
<p class="text-sm text-muted-foreground">
{$_("common.total_results", { values: { total: data.total } })}
</p>
</div>
{/if}
</div>

View File

@@ -31,7 +31,6 @@
let isLiked = $state(data.likeStatus.liked);
let likesCount = $state(data.video.likes_count || 0);
let isLikeLoading = $state(false);
let isBookmarked = $state(false);
let newComment = $state("");
let showComments = $state(true);
let isCommentLoading = $state(false);
@@ -40,41 +39,6 @@
let currentPlayId = $state<string | null>(null);
let lastTrackedTime = $state(0);
const _relatedVideos = [
{
id: 2,
title: "Sunset Dreams",
thumbnail: "/placeholder.svg?size=wide",
duration: "8:45",
views: "1.8M",
model: "Luna Belle",
},
{
id: 3,
title: "Intimate Moments",
thumbnail: "/placeholder.svg?size=wide",
duration: "15:22",
views: "3.2M",
model: "Aria Divine",
},
{
id: 4,
title: "Morning Light",
thumbnail: "/placeholder.svg?size=wide",
duration: "10:15",
views: "956K",
model: "Maya Starlight",
},
{
id: 5,
title: "Passionate Dance",
thumbnail: "/placeholder.svg?size=wide",
duration: "7:33",
views: "1.4M",
model: "Zara Moon",
},
];
async function handleLike() {
if (!data.authStatus.authenticated) {
toast.error("Please sign in to like videos");
@@ -101,10 +65,6 @@
}
}
function _handleBookmark() {
isBookmarked = !isBookmarked;
}
async function handleDeleteComment(id: number) {
try {
await deleteComment(id);
@@ -289,16 +249,6 @@
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>
<!-- Model Info -->
@@ -329,15 +279,8 @@
</svg>
</div>
</a>
<!-- <p class="text-sm text-muted-foreground">
{data.video.model.subscribers} subscribers
</p> -->
</div>
</div>
<!-- <Button
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>Subscribe</Button
> -->
</div>
{/each}
</div>

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",
"short_name": "Sexy.Art",
"name": "Sexy",
"short_name": "Sexy",
"icons": [
{
"src": "/android-chrome-192x192.png",
@@ -13,8 +13,8 @@
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"theme_color": "#ce47eb",
"background_color": "#000000",
"display": "standalone",
"start_url": "/"
}