feat: improve profile navigation and responsive privacy UX

This commit is contained in:
2026-09-29 15:26:28 +03:30
parent 61698ccbd9
commit bbf924c22d
25 changed files with 1353 additions and 764 deletions

143
src/views/PublicProfile.tsx Normal file
View File

@@ -0,0 +1,143 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import Image from "next/image";
import { CalendarDays, EyeOff, UserRound } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Helmet } from "@/lib/helmet";
import { Link, useParams } from "@/lib/router";
import { api } from "@/lib/api";
import { formatJalali, formatNumberPersian, toPersianDigits } from "@/lib/utils";
import type { PublicProfileEventSchema } from "@/lib/types";
function EventCard({ event }: { event: PublicProfileEventSchema }) {
return (
<Link to={`/events/${event.slug}`} className="group flex min-h-28 gap-4 rounded-2xl border border-border/70 bg-background p-3 text-right transition-colors hover:bg-muted/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{event.image_url ? <Image src={event.image_url} alt="" width={160} height={120} unoptimized className="aspect-[4/3] w-24 shrink-0 rounded-xl object-cover sm:w-32" /> : (
<span className="flex aspect-[4/3] w-24 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary sm:w-32"><CalendarDays className="h-6 w-6" aria-hidden="true" /></span>
)}
<span className="flex min-w-0 flex-1 flex-col gap-2 py-1">
<span className="font-semibold text-foreground group-hover:text-primary">{event.title}</span>
<span className="text-xs text-muted-foreground">{formatJalali(event.start_time, false)}</span>
</span>
</Link>
);
}
export default function PublicProfile() {
const { username: rawUsername } = useParams<{ username: string }>();
const username = Array.isArray(rawUsername) ? rawUsername[0] : rawUsername;
const profileQuery = useQuery({
queryKey: ["public-profile", username],
queryFn: () => api.getPublicProfile(username),
enabled: Boolean(username),
retry: false,
});
const profile = profileQuery.data;
if (profileQuery.isLoading) {
return <main className="flex min-h-[70vh] items-center justify-center text-muted-foreground" dir="rtl">در حال بارگذاری پروفایل عمومی...</main>;
}
if (profileQuery.isError || !profile) {
return (
<main className="container mx-auto max-w-4xl px-4 py-16" dir="rtl">
<Card className="border-border/70 text-center shadow-sm"><CardContent className="p-10">
<UserRound className="mx-auto h-8 w-8 text-muted-foreground" aria-hidden="true" />
<h1 className="mt-4 text-xl font-bold">این پروفایل در دسترس نیست</h1>
<p className="mt-2 text-sm text-muted-foreground">نام کاربری را بررسی کنید یا بعداً دوباره تلاش کنید.</p>
</CardContent></Card>
</main>
);
}
const fallback = `${profile.first_name?.[0] ?? ""}${profile.last_name?.[0] ?? ""}` || profile.username[0];
const maskedDetails = [
{ label: "شماره موبایل", value: profile.mobile },
{ label: "شماره دانشجویی", value: profile.student_id },
{ label: "دانشگاه", value: profile.university },
{ label: "رشته", value: profile.major },
{ label: "سال ورود", value: profile.year_of_study || null },
];
const pageDescription = profile.bio || `پروفایل عمومی ${profile.full_name} و رویدادهای او در انجمن علمی مهندسی کامپیوتر.`;
return (
<>
<Helmet>
<title>{`${profile.full_name} | پروفایل عمومی`}</title>
<meta name="description" content={pageDescription} />
<meta name="robots" content="index, follow" />
</Helmet>
<main className="min-h-screen bg-muted/15 px-4 py-8 md:py-12" dir="rtl">
<div className="container mx-auto max-w-6xl space-y-5">
<div className="grid gap-5 lg:grid-cols-12 lg:items-start">
<aside className="space-y-5 lg:col-span-4">
<Card className="border-border/70 shadow-sm">
<CardContent className="p-6 text-right">
<div className="flex items-center gap-4">
<Avatar className="h-20 w-20 border-2 border-background shadow-sm">
<AvatarImage src={profile.profile_picture || undefined} alt={profile.full_name} />
<AvatarFallback className="text-xl">{fallback}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<h1 className="truncate text-xl font-bold">{profile.full_name}</h1>
<p className="mt-1 text-sm text-muted-foreground">@{profile.username}</p>
</div>
</div>
{profile.bio ? <p className="mt-5 whitespace-pre-wrap text-sm leading-7 text-muted-foreground">{profile.bio}</p> : null}
<div className="mt-5 grid grid-cols-1 gap-3">
<div className="rounded-2xl border border-border/70 bg-muted/45 p-4 text-center shadow-sm dark:bg-background/70"><p className="text-xl font-bold">{formatNumberPersian(profile.events.length)}</p><p className="mt-1 text-xs text-muted-foreground">رویداد</p></div>
</div>
</CardContent>
</Card>
<Card className="border-border/70 shadow-sm">
<CardHeader className="pb-2 text-right"><CardTitle className="text-base">{profile.details_visible ? "اطلاعات بیشتر" : "اطلاعات خصوصی"}</CardTitle></CardHeader>
<CardContent className="space-y-1">
{profile.details_visible ? maskedDetails.map(({ label, value }) => (
<div key={label} className="flex items-center justify-between gap-4 border-b border-border/60 py-3 last:border-0 text-sm">
<span className="shrink-0 text-muted-foreground">{label}</span>
<span className="text-left font-medium">{value ? toPersianDigits(String(value)) : "ثبت نشده"}</span>
</div>
)) : (
<div className="relative isolate overflow-hidden rounded-xl border border-border/70 bg-muted/20 p-5 sm:p-6">
<div aria-hidden="true" className="pointer-events-none select-none space-y-3 opacity-55 blur-[4px]">
{maskedDetails.map(({ label }) => (
<div key={label} className="flex items-center justify-between gap-4 border-b border-border/60 py-3 last:border-0 text-sm">
<span className="shrink-0 text-muted-foreground">{label}</span>
<span className="font-medium">••••••••</span>
</div>
))}
</div>
<div className="absolute inset-0 flex items-center justify-center bg-background/55 p-4 text-center">
<div className="max-w-xs">
<span className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<EyeOff className="h-6 w-6" aria-hidden="true" />
</span>
<p className="font-semibold">جزئیات این پروفایل خصوصی است</p>
<p className="mt-1 text-sm leading-6 text-muted-foreground">نمایش اطلاعات تکمیلی به درخواست صاحب حساب غیرفعال شده است.</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</aside>
<div className="space-y-5 lg:col-span-8">
<Card className="border-border/70 shadow-sm">
<CardHeader className="text-right">
<CardTitle className="flex items-center gap-2"><CalendarDays className="h-5 w-5 text-primary" aria-hidden="true" />رویدادهای انجمن</CardTitle>
<CardDescription>رویدادهایی که {profile.first_name || profile.username} در آن‌ها شرکت کرده</CardDescription>
</CardHeader>
<CardContent className="h-[70vh] space-y-3 overflow-y-auto overscroll-contain">
{profile.events.length ? profile.events.map((event) => <EventCard key={`${event.slug}-${event.start_time}`} event={event} />) : <p className="rounded-xl bg-muted/30 p-5 text-center text-sm text-muted-foreground">هنوز رویدادی برای نمایش ثبت نشده است.</p>}
</CardContent>
</Card>
</div>
</div>
</div>
</main>
</>
);
}