feat: implement user profile pages with comment avatar links

Added user profile feature allowing authenticated users to view profiles of other users. Key changes:

**New Routes:**
- `/users/[id]/+page.server.ts` - Server-side load function with authentication guard and user data fetching
- `/users/[id]/+page.svelte` - User profile UI component displaying avatar, stats, and bio

**Features:**
- Authentication required - redirects to /login if not authenticated
- Shows user display name (first_name + last_name or email fallback)
- Displays join date, location, and description
- Statistics: comments count and likes count
- "Edit Profile" button visible only for own profile (links to /me)
- Responsive layout with avatar placeholder for users without profile images

**Comment Integration:**
- Updated video comment section to link user avatars to their profiles
- Added hover effects on avatars (ring-primary/40 transition)
- Username in comments now clickable and links to `/users/[id]`

**Translations:**
- Added `profile` section to en.ts locales with:
  - member_since: "Member since {date}"
  - comments: "Comments"
  - likes: "Likes"
  - edit: "Edit Profile"
  - activity: "Activity"

**Design:**
- Simplified layout (no cover banner) compared to model profiles
- Peony background with card-based UI
- Primary color theme with gradient accents
- Consistent with existing site design patterns

This creates a clear distinction between:
- Model profiles (`/models/[slug]`) - public, content-focused
- User profiles (`/users/[id]`) - authenticated only, viewer-focused

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Valknar XXX
2025-10-28 12:54:45 +01:00
parent 9503f8d0aa
commit 74e68c32dc
4 changed files with 212 additions and 17 deletions

View File

@@ -223,6 +223,13 @@ export default {
toast_reset: "Your password has been reset!",
},
},
profile: {
member_since: "Member since {date}",
comments: "Comments",
likes: "Likes",
edit: "Edit Profile",
activity: "Activity",
},
models: {
title: "Our Models",
description:

View File

@@ -0,0 +1,45 @@
import { redirect } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ params, locals, fetch }) => {
// Guard: Redirect to login if not authenticated
if (!locals.authStatus.authenticated) {
throw redirect(302, "/login");
}
const { id } = params;
try {
// Fetch user profile data from Directus
const userResponse = await fetch(`/api/users/${id}?fields=id,first_name,last_name,email,description,avatar,date_created,location`);
if (!userResponse.ok) {
throw redirect(404, "/");
}
const userData = await userResponse.json();
const user = userData.data;
// Fetch user's comments count
const commentsResponse = await fetch(`/api/comments?filter[user_created][_eq]=${id}&aggregate[count]=*`);
const commentsData = await commentsResponse.json();
const commentsCount = commentsData.data?.[0]?.count || 0;
// Fetch user's video likes count
const likesResponse = await fetch(`/api/items/sexy_video_likes?filter[user_id][_eq]=${id}&aggregate[count]=*`);
const likesData = await likesResponse.json();
const likesCount = likesData.data?.[0]?.count || 0;
return {
user,
stats: {
comments_count: commentsCount,
likes_count: likesCount,
},
isOwnProfile: locals.authStatus.user?.id === id,
};
} catch (error) {
console.error("Failed to load user profile:", error);
throw redirect(404, "/");
}
};

View File

