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:
@@ -223,6 +223,13 @@ export default {
|
|||||||
toast_reset: "Your password has been reset!",
|
toast_reset: "Your password has been reset!",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
profile: {
|
||||||
|
member_since: "Member since {date}",
|
||||||
|
comments: "Comments",
|
||||||
|
likes: "Likes",
|
||||||
|
edit: "Edit Profile",
|
||||||
|
activity: "Activity",
|
||||||
|
},
|
||||||
models: {
|
models: {
|
||||||
title: "Our Models",
|
title: "Our Models",
|
||||||
description:
|
description:
|
||||||
|
|||||||
45
packages/frontend/src/routes/users/[id]/+page.server.ts
Normal file
45
packages/frontend/src/routes/users/[id]/+page.server.ts
Normal 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, "/");
|
||||||
|
}
|
||||||
|
};
|
||||||
141
packages/frontend/src/routes/users/[id]/+page.svelte
Normal file
141
packages/frontend/src/routes/users/[id]/+page.svelte
Normal 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>
|
||||||
@@ -442,26 +442,28 @@ let showPlayer = $state(false);
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
{#each data.comments as comment}
|
{#each data.comments as comment}
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<Avatar
|
<a href="/users/{comment.user_created.id}" class="flex-shrink-0">
|
||||||
class="h-8 w-8 ring-2 ring-accent/20 transition-all duration-200"
|
<Avatar
|
||||||
>
|
class="h-8 w-8 ring-2 ring-accent/20 hover:ring-primary/40 transition-all duration-200 cursor-pointer"
|
||||||
<AvatarImage
|
|
||||||
src={getAssetUrl(
|
|
||||||
comment.user_created.avatar as string,
|
|
||||||
'mini'
|
|
||||||
)}
|
|
||||||
alt={comment.user_created.artist_name}
|
|
||||||
/>
|
|
||||||
<AvatarFallback
|
|
||||||
class="bg-gradient-to-br from-primary to-accent text-primary-foreground text-xs font-semibold transition-all duration-200"
|
|
||||||
>
|
>
|
||||||
{getUserInitials(data.authStatus.user!.artist_name)}
|
<AvatarImage
|
||||||
</AvatarFallback>
|
src={getAssetUrl(
|
||||||
</Avatar>
|
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-1">
|
||||||
<div class="flex items-center gap-2 mb-1">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<span class="font-medium text-sm"
|
<a href="/users/{comment.user_created.id}" class="font-medium text-sm hover:text-primary transition-colors"
|
||||||
>{comment.user_created.artist_name}</span
|
>{comment.user_created.artist_name}</a
|
||||||
>
|
>
|
||||||
<span class="text-xs text-muted-foreground"
|
<span class="text-xs text-muted-foreground"
|
||||||
>{timeAgo.format(
|
>{timeAgo.format(
|
||||||
|
|||||||
Reference in New Issue
Block a user