feat(member): implement member profile details page and routing
All checks were successful
Deploy Flowstate to VPS / deploy (push) Successful in 47s

This commit is contained in:
Chneemann 2026-08-18 12:48:33 +02:00
parent 86d1c8859d
commit a457ad19da
No known key found for this signature in database
5 changed files with 172 additions and 16 deletions

View file

@ -5,7 +5,9 @@
"use client";
import { GoBackButton } from "@/app/components/ui/buttons/GoBackButton";
import { UsersRound } from "lucide-react";
import { usePathname } from "next/navigation";
/**
* Renders the members page header featuring title details, workspace subtitle, and an indicator icon.
@ -13,18 +15,27 @@ import { UsersRound } from "lucide-react";
* @returns {JSX.Element} The rendered members header component.
*/
export default function MemberHeader() {
const pathname = usePathname();
const isRootPage = pathname === "/member";
return (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<div className="flex items-center gap-2 text-primary font-medium text-xs uppercase tracking-wider mb-1">
<UsersRound size={14} />
Team
<>
<div className="flex items-center gap-3 mb-4">
<div>
<div className="flex items-center gap-2 text-primary font-medium text-xs uppercase tracking-wider mb-1">
<UsersRound size={14} />
Team
</div>
<h1 className="text-3xl font-extrabold tracking-tight">Member</h1>
<p className="text-sm text-foreground-muted mt-1">
Manage your team members and collaborate efficiently.
</p>
</div>
<h1 className="text-3xl font-extrabold tracking-tight">Member</h1>
<p className="text-sm text-foreground-muted mt-1">
Manage your team members and collaborate efficiently.
</p>
</div>
</div>
{!isRootPage && (
<GoBackButton fallbackUrl="/member" label="Member List" />
)}
</>
);
}

View file

