feat(member): integrate dynamic member list with server-side data and utility helpers
All checks were successful
Deploy Flowstate to VPS / deploy (push) Successful in 49s

This commit is contained in:
Chneemann 2026-08-18 12:01:57 +02:00
parent ca01a3b44e
commit 86d1c8859d
No known key found for this signature in database
8 changed files with 306 additions and 6 deletions

View file

@ -0,0 +1,30 @@
/**
* @file member/MemberHeader.tsx
* @description Client component rendering the header section for the team members page.
*/
"use client";
import { UsersRound } from "lucide-react";
/**
* Renders the members page header featuring title details, workspace subtitle, and an indicator icon.
*
* @returns {JSX.Element} The rendered members header component.
*/
export default function MemberHeader() {
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>
<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>
);
}

View file

@ -0,0 +1,95 @@
/**
* @file member/list/MemberItem.tsx
* @description Client component rendering an individual member item card displaying avatar initials, full name, role badge, email address, online status indicator, and last active timestamp.
*/
"use client";
import {
getFullName,
getInitials,
getStatusColor,
formatTimeAgo,
} from "@/utils/user";
import { Mail } from "lucide-react";
import { UserListItem } from "@/types/user";
/**
* Properties for the MemberItem component.
*
* @interface MemberItemProps
* @property {UserListItem} member - The member user object containing profile and status information.
*/
interface MemberItemProps {
member: UserListItem;
}
/**
* Renders a member list item displaying user credentials, email, dynamic color accent stripe based on online state, and activity status.
*
* @param {MemberItemProps} props - The component props.
* @returns {JSX.Element} The rendered member item component.
*/
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">
{/* Dynamic colored accent stripe */}
<div
className={`absolute left-0 top-0 bottom-0 w-1 transition-colors ${
member.isOnline
? "bg-emerald-500/60 group-hover:bg-emerald-500"
: "bg-slate-600/60 group-hover:bg-slate-500"
}`}
/>
{/* --- Left Side --- */}
<div className="flex items-center gap-3.5">
<div
className={`w-11 h-11 rounded-xl flex items-center justify-center text-sm font-black tracking-wider text-white shadow-md 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">
<div className="flex items-center gap-2.5">
<h3 className="font-semibold text-base text-foreground group-hover:text-primary transition-colors tracking-tight">
{getFullName(member.firstName, member.lastName)}
</h3>
<span className="text-[10px] px-2 py-0.5 rounded-md font-semibold border border-border/60 bg-card/60 text-foreground-muted">
Member
</span>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-foreground-muted font-mono">
<span className="inline-flex items-center gap-1.5">
<Mail size={12} className="text-foreground-muted/70" />
{member.email}
</span>
</div>
</div>
</div>
{/* --- Right Side --- */}
<div className="flex sm:flex-col flex-row sm:items-end items-center justify-between sm:justify-center gap-2">
<div
className="flex items-center gap-2 px-2.5 py-1 rounded-full bg-card/60 border border-border/60 shadow-xs"
title={member.isOnline ? "Online" : "Offline"}
>
<span
className={`w-2.5 h-2.5 rounded-full shadow-lg ${getStatusColor(
member.isOnline,
)}`}
/>
<span className="text-xs font-medium text-foreground-muted">
{member.isOnline ? "Online" : "Offline"}
</span>
</div>
<span className="text-[11px] text-foreground-muted/70 font-mono">
{formatTimeAgo(member.lastLogin)}
</span>
</div>
</div>
);
}

View file

@ -0,0 +1,54 @@
/**
* @file member/list/MemberList.tsx
* @description Client component rendering the list of team members along with the section header and an empty state fallback.
*/
"use client";
import MemberHeader from "../MemberHeader";
import { UserListItem } from "@/types/user";
import MemberItem from "./MemberItem";
/**
* Properties for the MembersList component.
*
* @interface MemberListProps
* @property {UserListItem[]} members - Array of user items to display in the list.
*/
interface MemberListProps {
members: UserListItem[];
}
/**
* Renders the member directory section including the section header, a responsive list of team members,
* or an empty state message if no members are available.
*
* @param {MembersListProps} props - The component props.
* @returns {JSX.Element} The rendered members list section component.
*/
export default function MemberList({ members }: MemberListProps) {
return (
<div className="space-y-6 mx-auto">
<MemberHeader />
{members.length === 0 ? (
<div className="text-center py-20 border-2 border-dashed border-border/50 rounded-3xl bg-card/20 space-y-2">
<p className="text-sm font-medium text-foreground-muted">
No team members found.
</p>
<p className="text-xs text-foreground-muted/65">
There are no users registered yet.
</p>
</div>
) : (
<ul className="grid gap-4 list-none">
{members.map((member) => (
<li key={member.id}>
<MemberItem member={member} />
</li>
))}
</ul>
)}
</div>
);
}

23
app/(app)/member/page.tsx Normal file
View file

@ -0,0 +1,23 @@
/**
* @file member/page.tsx
* @description Server component rendering the members directory page by fetching all registered users and passing them to the MemberList component.
*/
import MemberList from "./list/MemberList";
import { UserService } from "@/services/user.service";
/**
* Renders the member management page displaying all registered users in a list container.
*
* @async
* @returns {Promise<JSX.Element>} The rendered member page component.
*/
export default async function MemberPage() {
const users = await UserService.findAllUsers();
return (
<div className="space-y-8 max-w-7xl mx-auto pb-12">
<MemberList members={users} />
</div>
);
}

View file

@ -7,7 +7,13 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { LayoutDashboard, FileText, Trash2, FilePenLine } from "lucide-react";
import {
LayoutDashboard,
FileText,
Trash2,
FilePenLine,
Users,
} from "lucide-react";
import useSWR from "swr";
import MobileLegalMenu from "./MobileLegalMenu";
@ -26,6 +32,7 @@ const navItems = [
{ name: "Summary", href: "/summary", icon: FileText },
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ name: "Add Task", href: "/tasks", icon: FilePenLine },
{ name: "Member", href: "/member", icon: Users },
{ name: "Trash", href: "/trash", icon: Trash2 },
];

View file

@ -5,18 +5,18 @@
import { db } from "@/db";
import { usersTable } from "@/db/schema";
import { eq } from "drizzle-orm";
import { eq, desc, 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) for a user by ID.
* Retrieves specific profile information (firstName, lastName, color, online status, last login) 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.
* @returns {Promise<{ firstName: string; lastName: 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
@ -32,4 +32,25 @@ export class UserService {
return user || null;
}
/**
* Retrieves a list of all users sorted by their last login date in descending order, putting 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.
*/
static async findAllUsers() {
return await db
.select({
id: usersTable.id,
firstName: usersTable.firstName,
lastName: usersTable.lastName,
email: usersTable.email,
color: usersTable.color,
lastLogin: usersTable.lastLogin,
isOnline: usersTable.isOnline,
})
.from(usersTable)
.orderBy(sql`${usersTable.lastLogin} DESC NULLS LAST`);
}
}

View file

@ -4,6 +4,7 @@
*/
import { type User as DbUser } from "@/db/schema";
import { UserService } from "@/services/user.service";
// ==========================================
// Types
@ -11,6 +12,9 @@ import { type User as DbUser } from "@/db/schema";
export type { DbUser };
export type UserColor = (typeof AVAILABLE_COLORS)[number];
export type UserListItem = Awaited<
ReturnType<typeof UserService.findAllUsers>
>[number];
// ==========================================
// UI Configurations

View file

@ -1,17 +1,83 @@
/**
* @file utils/user.ts
* @description Utility functions for user formatting and initials generation.
* @description Utility functions for user formatting, initials generation, and name capitalization.
*/
/**
* Generates the full name with properly capitalized first and last names.
*
* @param {string} [firstName] - The user's first name.
* @param {string} [lastName] - The user's last name.
* @returns {string} The formatted full name.
*/
export function getFullName(firstName?: string, lastName?: string): string {
const format = (str?: string) =>
str ? str.charAt(0).toUpperCase() + str.slice(1).toLowerCase() : "";
return `${format(firstName)} ${format(lastName)}`.trim();
}
/**
* Generates uppercase initials from a user's first and last name.
*
* @param {string} [firstName] - The user's first name.
* @param {string} [lastName] - The user's last name.
* @returns {string} The computed initials (e.g., "JD") or a question mark if neither is provided.
* @returns {string} The computed initials (e.g., "JD") or a fallback question mark "?" if neither is provided.
*/
export function getInitials(firstName?: string, lastName?: string): string {
const first = firstName?.[0] || "";
const last = lastName?.[0] || "";
return `${first}${last}`.toUpperCase() || "?";
}
/**
* Returns the CSS classes for the user's online status indicator.
*
* @param {boolean} isOnline - Whether the user is currently online.
* @returns {string} Tailwind CSS classes representing the status dot indicator.
*/
export function getStatusColor(isOnline: boolean): string {
return isOnline
? "bg-emerald-500 shadow-emerald-500/50"
: "bg-slate-600 shadow-none";
}
/**
* Formats a given login date into a human-readable relative time string (e.g., "2 days ago").
*
* @param {Date | string | null} [dateInput] - The login date to format.
* @returns {string} A relative time string formatted via Intl.RelativeTimeFormat, or a fallback message if no date is provided.
*/
export function formatTimeAgo(dateInput?: Date | string | null): string {
if (!dateInput) return "Never logged in";
const date = new Date(dateInput);
const now = new Date();
const diffInSeconds = Math.round((date.getTime() - now.getTime()) / 1000);
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
const intervals: { limit: number; unit: Intl.RelativeTimeFormatUnit }[] = [
{ limit: 60, unit: "second" },
{ limit: 3600, unit: "minute" },
{ limit: 86400, unit: "hour" },
{ limit: 2592000, unit: "day" },
{ limit: 31536000, unit: "month" },
{ limit: Infinity, unit: "year" },
];
const divisors = [1, 60, 3600, 86400, 2592000, 31536000];
for (let i = 0; i < intervals.length; i++) {
const divisor = divisors[i];
if (
Math.abs(diffInSeconds) < intervals[i].limit ||
i === intervals.length - 1
) {
const value = Math.round(diffInSeconds / divisor);
return rtf.format(value, intervals[i].unit);
}
}
return "Never logged in";
}