@@ -0,0 +1,141 @@
<script lang="ts">
import { _, locale } from "svelte-i18n";
import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card";
import { getAssetUrl } from "$lib/directus";
import Meta from "$lib/components/meta/meta.svelte";
import PeonyBackground from "$lib/components/background/peony-background.svelte";
const { data } = $props();
// Format user display name
let displayName = $derived(
data.user.first_name && data.user.last_name
? `${data.user.first_name} ${data.user.last_name}`
: data.user.email?.split("@")[0] || "User",
);
// Format join date
let joinDate = $derived(
new Date(data.user.date_created).toLocaleDateString($locale!, {
month: "long",
year: "numeric",
}),
);
</script>
<Meta
title={displayName}
description={data.user.description || `${displayName}'s profile`}
image={data.user.avatar ? getAssetUrl(data.user.avatar, "thumbnail") : undefined}
/>
<div
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5"
>
<PeonyBackground />
<div class="container mx-auto px-4 py-8 relative z-10">
<!-- Profile Card -->
<Card
class="max-w-3xl mx-auto bg-card/90 backdrop-blur-sm border-border/50"
>
<CardContent class="p-6 md:p-8">
<!-- Header with Back Button -->
<div class="flex items-center justify-between mb-6">
<Button
variant="ghost"
size="sm"
href="/"
class="text-muted-foreground hover:text-foreground"
>
<span class="icon-[ri--arrow-left-line] w-4 h-4 mr-2"></span>
{$_("common.back")}
</Button>
{#if data.isOwnProfile}
<Button variant="outline" size="sm" href="/me">
<span class="icon-[ri--settings-3-line] w-4 h-4 mr-2"></span>
{$_("profile.edit")}
</Button>
{/if}
</div>
<!-- Profile Content -->
<div class="flex flex-col md:flex-row gap-6 items-start">
<!-- Avatar -->
<div class="flex-shrink-0">
{#if data.user.avatar}
<img
src={getAssetUrl(data.user.avatar, "thumbnail")}
alt={displayName}
class="w-24 h-24 rounded-2xl object-cover ring-4 ring-primary/20"
/>
{:else}
<div
class="w-24 h-24 rounded-2xl bg-primary/10 flex items-center justify-center ring-4 ring-primary/20"
>
<span class="text-3xl font-bold text-primary">
{displayName.charAt(0).toUpperCase()}
</span>
</div>
{/if}
</div>
<!-- Info -->
<div class="flex-1">
<h1 class="text-3xl font-bold mb-2">{displayName}</h1>
<div
class="flex items-center gap-2 text-muted-foreground mb-4"
>
<span class="icon-[ri--calendar-line] w-4 h-4"></span>
<span
>{$_("profile.member_since", {
values: { date: joinDate },
})}</span
>
</div>
{#if data.user.location}
<div
class="flex items-center gap-2 text-muted-foreground mb-4"
>
<span class="icon-[ri--map-pin-line] w-4 h-4"></span>
<span>{data.user.location}</span>
</div>
{/if}
{#if data.user.description}
<p class="text-muted-foreground mb-4">
{data.user.description}
</p>
{/if}
<!-- Statistics -->
<div
class="grid grid-cols-2 gap-4 pt-4 border-t border-border/50"
>
<div class="text-center">
<div class="text-2xl font-bold text-primary">
{data.stats.comments_count}
</div>
<div class="text-sm text-muted-foreground">
{$_("profile.comments")}
</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-primary">
{data.stats.likes_count}
</div>
<div class="text-sm text-muted-foreground">
{$_("profile.likes")}
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>

View File

@@ -442,26 +442,28 @@ let showPlayer = $state(false);
<div class="space-y-4">
{#each data.comments as comment}
<div class="flex gap-3">
<Avatar
class="h-8 w-8 ring-2 ring-accent/20 transition-all duration-200"
>
<AvatarImage
src={getAssetUrl(
comment.user_created.avatar as string,
'mini'
)}
alt={comment.user_created.artist_name}
/>
<AvatarFallback
class="bg-gradient-to-br from-primary to-accent text-primary-foreground text-xs font-semibold transition-all duration-200"
<a href="/users/{comment.user_created.id}" class="flex-shrink-0">
<Avatar
class="h-8 w-8 ring-2 ring-accent/20 hover:ring-primary/40 transition-all duration-200 cursor-pointer"
>
{getUserInitials(data.authStatus.user!.artist_name)}
</AvatarFallback>
</Avatar>
<AvatarImage
src={getAssetUrl(
comment.user_created.avatar as string,
'mini'
)}
alt={comment.user_created.artist_name}
/>
<AvatarFallback
class="bg-gradient-to-br from-primary to-accent text-primary-foreground text-xs font-semibold transition-all duration-200"
>
{getUserInitials(comment.user_created.artist_name)}
</AvatarFallback>
</Avatar>
</a>
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-sm"
>{comment.user_created.artist_name}</span
<a href="/users/{comment.user_created.id}" class="font-medium text-sm hover:text-primary transition-colors"
>{comment.user_created.artist_name}</a
>
<span class="text-xs text-muted-foreground"
>{timeAgo.format(