@ -0,0 +1,96 @@
/**
* @file MemberProfileCard.tsx
* @description Client component rendering detailed profile information and activity metrics for a specific team member.
*/
"use client";
import { getFullName, getInitials, formatTimeAgo } from "@/utils/user";
import { Mail, Clock, ShieldCheck } from "lucide-react";
import { UserListItem } from "@/types/user";
/**
* Properties for the MemberProfileCard component.
*
* @interface MemberProfileCardProps
* @property {UserListItem} member - The user item containing detailed profile data, activity status, and credentials.
*/
interface MemberProfileCardProps {
member: UserListItem;
}
/**
* Renders a detailed profile card for a team member, including user avatar initials, online status indicators,
* contact information, and activity metrics.
*
* @param {MemberProfileCardProps} props - The component props.
* @returns {JSX.Element} The rendered member profile card component.
*/
export default function MemberProfileCard({ member }: MemberProfileCardProps) {
const stats = [
{
label: "Last Activity",
value: formatTimeAgo(member.lastLogin),
icon: Clock,
color: "text-blue-400 bg-blue-500/10 border-blue-500/20",
},
{
label: "Account Status",
value: "Active - Member",
icon: ShieldCheck,
color: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20",
},
];
return (
<div className="relative bg-card/40 border border-border/80 rounded-3xl p-4 sm:p-6 shadow-sm overflow-hidden">
{/* Accent Bar */}
<div
className={`absolute left-0 top-0 bottom-0 w-1.5 transition-colors ${member.isOnline ? "bg-emerald-500" : "bg-slate-600"}`}
/>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 pl-2">
<div className="flex items-center gap-5">
<div
className={`w-16 h-16 shrink-0 rounded-2xl flex items-center justify-center text-xl font-black tracking-wider text-white shadow-xl border-2 border-card ring-2 ring-border/50 ${member.color}`}
style={{ textShadow: "0 1px 2px rgba(0, 0, 0, 0.8)" }}
>
{getInitials(member.firstName, member.lastName)}
</div>
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight text-foreground">
{getFullName(member.firstName, member.lastName)}
</h1>
<div className="flex items-center gap-2 text-foreground-muted font-mono text-xs sm:text-sm">
<Mail size={14} className="shrink-0 text-foreground-muted/70" />
<span className="truncate">{member.email}</span>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4 pt-4 border-t border-border/60 pl-2">
{stats.map((stat) => (
<div
key={stat.label}
className="flex items-center gap-4 p-4 rounded-2xl bg-card/30 border border-border/60 hover:border-primary/40 transition-colors"
>
<div
className={`w-12 h-12 shrink-0 rounded-xl border flex items-center justify-center ${stat.color}`}
>
<stat.icon size={22} />
</div>
<div className="flex flex-col gap-0.5 min-w-0">
<p className="text-[11px] uppercase tracking-wider text-foreground-muted font-mono font-semibold">
{stat.label}
</p>
<p className="text-sm font-semibold text-foreground truncate">
{stat.value}
</p>
</div>
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,43 @@
/**
* @file member/[id]/page.tsx
* @description Server component rendering the detailed profile page for a specific member by fetching their user profile data.
*/
import { UserService } from "@/services/user.service";
import { notFound } from "next/navigation";
import MemberHeader from "../MemberHeader";
import MemberProfileCard from "./MemberProfileCard";
/**
* Properties for the MemberDetailPage component.
*
* @interface MemberDetailPageProps
* @property {Promise<{ id: string }>} params - Promise containing route context parameters with the target member ID.
*/
interface MemberDetailPageProps {
params: Promise<{ id: string }>;
}
/**
* Renders the member detail page by resolving the route parameter, fetching the user profile by ID,
* and displaying their header and profile card or triggering a 404 page if the member is not found.
*
* @async
* @param {MemberDetailPageProps} props - The component props.
* @returns {Promise<JSX.Element>} The rendered member detail page component.
*/
export default async function MemberDetailPage({
params,
}: MemberDetailPageProps) {
const { id } = await params;
const user = await UserService.findProfileById(id);
if (!user) notFound();
return (
<div className="space-y-6 max-w-4xl mx-auto pb-12">
<MemberHeader />
<MemberProfileCard member={user} />
</div>
);
}

View file

@ -13,6 +13,7 @@ import {
} from "@/utils/user";
import { Mail } from "lucide-react";
import { UserListItem } from "@/types/user";
import Link from "next/link";
/**
* Properties for the MemberItem component.
@ -32,7 +33,10 @@ interface MemberItemProps {
*/
export default function MemberItem({ member }: MemberItemProps) {
return (
<div className="group relative flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 bg-card/40 border border-border/80 rounded-2xl hover:border-primary/40 hover:bg-card/70 transition-all duration-200 shadow-sm overflow-hidden">
<Link
href={`/member/${member.id}`}
className="group relative flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 bg-card/40 border border-border/80 rounded-2xl hover:border-primary/40 hover:bg-card/70 transition-all duration-200 shadow-sm overflow-hidden"
>
{/* Dynamic colored accent stripe */}
<div
className={`absolute left-0 top-0 bottom-0 w-1 transition-colors ${
@ -90,6 +94,6 @@ export default function MemberItem({ member }: MemberItemProps) {
{formatTimeAgo(member.lastLogin)}
</span>
</div>
</div>
</Link>
);
}

View file

@ -5,24 +5,26 @@
import { db } from "@/db";
import { usersTable } from "@/db/schema";
import { eq, desc, sql } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
/**
* Service class for handling user operations and database interactions.
*/
export class UserService {
/**
* Retrieves specific profile information (firstName, lastName, color, online status, last login) for a user by ID.
* Retrieves specific profile information for a user by ID.
*
* @async
* @param {string} userId - The unique identifier of the user.
* @returns {Promise<{ firstName: string; lastName: string; color: string; isOnline: boolean; lastLogin: Date } | null>} The user profile data or null if not found.
* @returns {Promise<{ id: string; firstName: string; lastName: string; email: string; color: string; isOnline: boolean; lastLogin: Date } | null>} The user profile data or null if not found.
*/
static async findProfileById(userId: string) {
const [user] = await db
.select({
id: usersTable.id,
firstName: usersTable.firstName,
lastName: usersTable.lastName,
email: usersTable.email,
color: usersTable.color,
isOnline: usersTable.isOnline,
lastLogin: usersTable.lastLogin,
@ -34,7 +36,7 @@ export class UserService {
}
/**
* Retrieves a list of all users sorted by their last login date in descending order, putting null values last.
* Retrieves a list of all users sorted by their last login date in descending order, placing null values last.
*
* @async
* @returns {Promise<Array<{ id: string; firstName: string; lastName: string; email: string; color: string; lastLogin: Date; isOnline: boolean }>>} An array of user list items.