feat: release v0.2.0 profiles, chat, freeze, and bots

Ship the product pass on the v0.1.3 schema with additive migrations, a replica migration lock, and bot accounts kept out of people lists.
This commit is contained in:
ahp
2026-10-10 16:42:35 +03:30
parent 8586bbafcc
commit 22d92c133d
85 changed files with 10510 additions and 1341 deletions
@@ -0,0 +1,138 @@
"use client";
import type { ReactNode } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
function Field({ id, label, children }: { id: string; label: string; children: ReactNode }) {
return (
<div className="space-y-1.5">
<Label htmlFor={id} className="text-xs font-bold text-muted-foreground">
{label}
</Label>
{children}
</div>
);
}
/** نام، ایمیل، تلفن — same block on /settings and in the admin account editor. */
export function AccountIdentityFields({
idPrefix,
name,
email,
phone,
onName,
onEmail,
onPhone,
phoneMaxLength,
action,
}: {
idPrefix: string;
name: string;
email: string;
phone: string;
onName: (value: string) => void;
onEmail: (value: string) => void;
onPhone: (value: string) => void;
phoneMaxLength?: number;
action?: ReactNode;
}) {
return (
<section className="space-y-4" aria-labelledby={`${idPrefix}-identity-heading`}>
<h3 id={`${idPrefix}-identity-heading`} className="text-sm font-black">هویت</h3>
<div className="grid gap-4 sm:grid-cols-2">
<Field id={`${idPrefix}-name`} label="نام">
<Input id={`${idPrefix}-name`} value={name} onChange={(e) => onName(e.target.value)} autoComplete="name" />
</Field>
<Field id={`${idPrefix}-email`} label="ایمیل">
<Input
id={`${idPrefix}-email`}
value={email}
onChange={(e) => onEmail(e.target.value)}
dir="ltr"
autoComplete="email"
inputMode="email"
/>
</Field>
<Field id={`${idPrefix}-phone`} label="تلفن">
<Input
id={`${idPrefix}-phone`}
value={phone}
onChange={(e) => onPhone(e.target.value)}
dir="ltr"
autoComplete="tel"
inputMode="tel"
maxLength={phoneMaxLength}
/>
</Field>
</div>
{action}
</section>
);
}
type UsernameSelf = {
mode: "self";
idPrefix: string;
current: string;
next: string;
onNext: (value: string) => void;
disabled?: boolean;
hint?: ReactNode;
action?: ReactNode;
};
type UsernameAdmin = {
mode: "admin";
idPrefix: string;
value: string;
onChange: (value: string) => void;
};
/** نام کاربری follows هویت. Self-service keeps the cooldown fields; admin sets the name directly. */
export function AccountUsernameSection(props: UsernameSelf | UsernameAdmin) {
const headingId = `${props.idPrefix}-username-heading`;
return (
<section className="space-y-3 border-t border-border pt-6" aria-labelledby={headingId}>
<h3 id={headingId} className="text-sm font-black">نام کاربری</h3>
{props.mode === "self" && props.hint}
{props.mode === "self" ? (
<div className="grid gap-4 sm:grid-cols-2">
<Field id={`${props.idPrefix}-username-current`} label="نام کاربری فعلی">
<Input
id={`${props.idPrefix}-username-current`}
value={props.current}
readOnly
dir="ltr"
autoComplete="username"
/>
</Field>
<Field id={`${props.idPrefix}-username`} label="نام کاربری جدید">
<Input
id={`${props.idPrefix}-username`}
value={props.next}
onChange={(e) => props.onNext(e.target.value.toLowerCase())}
dir="ltr"
autoComplete="off"
disabled={props.disabled}
/>
</Field>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2">
<Field id={`${props.idPrefix}-username`} label="نام کاربری">
<Input
id={`${props.idPrefix}-username`}
value={props.value}
onChange={(e) => props.onChange(e.target.value.toLowerCase())}
dir="ltr"
autoComplete="off"
placeholder="مثلاً ahp"
/>
</Field>
</div>
)}
{props.mode === "self" ? props.action : null}
</section>
);
}
+53 -144
View File
@@ -25,7 +25,6 @@ import {
Plus,
Rocket,
Save,
ShieldCheck,
Sparkles,
Target,
Terminal,
@@ -82,13 +81,14 @@ import {
isValidAdminTab,
type AdminTabId,
} from "@/components/admin/nav";
import { AdminPanelHeader } from "@/components/admin/panel-header";
import { SectionColumns, SectionNav, SectionPanel } from "@/components/section-nav";
import { useConfirm } from "@/components/admin/use-confirm";
import { StatsPanel } from "@/components/admin/stats-panel";
import { LearningPanel } from "@/components/admin/learning-panel";
import { QuestionsPanel } from "@/components/admin/questions-panel";
import { VideosPanel, VideoKeyPicker } from "@/components/admin/videos-panel";
import { AnnouncePanel } from "@/components/admin/announce-panel";
import { BotPanel } from "@/components/admin/bot-panel";
import { PhysicalOrdersPanel } from "@/components/admin/physical-orders-panel";
import {
DEFAULT_PAGE_SIZE,
@@ -96,6 +96,7 @@ import {
buildPageQuery,
type Paginated,
} from "@/components/admin/list-pagination";
import { AccountIdentityFields, AccountUsernameSection } from "@/components/account-identity-fields";
import { AdminSelect, ListToolbar } from "@/components/admin/list-toolbar";
import { SiteSettingsPanel } from "@/components/admin/site-settings-panel";
import { RegistrationPanel } from "@/components/admin/registration-panel";
@@ -196,113 +197,32 @@ export function AdminStudio() {
})).filter((g) => g.items.length > 0);
return (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="section-kicker">{isAdmin ? "استودیو ادمین" : "استودیو منتور"}</p>
<h1 className="mt-1.5 text-2xl font-bold tracking-tight sm:text-3xl">
{isAdmin ? (
<>
پنل <span className="text-gradient-brand">مدیریت</span>
</>
) : (
<>
پنل <span className="text-gradient-brand">منتور</span>
</>
)}
</h1>
<p className="mt-1.5 max-w-2xl text-sm leading-relaxed text-muted-foreground">
{isAdmin
? "افراد، ثبت‌نام، محتوای دوره، ارتباط و تنظیمات سایت — گروه‌بندی‌شده تا چیزی گم نشود."
: MENTOR_HINT}
</p>
</div>
<Badge variant="accent" className="gap-1 rounded-lg px-3 py-1.5">
<ShieldCheck className="h-4 w-4" /> {isAdmin ? "ادمین" : "منتور"}
</Badge>
</div>
<div>
<header className="mb-6 space-y-1">
<h1 className="text-2xl font-black tracking-tight">{isAdmin ? "مدیریت" : "منتور"}</h1>
<p className="text-sm text-muted-foreground">
{isAdmin
? "افراد، ثبت‌نام، محتوای دوره، ارتباط و تنظیمات سایت."
: MENTOR_HINT}
</p>
</header>
{/* Mobile: horizontal grouped chips */}
<nav className="lg:hidden" aria-label={isAdmin ? "بخش‌های مدیریت" : "بخش‌های منتور"}>
<div className="-mx-1 flex gap-3 overflow-x-auto px-1 pb-1">
{visibleGroups.map((group) => (
<div key={group.title} className="flex shrink-0 flex-col gap-1.5">
<p className="px-1 text-[10px] font-bold text-muted-foreground">{group.title}</p>
<div className="flex gap-1.5">
{group.items.map((item) => {
const Icon = item.icon;
const active = tab === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => setTab(item.id)}
className={cn(
"inline-flex items-center gap-1.5 whitespace-nowrap rounded-xl px-3 py-2 text-xs font-bold transition-colors",
active
? "bg-primary text-primary-foreground shadow-offset-sm"
: "border border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="h-3.5 w-3.5 shrink-0" />
{item.label}
</button>
);
})}
</div>
</div>
))}
</div>
</nav>
<div className="flex flex-col gap-5 lg:flex-row lg:items-start">
<aside className="hidden w-52 shrink-0 lg:sticky lg:top-24 lg:block">
<nav
className="space-y-3 rounded-xl border border-border bg-card p-2.5"
aria-label={isAdmin ? "منوی مدیریت" : "منوی منتور"}
>
{visibleGroups.map((group) => (
<div key={group.title}>
<p className="mb-1 px-2 text-[11px] font-bold text-muted-foreground">
{group.title}
</p>
<ul className="space-y-0.5">
{group.items.map((item) => {
const Icon = item.icon;
const active = tab === item.id;
return (
<li key={item.id}>
<button
type="button"
onClick={() => setTab(item.id)}
aria-current={active ? "page" : undefined}
className={cn(
"flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm font-bold transition-colors",
active
? "bg-primary text-primary-foreground shadow-offset-sm"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="h-4 w-4 shrink-0" />
{item.label}
</button>
</li>
);
})}
</ul>
</div>
))}
</nav>
</aside>
<div className="min-w-0 flex-1">
{navItem && (
<AdminPanelHeader
title={navItem.label}
description={navItem.description}
icon={navItem.icon}
/>
)}
<SectionColumns>
<SectionNav
label={isAdmin ? "بخش‌های مدیریت" : "بخش‌های منتور"}
groups={visibleGroups.map((group) => ({
title: group.title,
items: group.items.map((item) => ({ id: item.id, label: item.label })),
}))}
activeId={tab}
onSelect={setTab}
/>
<SectionPanel
id="admin-section"
titleId="admin-section-title"
title={navItem?.label ?? ""}
description={navItem?.description ?? ""}
>
{tab === "stats" && isAdmin && <StatsPanel />}
{tab === "learning" && <LearningPanel />}
{tab === "users" && isAdmin && <UsersPanel />}
@@ -318,9 +238,10 @@ export function AdminStudio() {
{tab === "events" && <AdminEventsPanel />}
{tab === "challenges" && isAdmin && <ChallengesPanel />}
{tab === "announce" && isAdmin && <AnnouncePanel />}
{tab === "bots" && isAdmin && <BotPanel />}
{tab === "chat-export" && isAdmin && <ChatExportPanel />}
</div>
</div>
</SectionPanel>
</SectionColumns>
</div>
);
}
@@ -374,7 +295,7 @@ function UsersPanel() {
const del = async (u: User) => {
if (
!(await confirm(
"حذف کاربر",
"حذف دائمی",
`حساب «${u.name}» (${u.email}) برای همیشه حذف شود؟ این عمل بازگشت‌ناپذیر است.`
))
) {
@@ -497,8 +418,8 @@ function UsersPanel() {
<Pencil className="h-3.5 w-3.5" />
</Button>
)}
{canMutate && u.id !== currentUser?.id && (
<Button size="sm" variant="ghost" className="h-8 px-2 text-destructive" disabled={busy === u.id} onClick={() => del(u)} title="حذف">
{canMutate && u.id !== currentUser?.id && (u.isFrozen || u.isClosed) && (
<Button size="sm" variant="ghost" className="h-8 px-2 text-destructive" disabled={busy === u.id} onClick={() => del(u)} title="حذف دائمی" aria-label="حذف دائمی">
{busy === u.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
</Button>
)}
@@ -644,37 +565,24 @@ export function UserDialog({
: "اطلاعات کاربر را به‌روزرسانی کنید."}
</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<div className="space-y-1.5">
<Label>نام</Label>
<Input value={form.name ?? ""} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label>شناسه</Label>
<Input
value={form.username ?? ""}
onChange={(e) => setForm({ ...form, username: e.target.value.toLowerCase() })}
dir="ltr"
className="text-left"
placeholder="مثلاً ahp"
/>
</div>
<div className="space-y-1.5">
<Label>ایمیل</Label>
<Input type="email" value={form.email ?? ""} onChange={(e) => setForm({ ...form, email: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label>تلفن</Label>
<Input
value={form.phone ?? ""}
onChange={(e) => setForm({ ...form, phone: clampPhoneInput(e.target.value) })}
dir="ltr"
className="text-left"
placeholder="09xxxxxxxxx"
inputMode="tel"
maxLength={PHONE_INPUT_MAX_LEN}
/>
</div>
<div className="space-y-8">
<AccountIdentityFields
idPrefix="admin-user"
name={form.name ?? ""}
email={form.email ?? ""}
phone={form.phone ?? ""}
onName={(value) => setForm({ ...form, name: value })}
onEmail={(value) => setForm({ ...form, email: value })}
onPhone={(value) => setForm({ ...form, phone: clampPhoneInput(value) })}
phoneMaxLength={PHONE_INPUT_MAX_LEN}
/>
<AccountUsernameSection
mode="admin"
idPrefix="admin-user"
value={form.username ?? ""}
onChange={(value) => setForm({ ...form, username: value })}
/>
<section className="space-y-4 border-t border-border pt-6">
<div className="space-y-1.5">
<Label>نقش</Label>
<select
@@ -745,6 +653,7 @@ export function UserDialog({
/>
</div>
)}
</section>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>انصراف</Button>
+145 -20
View File
@@ -1,14 +1,19 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
Bell,
BookOpen,
Calendar,
Eye,
Gamepad2,
Loader2,
Megaphone,
Route,
Settings2,
Trash2,
TrendingUp,
Users,
} from "lucide-react";
@@ -28,14 +33,28 @@ import {
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { toast } from "@/components/providers";
import { SafeMarkdown } from "@/components/safe-markdown";
const CATEGORIES = [
{ id: "event", label: "رویداد", icon: Calendar, hint: "جلسه زنده، کارگاه، ددلاین" },
{ id: "progress", label: "پیشرفت", icon: TrendingUp, hint: "درس جدید، فصل بازشده" },
{ id: "gamification", label: "بازی‌سازی", icon: Gamepad2, hint: "جایزه، استریک، چالش" },
{ id: "mentor", label: "منتور", icon: Users, hint: "پیام مستقیم تیم منتور" },
{ id: "urgent", label: "فوری", icon: AlertTriangle, hint: "نیاز به توجه همین حالا" },
{ id: "curriculum", label: "درس", icon: BookOpen, hint: "محتوای آموزشی" },
{ id: "community", label: "جامعه", icon: Users, hint: "فضای مشترک دوره" },
{ id: "system", label: "سیستم", icon: Settings2, hint: "وضعیت خود پرگار" },
] as const;
type Announcement = {
id: number;
title: string;
body: string;
category: string;
route: string;
pinned: boolean;
};
const ROUTES = [
{ path: "/unwrap", label: "نقشه مسیر" },
{ path: "/notifications", label: "اعلان‌ها" },
@@ -43,6 +62,20 @@ const ROUTES = [
{ path: "/cap", label: "داشبورد" },
];
function announcementExcerpt(body: string) {
const plain = body
.replace(/```[\s\S]*?```/g, " ")
.replace(/`([^`]*)`/g, "$1")
.replace(/!\[[^\]]*]\([^)]*\)/g, " ")
.replace(/\[([^\]]*)]\([^)]*\)/g, "$1")
.replace(/^#{1,6}\s+/gm, "")
.replace(/[*_~>]+/g, "")
.replace(/\s+/g, " ")
.trim();
if (plain.length <= 180) return plain;
return `${plain.slice(0, 180).trimEnd()}…`;
}
function PreviewCard({
title,
body,
@@ -66,9 +99,10 @@ function PreviewCard({
</Badge>
</div>
<p className="mt-2.5 text-sm font-black leading-snug">{title.trim() || "عنوان اطلاعیه"}</p>
<p className="mt-1 min-h-[2.5rem] text-xs leading-relaxed text-muted-foreground">
{body.trim() || "متن پیام اینجا دیده می‌شود…"}
</p>
<SafeMarkdown
text={body.trim() || "متن پیام اینجا دیده می‌شود…"}
className="mt-1 min-h-[2.5rem] space-y-1 text-xs leading-relaxed text-muted-foreground"
/>
<p className="mt-2.5 truncate text-[10px] font-mono text-primary" dir="ltr">
→ {route || "/"}
</p>
@@ -80,8 +114,15 @@ export function AnnouncePanel() {
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [category, setCategory] = useState<string>("event");
const [route, setRoute] = useState("/unwrap");
const [route, setRoute] = useState("");
const [pinned, setPinned] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [busy, setBusy] = useState(false);
const qc = useQueryClient();
const listQ = useQuery({
queryKey: ["admin-announcements"],
queryFn: () => http.get<{ announcements: Announcement[] }>("/api/admin/announcements"),
});
const [confirmOpen, setConfirmOpen] = useState(false);
const [lastSent, setLastSent] = useState<{ title: string; recipients: number } | null>(null);
@@ -93,17 +134,21 @@ export function AnnouncePanel() {
if (!valid) return;
setBusy(true);
try {
const res = await http.post<{ recipients: number }>("/api/admin/announce", {
title: title.trim(),
body: body.trim(),
category,
route,
});
toast.success(`اطلاعیه برای ${res.recipients} نفر ارسال شد`);
setLastSent({ title: title.trim(), recipients: res.recipients });
const payload = { title: title.trim(), body: body.trim(), category, route: route.trim(), pinned };
if (editingId) {
await http.put(`/api/admin/announcements/${editingId}`, payload);
toast.success("اطلاعیه به‌روز شد");
} else {
const res = await http.post<{ recipients: number }>("/api/admin/announce", payload);
toast.success(`اطلاعیه برای ${res.recipients} نفر ارسال شد`);
setLastSent({ title: title.trim(), recipients: res.recipients });
}
setTitle("");
setBody("");
setPinned(false);
setEditingId(null);
setConfirmOpen(false);
void qc.invalidateQueries({ queryKey: ["admin-announcements"] });
} catch (e) {
toast.error(toUserError(e, "ارسال اطلاعیه ممکن نشد"));
} finally {
@@ -113,7 +158,8 @@ export function AnnouncePanel() {
return (
<>
<div className="grid items-start gap-4 lg:grid-cols-[minmax(0,1fr)_20rem]">
<div className="flex flex-col gap-6">
<div data-announce-compose className="grid items-start gap-4 lg:grid-cols-[minmax(0,1fr)_20rem]">
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-base">نگارش اعلان</CardTitle>
@@ -136,13 +182,13 @@ export function AnnouncePanel() {
<div className="space-y-2">
<div className="flex h-5 items-center justify-between gap-3">
<Label className="text-sm font-bold">پیام</Label>
<span className="text-[11px] tabular-nums text-muted-foreground">{body.length}/۵۰۰</span>
<span className="text-[11px] tabular-nums text-muted-foreground">{body.length}/۴۰۰۰</span>
</div>
<textarea
value={body}
onChange={(e) => setBody(e.target.value.slice(0, 500))}
onChange={(e) => setBody(e.target.value.slice(0, 4000))}
rows={5}
maxLength={500}
maxLength={4000}
className={cn(
"w-full resize-y rounded-xl border bg-background px-4 py-3 text-sm leading-relaxed focus:outline-none focus:ring-2 focus:ring-primary",
!bodyOk && body.length > 0 ? "border-destructive/50" : "border-input"
@@ -190,8 +236,9 @@ export function AnnouncePanel() {
<div className="space-y-2">
<Label className="flex h-5 items-center gap-1.5 text-sm font-bold">
<Route className="h-3.5 w-3.5" /> مقصد کلیک
<Route className="h-3.5 w-3.5" /> با زدن اعلان این صفحه باز می‌شود
</Label>
<p className="text-[11px] text-muted-foreground">خالی بگذارید تا اعلان فقط خوانده شود و صفحه‌ای باز نکند.</p>
<div className="flex flex-wrap gap-2">
{ROUTES.map((r) => (
<button
@@ -214,8 +261,12 @@ export function AnnouncePanel() {
onChange={(e) => setRoute(e.target.value)}
dir="ltr"
className="h-10 rounded-2xl font-mono text-xs"
placeholder="/unwrap"
placeholder="خالی مجاز است"
/>
<label className="flex items-center gap-2 text-sm font-bold">
<input type="checkbox" checked={pinned} onChange={(e) => setPinned(e.target.checked)} />
سنجاق روی درخت مهارت
</label>
</div>
<div className="flex flex-wrap items-center gap-3 border-t border-border pt-4">
@@ -235,7 +286,7 @@ export function AnnouncePanel() {
</CardContent>
</Card>
<div className="space-y-4 lg:sticky lg:top-24">
<div className="space-y-4">
<Card className="border bg-muted/20">
<CardHeader className="space-y-1 pb-3">
<CardTitle className="flex h-6 items-center gap-2 text-sm">
@@ -260,6 +311,80 @@ export function AnnouncePanel() {
</div>
</div>
<Card data-announce-list className="rounded-2xl border border-border">
<CardHeader>
<CardTitle className="text-base">اطلاعیه‌های ثبت‌شده</CardTitle>
</CardHeader>
<CardContent>
{(listQ.data?.announcements ?? []).length > 0 ? (
<ul className="flex list-none flex-col gap-3 p-0">
{(listQ.data?.announcements ?? []).map((a) => {
const cat = CATEGORIES.find((c) => c.id === a.category);
const excerpt = announcementExcerpt(a.body);
return (
<li key={a.id} className="rounded-2xl border border-border bg-background p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<p className="min-w-0 text-sm font-bold leading-snug">{a.title}</p>
<Badge variant="secondary" className="text-[10px]">
{cat?.label ?? a.category}
</Badge>
{a.pinned ? (
<Badge variant="gold" className="text-[10px]">
سنجاق
</Badge>
) : null}
</div>
{excerpt ? (
<p className="line-clamp-2 text-xs leading-relaxed text-muted-foreground">{excerpt}</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setEditingId(a.id);
setTitle(a.title);
setBody(a.body);
setCategory(a.category);
setRoute(a.route);
setPinned(a.pinned);
}}
>
ویرایش
</Button>
<Button
type="button"
size="sm"
variant="outline"
aria-label="حذف"
onClick={() => {
void http
.del(`/api/admin/announcements/${a.id}`)
.then(() => qc.invalidateQueries({ queryKey: ["admin-announcements"] }))
.catch((e) => toast.error(toUserError(e, "حذف ممکن نشد")));
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</li>
);
})}
</ul>
) : !listQ.isPending ? (
<p className="rounded-2xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
هنوز اطلاعیه‌ای ثبت نشده است.
</p>
) : null}
</CardContent>
</Card>
</div>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent className="max-w-md gap-0 overflow-hidden p-0 sm:rounded-2xl">
<DialogHeader className="space-y-1 border-b border-border px-6 py-4 text-start">
@@ -275,7 +400,7 @@ export function AnnouncePanel() {
</Button>
<Button className="h-10 gap-2" disabled={busy || !valid} onClick={() => void send()}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Bell className="h-4 w-4" />}
تأیید و ارسال
{editingId ? "تأیید و ذخیره" : "تأیید و ارسال"}
</Button>
</DialogFooter>
</DialogContent>
+192
View File
@@ -0,0 +1,192 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { BookOpen } from "lucide-react";
import { http, toUserError } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "@/components/providers";
import { formatFaNumber, formatJalaliStamp } from "@/lib/utils";
const PAGE_SIZE = 8;
type BotKey = {
id: number;
botId: number;
name: string;
createdAt: string;
active: boolean;
requestCount: number;
};
type BotList = {
items: BotKey[];
total: number;
page: number;
pageSize: number;
};
export function BotPanel() {
const qc = useQueryClient();
const [name, setName] = useState("");
const [token, setToken] = useState("");
const [busy, setBusy] = useState(false);
const [page, setPage] = useState(1);
const [toggling, setToggling] = useState<number | null>(null);
const listQ = useQuery({
queryKey: ["admin-bots", page],
queryFn: () => http.get<BotList>(`/api/admin/bots?page=${page}&pageSize=${PAGE_SIZE}`),
});
const items = listQ.data?.items ?? [];
const total = listQ.data?.total ?? 0;
const pageSize = listQ.data?.pageSize ?? PAGE_SIZE;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const pageSafe = Math.min(Math.max(page, 1), totalPages);
async function copyToken() {
try {
await navigator.clipboard.writeText(token);
toast.success("توکن کپی شد");
} catch {
toast.error("کپی ممکن نشد");
}
}
function setActive(row: BotKey, active: boolean) {
setToggling(row.id);
void http
.put(`/api/admin/bots/${row.id}`, { active })
.then(() => {
toast.success(active ? "کلید فعال شد" : "کلید متوقف شد");
return qc.invalidateQueries({ queryKey: ["admin-bots"] });
})
.catch((e) => toast.error(toUserError(e, "تغییر وضعیت ممکن نشد")))
.finally(() => setToggling(null));
}
return (
<div className="space-y-4">
<Card className="rounded-2xl">
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
<div className="space-y-1">
<CardTitle className="text-base">کلید API</CardTitle>
<p className="text-sm text-muted-foreground">توکن فقط یک بار نشان داده می‌شود.</p>
</div>
<Button type="button" variant="outline" size="sm" asChild>
<Link href="/admin/bot-docs">
<BookOpen className="h-3.5 w-3.5" />
راهنما
</Link>
</Button>
</CardHeader>
<CardContent className="space-y-3">
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="نام ربات" />
<Button
type="button"
disabled={busy}
onClick={() => {
setBusy(true);
void http
.post<{ token: string }>("/api/admin/bots", { name })
.then((res) => {
setToken(res.token);
setName("");
setPage(1);
toast.success("ربات ساخته شد");
return qc.invalidateQueries({ queryKey: ["admin-bots"] });
})
.catch((e) => toast.error(toUserError(e, "ساخت ربات ممکن نشد")))
.finally(() => setBusy(false));
}}
>
ساخت ربات
</Button>
{token && (
<div className="space-y-2 rounded-2xl border border-border bg-muted/30 p-3">
<p className="text-sm text-muted-foreground">توکن فقط یک بار نشان داده می‌شود.</p>
<div className="flex flex-col gap-2 sm:flex-row">
<Input readOnly value={token} dir="ltr" className="font-mono text-xs" aria-label="توکن ربات" />
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={() => void copyToken()}>
کپی
</Button>
<Button type="button" variant="ghost" onClick={() => setToken("")}>
بستن
</Button>
</div>
</div>
</div>
)}
</CardContent>
</Card>
<Card className="rounded-2xl">
<CardHeader>
<CardTitle className="text-base">کلیدها</CardTitle>
</CardHeader>
<CardContent>
{listQ.isPending ? (
<p className="text-sm text-muted-foreground">در حال بارگذاری…</p>
) : listQ.isError ? (
<p className="text-sm font-bold text-destructive">{toUserError(listQ.error, "بارگذاری کلیدها ممکن نشد")}</p>
) : items.length === 0 ? (
<p className="text-sm text-muted-foreground">هنوز کلیدی نیست.</p>
) : (
<div className="space-y-3">
<ul className="divide-y divide-border">
{items.map((row) => (
<li key={row.id} className="flex flex-col gap-3 py-4 first:pt-0 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-1">
<p className="truncate text-sm font-bold">{row.name || "ربات"}</p>
<p className="text-xs text-muted-foreground">{formatJalaliStamp(row.createdAt)}</p>
<p className="text-xs">
<span className={row.active ? "font-bold text-success" : "font-bold text-muted-foreground"}>
{row.active ? "فعال" : "متوقف"}
</span>
<span className="text-muted-foreground"> · {formatFaNumber(row.requestCount)} درخواست</span>
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
disabled={toggling === row.id}
onClick={() => setActive(row, !row.active)}
>
{row.active ? "توقف" : "فعال‌کردن"}
</Button>
</li>
))}
</ul>
{totalPages > 1 && (
<div className="flex items-center justify-between gap-3 border-t border-border pt-4">
<Button type="button" variant="outline" size="sm" disabled={pageSafe <= 1} onClick={() => setPage(pageSafe - 1)}>
قبلی
</Button>
<p className="text-sm font-bold tabular-nums text-muted-foreground">
{formatFaNumber(pageSafe)} از {formatFaNumber(totalPages)}
</p>
<Button
type="button"
variant="outline"
size="sm"
disabled={pageSafe >= totalPages}
onClick={() => setPage(pageSafe + 1)}
>
بعدی
</Button>
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
);
}
+10 -1
View File
@@ -1,6 +1,7 @@
import type { LucideIcon } from "lucide-react";
import {
Activity,
Bot,
BookOpen,
CalendarPlus,
DatabaseBackup,
@@ -33,6 +34,7 @@ export type AdminTabId =
| "events"
| "challenges"
| "announce"
| "bots"
| "chat-export"
| "settings";
@@ -166,6 +168,13 @@ export const ADMIN_NAV: AdminNavGroup[] = [
icon: Megaphone,
adminOnly: true,
},
{
id: "bots",
label: "ربات",
description: "کلیدهای API، وضعیت، شمار درخواست و راهنمای اتصال.",
icon: Bot,
adminOnly: true,
},
{
id: "chat-export",
label: "آرشیو گفتگوها",
@@ -181,7 +190,7 @@ export const ADMIN_NAV: AdminNavGroup[] = [
{
id: "settings",
label: "تنظیمات سایت",
description: "حمایت مالی، گواهینامه فیزیکی و اسپانسرهای لندینگ.",
description: "شبکه‌ها، قلب‌ها، گواهینامه فیزیکی و اسپانسرهای لندینگ.",
icon: Settings2,
adminOnly: true,
},
@@ -45,6 +45,7 @@ export function AdminProfileManage({ userId }: { userId: number }) {
const account = accountQ.data?.user;
const isSelf = account?.id === me?.id;
const canDelete = !!account && !isSelf && (account.isFrozen || account.isClosed);
const act = async (kind: "lock" | "unlock") => {
setBusy("lock");
@@ -89,7 +90,7 @@ export function AdminProfileManage({ userId }: { userId: number }) {
}
if (
!(await confirm(
"حذف کاربر",
"حذف دائمی",
`حساب «${account.name}» (${account.email}) برای همیشه حذف شود؟ این عمل بازگشت‌ناپذیر است.`,
))
) {
@@ -156,17 +157,17 @@ export function AdminProfileManage({ userId }: { userId: number }) {
قفل
</Button>
))}
<div className={cn("grid gap-2", isSelf ? "sm:grid-cols-2" : "sm:grid-cols-3")}>
<div className={cn("grid gap-2", canDelete ? "sm:grid-cols-3" : "sm:grid-cols-2")}>
<Button size="sm" variant="outline" className="w-full" onClick={() => setEditing(account)}>
<Pencil className="h-3.5 w-3.5" /> ویرایش
</Button>
<Button size="sm" variant="outline" className="w-full" disabled={busy !== null} onClick={() => setResetOpen(true)}>
<KeyRound className="h-3.5 w-3.5" /> ریست رمز
</Button>
{!isSelf && (
{canDelete && (
<Button size="sm" variant="destructive" className="w-full" disabled={busy !== null} onClick={del}>
{busy === "delete" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
حذف
حذف دائمی
</Button>
)}
</div>
@@ -2,7 +2,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { HeartHandshake, Loader2, Package, Save, Settings2, Sparkles } from "lucide-react";
import { Heart, Loader2, Package, Save, Settings2, Share2, Sparkles } from "lucide-react";
import { http, toUserError } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -15,6 +15,7 @@ import type { AdminTabId } from "@/components/admin/nav";
type SettingsMap = Record<string, string>;
type SponsorDraft = { name: string; url: string; blurb: string };
type SocialDraft = { name: string; url: string };
function SectionHeading({
icon: Icon,
@@ -44,6 +45,7 @@ export function SiteSettingsPanel({
const qc = useQueryClient();
const [draft, setDraft] = useState<SettingsMap>({});
const [sponsors, setSponsors] = useState<SponsorDraft[]>([]);
const [socials, setSocials] = useState<SocialDraft[]>([]);
const [busy, setBusy] = useState(false);
const { data, isLoading } = useQuery({
@@ -68,15 +70,17 @@ export function SiteSettingsPanel({
} catch {
setSponsors([]);
}
try {
const parsed = JSON.parse(data.settings.social_links || "[]") as SocialDraft[];
setSocials(Array.isArray(parsed) ? parsed.map((s) => ({ name: s.name ?? "", url: s.url ?? "" })) : []);
} catch {
setSocials([]);
}
}, [data]);
const set = (key: string, value: string) => setDraft((d) => ({ ...d, [key]: value }));
const save = async () => {
if ((draft.donation_note ?? "").trim().length > 2000) {
toast.error("یادداشت دونیت خیلی طولانی است");
return;
}
const price = Number(draft.physical_cert_price_irr);
const days = Number(draft.physical_cert_window_days);
if (!Number.isFinite(price) || price < 0) {
@@ -110,6 +114,9 @@ export function SiteSettingsPanel({
try {
const settings = {
...draft,
social_links: JSON.stringify(
socials.filter((s) => s.name.trim() && s.url.trim()).map((s) => ({ name: s.name.trim(), url: s.url.trim() })),
),
sponsors: JSON.stringify(
sponsors
.filter((s) => s.name.trim())
@@ -173,24 +180,60 @@ export function SiteSettingsPanel({
<section className="space-y-3">
<SectionHeading
icon={HeartHandshake}
title="حمایت / دونیت"
hint="متن راهنما برای گفتگو با تیم دربارهٔ حمایت مالی"
icon={Share2}
title="شبکه‌های پرگار"
hint="حداکثر ۸ لینک رسمی. در فوتر و زبانه شبکه‌ها دیده می‌شود."
/>
{socials.map((s, i) => (
<div key={i} className="grid gap-2 sm:grid-cols-2">
<Input
value={s.name}
placeholder="نام"
onChange={(e) =>
setSocials((list) => list.map((row, idx) => (idx === i ? { ...row, name: e.target.value } : row)))
}
/>
<Input
dir="ltr"
value={s.url}
placeholder="https://"
onChange={(e) =>
setSocials((list) => list.map((row, idx) => (idx === i ? { ...row, url: e.target.value } : row)))
}
/>
</div>
))}
{socials.length < 8 && (
<Button type="button" size="sm" variant="outline" onClick={() => setSocials((list) => [...list, { name: "", url: "" }])}>
افزودن شبکه
</Button>
)}
</section>
<section className="space-y-3">
<SectionHeading icon={Heart} title="قلب‌ها" hint="سقف، فاصله بازگشت، و قفل در صفر." />
<div className="grid gap-2 sm:grid-cols-2">
<Input
type="number"
value={draft.hearts_max ?? "5"}
onChange={(e) => set("hearts_max", e.target.value)}
aria-label="سقف قلب"
/>
<Input
type="number"
value={draft.hearts_regen_minutes ?? "240"}
onChange={(e) => set("hearts_regen_minutes", e.target.value)}
aria-label="فاصله بازگشت به دقیقه"
/>
</div>
<label className="flex items-center justify-between gap-3 text-sm">
<span>فعال باشد</span>
<span>صفر قلب حساب را قفل کند</span>
<Switch
checked={(draft.donation_enabled ?? "true") === "true"}
onCheckedChange={(v) => set("donation_enabled", v ? "true" : "false")}
aria-label="حمایت مالی"
checked={(draft.hearts_lock_on_empty ?? "false") === "true"}
onCheckedChange={(v) => set("hearts_lock_on_empty", v ? "true" : "false")}
aria-label="قفل در صفر"
/>
</label>
<Textarea
className="min-h-[96px]"
value={draft.donation_note ?? ""}
onChange={(e) => set("donation_note", e.target.value)}
placeholder="متن راهنما برای دانشجو"
/>
</section>
<section className="space-y-3">
+11 -6
View File
@@ -11,6 +11,15 @@ import { http } from "@/lib/api";
import type { User } from "@/lib/types";
import { hasInfiniteHearts } from "@/lib/hearts";
function SiteFooter() {
return (
<div className="mx-auto flex w-full max-w-6xl flex-col items-center justify-between gap-3 px-4 py-8 sm:flex-row sm:px-6">
<BrandMark size="sm" logoOnly />
<p className="text-xs text-muted-foreground">بوت‌کمپ ۱۲ هفته‌ای · توسعه‌یافته توسط امیرحسین پناهی‌فر</p>
</div>
);
}
function isPublic(p: string) {
return p === "/" || p === "/login";
}
@@ -25,6 +34,7 @@ const PROTECTED_PREFIXES = [
"/notifications",
"/lockout",
"/profile",
"/settings",
];
function needsAuth(p: string) {
@@ -141,12 +151,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
{allowChildren ? children : null}
</main>
<footer className="border-t border-border/70 bg-muted/20 backdrop-blur">
<div className="mx-auto flex w-full max-w-6xl flex-col items-center justify-between gap-3 px-4 py-8 sm:flex-row sm:px-6">
<BrandMark size="sm" logoOnly />
<p className="text-xs text-muted-foreground">
بوت‌کمپ ۱۲ هفته‌ای · توسعه‌یافته توسط امیرحسین پناهی‌فر
</p>
</div>
<SiteFooter />
</footer>
{user && <ChatWidget />}
</div>
+72 -19
View File
@@ -20,12 +20,16 @@ function clamp(v: number, lo: number, hi: number) {
export function AvatarCropDialog({
file,
busy,
error,
onCancel,
onInvalid,
onCrop,
}: {
file: File | null;
busy?: boolean;
error?: string | null;
onCancel: () => void;
onInvalid?: () => void;
onCrop: (blob: Blob) => Promise<void> | void;
}) {
const boxRef = useRef<HTMLDivElement>(null);
@@ -33,25 +37,68 @@ export function AvatarCropDialog({
const [C, setC] = useState(280);
const [scale, setScale] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [localError, setLocalError] = useState<string | null>(null);
const drag = useRef<{ sx: number; sy: number; px: number; py: number } | null>(null);
const onInvalidRef = useRef(onInvalid);
onInvalidRef.current = onInvalid;
const fittedFor = useRef<HTMLImageElement | null>(null);
const fittedSize = useRef(0);
useEffect(() => {
if (!file) return;
if (!file) {
setImg(null);
return;
}
let cancel = false;
setLocalError(null);
const url = URL.createObjectURL(file);
const el = new Image();
el.onload = () => setImg({ el, w: el.naturalWidth, h: el.naturalHeight });
el.onload = () => {
if (cancel) return;
setImg({ el, w: el.naturalWidth, h: el.naturalHeight });
};
el.onerror = () => {
if (cancel) return;
setImg(null);
onInvalidRef.current?.();
};
el.src = url;
return () => URL.revokeObjectURL(url);
return () => {
cancel = true;
URL.revokeObjectURL(url);
};
}, [file]);
useEffect(() => {
if (!img) return;
const box = boxRef.current;
const size = Math.min(box?.clientWidth ?? 280, 320);
setC(size);
const minScale = size / Math.min(img.w, img.h);
setScale(minScale);
setPan({ x: (size - img.w * minScale) / 2, y: (size - img.h * minScale) / 2 });
let ro: ResizeObserver | null = null;
let frame = 0;
let tries = 0;
const fit = () => {
const box = boxRef.current;
if (!box) {
if (tries++ < 40) frame = requestAnimationFrame(fit);
return;
}
if (!ro) {
ro = new ResizeObserver(fit);
ro.observe(box);
}
const size = Math.round(Math.min(box.clientWidth, 320));
if (size < 8) return;
setC(size);
if (fittedFor.current === img.el && Math.abs(fittedSize.current - size) < 2) return;
fittedFor.current = img.el;
fittedSize.current = size;
const min = size / Math.min(img.w, img.h);
setScale(min);
setPan({ x: (size - img.w * min) / 2, y: (size - img.h * min) / 2 });
};
fit();
return () => {
cancelAnimationFrame(frame);
ro?.disconnect();
};
}, [img]);
const minScale = img ? C / Math.min(img.w, img.h) : 1;
@@ -94,12 +141,13 @@ export function AvatarCropDialog({
};
const confirm = async () => {
if (!img) return;
if (!img || !(scale > 0) || C < 8) return;
const cropSize = C / scale;
let cx = -pan.x / scale;
let cy = -pan.y / scale;
cx = clamp(cx, 0, img.w - cropSize);
cy = clamp(cy, 0, img.h - cropSize);
if (!(cropSize > 0) || !Number.isFinite(cx) || !Number.isFinite(cy)) return;
cx = clamp(cx, 0, Math.max(0, img.w - cropSize));
cy = clamp(cy, 0, Math.max(0, img.h - cropSize));
const canvas = document.createElement("canvas");
canvas.width = OUTPUT_SIZE;
@@ -113,10 +161,14 @@ export function AvatarCropDialog({
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/png")
);
if (blob) await onCrop(blob);
if (!blob) {
setLocalError("ساخت تصویر آواتار ممکن نشد");
return;
}
await onCrop(blob);
};
const zoomPct = Math.round((scale / maxScale) * 100);
const zoomPct = maxScale > 0 ? Math.round((scale / maxScale) * 100) : 17;
return (
<Dialog open={!!file && !!img} onOpenChange={(o) => !o && !busy && onCancel()}>
@@ -145,10 +197,9 @@ export function AvatarCropDialog({
position: "absolute",
left: 0,
top: 0,
transform: `translate(${pan.x}px, ${pan.y}px) scale(${scale})`,
transformOrigin: "0 0",
width: img.w,
height: img.h,
transform: `translate(${pan.x}px, ${pan.y}px)`,
width: img.w * scale,
height: img.h * scale,
maxWidth: "none",
maxHeight: "none",
}}
@@ -184,6 +235,8 @@ export function AvatarCropDialog({
/>
</div>
{(error || localError) && <p className="text-[11px] font-bold text-destructive">{error || localError}</p>}
<DialogFooter className="sm:space-x-0 sm:space-x-reverse sm:justify-between">
<Button variant="outline" onClick={onCancel} disabled={busy}>
<X /> انصراف
@@ -192,7 +245,7 @@ export function AvatarCropDialog({
<Button variant="ghost" size="sm" onClick={setZoomIn} disabled={busy} title="تنظیم خودکار">
<Scan /> برازش
</Button>
<Button onClick={confirm} disabled={busy || !img}>
<Button onClick={confirm} disabled={busy || !img || !(scale > 0) || C < 8}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check />}
{busy ? "در حال آپلود…" : "تأیید و ذخیره"}
</Button>
+24 -18
View File
@@ -6,7 +6,7 @@ import { useAuth } from "@/lib/auth-store";
import { queryClient } from "@/lib/query-client";
import { apiForm, http, toUserError } from "@/lib/api";
import type { User } from "@/lib/types";
import { AVATAR_COLORS, AVATAR_VARIANTS, UserAvatar, avatarPaletteOf } from "@/components/ui/user-avatar";
import { AVATAR_PALETTES, AVATAR_VARIANTS, UserAvatar, avatarPaletteOf } from "@/components/ui/user-avatar";
import { AvatarCropDialog } from "@/components/avatar-crop";
import {
Dialog,
@@ -18,15 +18,6 @@ import {
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const PALETTES: { label: string; colors: string[] }[] = [
{ label: "پیش‌فرض", colors: AVATAR_COLORS },
{ label: "رزین", colors: ["#0f172a", "#38bdf8", "#f0abfc", "#a5f3fc", "#7c3aed", "#f5d0fe"] },
{ label: "صحرایی", colors: ["#1c1917", "#fbbf24", "#fb923c", "#fde68a", "#f97316", "#fed7aa"] },
{ label: "اسکاندی", colors: ["#022c22", "#34d399", "#fef3c7", "#a7f3d0", "#166534", "#d1fae5"] },
{ label: "نئون", colors: ["#18181b", "#a3e635", "#22d3ee", "#f97316", "#e879f9", "#fb7185"] },
{ label: "سلطنتی", colors: ["#20123a", "#9370db", "#e6e6fa", "#6b21a8", "#c084fc", "#f3e8ff"] },
];
export function AvatarStudio({ user, onClose }: { user: User; onClose: () => void }) {
const setUser = useAuth((s) => s.setUser);
const fileRef = useRef<HTMLInputElement>(null);
@@ -54,13 +45,18 @@ export function AvatarStudio({ user, onClose }: { user: User; onClose: () => voi
void queryClient.invalidateQueries({ queryKey: ["leaderboard"] });
};
const publish = (fresh: User) => {
queryClient.setQueryData<{ user: User }>(["me"], { user: fresh });
setUser(fresh);
invalidate();
};
const doSave = async (body: { avatarVariant?: string; avatarPalette?: string; avatarPhoto?: string }) => {
setBusy(true);
setError(null);
try {
const { user: fresh } = await http.put<{ user: User }>("/api/me/avatar", body);
setUser(fresh);
invalidate();
publish(fresh);
} catch (e) {
setError(toUserError(e, "خطا در ذخیره"));
} finally {
@@ -95,8 +91,7 @@ export function AvatarStudio({ user, onClose }: { user: User; onClose: () => voi
form.append("file", new File([blob], "avatar.png", { type: "image/png" }), "avatar.png");
const { url } = await apiForm<{ url: string }>("/api/me/avatar-photo", form);
const { user: fresh } = await http.put<{ user: User }>("/api/me/avatar", { avatarPhoto: url });
setUser(fresh);
invalidate();
publish(fresh);
setPending(null);
} catch (e) {
setError(toUserError(e, "خطا در آپلود"));
@@ -106,7 +101,8 @@ export function AvatarStudio({ user, onClose }: { user: User; onClose: () => voi
};
return (
<div className="space-y-4">
<div className="flex min-h-0 flex-1 flex-col">
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pe-1">
<div className="flex items-center gap-4">
<div className="relative shrink-0">
<UserAvatar
@@ -162,7 +158,7 @@ export function AvatarStudio({ user, onClose }: { user: User; onClose: () => voi
<div>
<p className="mb-1.5 text-[11px] font-bold text-muted-foreground">رنگ‌ها</p>
<div className="space-y-1">
{PALETTES.map((p) => {
{AVATAR_PALETTES.map((p) => {
const active = JSON.stringify(p.colors) === draftP;
return (
<button
@@ -224,8 +220,9 @@ export function AvatarStudio({ user, onClose }: { user: User; onClose: () => voi
</div>
{error && <p className="text-[11px] font-bold text-destructive">{error}</p>}
</div>
<div className="sticky -bottom-3 z-10 -mx-2.5 flex items-center justify-between gap-2 bg-popover px-2.5 pb-1 pt-2">
<div className="mt-3 flex shrink-0 items-center justify-between gap-2 border-t border-border bg-popover pt-3">
<div className="flex items-center gap-2">
{dirty && (
<Button variant="ghost" size="sm" onClick={reset} disabled={busy}>
@@ -250,7 +247,16 @@ export function AvatarStudio({ user, onClose }: { user: User; onClose: () => voi
<AvatarCropDialog
file={pending}
busy={busy}
onCancel={() => !busy && setPending(null)}
error={error}
onCancel={() => {
if (busy) return;
setPending(null);
setError(null);
}}
onInvalid={() => {
setPending(null);
setError("خواندن این عکس ممکن نشد. JPEG، PNG یا WebP را انتخاب کنید.");
}}
onCrop={(blob) => onUpload(blob)}
/>
+225
View File
@@ -0,0 +1,225 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Check, Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { AVATAR_PALETTES } from "@/components/ui/user-avatar";
import { BANNER_FILLS, BANNER_HEIGHT, BANNER_WIDTH, drawBannerFill, type BannerFill } from "@/lib/banner-pattern";
import { toast } from "@/components/providers";
import { cn } from "@/lib/utils";
function FillThumb({
colors,
fill,
seed,
label,
active,
onClick,
}: {
colors: string[];
fill: BannerFill;
seed: string;
label: string;
active: boolean;
onClick: () => void;
}) {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
drawBannerFill(ctx, canvas.width, canvas.height, colors, fill, seed);
}, [colors, fill, seed]);
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cn(
"flex flex-col gap-1 rounded-lg border p-1 text-center transition-colors",
active ? "border-primary bg-primary/10 ring-2 ring-primary" : "border-border hover:bg-muted",
)}
>
<canvas ref={ref} width={240} height={80} className="h-10 w-full rounded-md" />
<span className={cn("text-[10px] font-bold", active ? "text-primary" : "text-muted-foreground")}>{label}</span>
</button>
);
}
function PatternCanvas({
canvasRef,
colors,
seed,
fill,
onReady,
}: {
canvasRef: React.RefObject<HTMLCanvasElement | null>;
colors: string[];
seed: string;
fill: BannerFill;
onReady: (ready: boolean) => void;
}) {
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) {
onReady(false);
return;
}
drawBannerFill(ctx, BANNER_WIDTH, BANNER_HEIGHT, colors, fill, seed);
onReady(true);
}, [canvasRef, colors, seed, fill, onReady]);
return <canvas ref={canvasRef} width={BANNER_WIDTH} height={BANNER_HEIGHT} className="absolute inset-0 h-full w-full" />;
}
function HeaderPreview({ children }: { children: React.ReactNode }) {
return (
<div className="relative aspect-[3/1] w-full overflow-hidden rounded-xl border-2 border-border bg-muted">
{children}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-card via-card/25 to-transparent" />
<div
aria-hidden
className="pointer-events-none absolute bottom-0 left-1/2 aspect-square w-[18%] -translate-x-1/2 translate-y-1/2 rounded-full bg-muted ring-4 ring-card"
/>
</div>
);
}
export function BannerEditor({
open,
busy,
onOpenChange,
onSave,
onRemove,
}: {
open: boolean;
busy?: boolean;
onOpenChange: (open: boolean) => void;
onSave: (blob: Blob) => Promise<void>;
onRemove?: () => void;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [paletteIndex, setPaletteIndex] = useState(0);
const [fill, setFill] = useState<BannerFill>("gradient");
const [ready, setReady] = useState(false);
const palette = AVATAR_PALETTES[paletteIndex] ?? AVATAR_PALETTES[0];
const save = async () => {
try {
const canvas = canvasRef.current;
if (!canvas || !ready) return;
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
if (!blob) {
toast.error("ساخت تصویر بنر ممکن نشد");
return;
}
await onSave(blob);
} catch {
toast.error("ذخیره بنر ممکن نشد");
}
};
return (
<Dialog open={open} onOpenChange={(next) => !busy && onOpenChange(next)}>
<DialogContent className="flex max-h-[min(92dvh,44rem)] w-[calc(100%-1.5rem)] max-w-xl flex-col gap-0 overflow-hidden p-0 sm:max-w-xl">
<DialogHeader className="shrink-0 space-y-1 px-4 pe-12 pt-4 text-start sm:px-6">
<DialogTitle>ویرایش بنر</DialogTitle>
<DialogDescription>تصویر عریض پشت نام و آواتار در هدر پروفایل. نسبت حدود ۳ به ۱.</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain px-4 py-3 sm:px-6">
<div className="space-y-1.5">
<HeaderPreview>
<PatternCanvas
canvasRef={canvasRef}
colors={palette.colors}
seed={palette.label}
fill={fill}
onReady={setReady}
/>
</HeaderPreview>
<p className="text-[11px] text-muted-foreground">پیش‌نمایش هدر — سایه و جای آواتار فقط برای دیدن نتیجه‌اند و داخل تصویر ذخیره نمی‌شوند.</p>
</div>
<div>
<p className="mb-1.5 text-[11px] font-bold text-muted-foreground">رنگ‌ها</p>
<div className="space-y-1">
{AVATAR_PALETTES.map((p, i) => {
const active = i === paletteIndex;
return (
<button
key={p.label}
type="button"
onClick={() => setPaletteIndex(i)}
aria-pressed={active}
className={cn(
"flex w-full items-center justify-between rounded-lg border px-2.5 py-1.5 transition-colors",
active ? "border-primary bg-primary/10" : "border-border hover:bg-muted",
)}
>
<span className="text-xs font-bold">{p.label}</span>
<span className="flex items-center gap-0.5">
{p.colors.slice(0, 5).map((c) => (
<span
key={c}
className="h-3.5 w-3.5 rounded-full ring-1 ring-black/10 dark:ring-white/10"
style={{ background: c }}
/>
))}
{active && <Check className="ms-1 h-3.5 w-3.5 text-primary" />}
</span>
</button>
);
})}
</div>
</div>
<div>
<p className="mb-1.5 text-[11px] font-bold text-muted-foreground">پس‌زمینه</p>
<div className="grid grid-cols-3 gap-2">
{BANNER_FILLS.map((item) => (
<FillThumb
key={item.id}
colors={palette.colors}
fill={item.id}
seed={palette.label}
label={item.label}
active={fill === item.id}
onClick={() => setFill(item.id)}
/>
))}
</div>
</div>
</div>
<DialogFooter className="shrink-0 gap-2 border-t border-border bg-popover px-4 py-3 sm:flex-row sm:justify-between sm:space-x-0 sm:px-6">
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
انصراف
</Button>
{onRemove && (
<Button type="button" variant="outline" onClick={onRemove} disabled={busy}>
برداشتن بنر
</Button>
)}
</div>
<Button type="button" onClick={() => void save()} disabled={!ready || busy}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check />}
{busy ? "در حال ذخیره…" : "ذخیره بنر"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+28 -51
View File
@@ -1,42 +1,25 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { HeartHandshake, LogIn, MessageSquareHeart } from "lucide-react";
import Link from "next/link";
import { ExternalLink, Share2 } from "lucide-react";
import { http, toUserError } from "@/lib/api";
import { useAuth } from "@/lib/auth-store";
import { Panel } from "@/components/panel";
import { Button } from "@/components/ui/button";
export type SocialLink = { name: string; url: string };
export type CommunityPayload = {
donation: {
enabled: boolean;
note: string;
mode: "chat";
contactId?: number;
draft: string;
};
socials: SocialLink[];
};
function openDonationChat(contactId: number | undefined, draft: string) {
window.dispatchEvent(
new CustomEvent("pargar:chat", {
detail: { open: true, with: contactId, draft },
}),
);
}
export function CommunityPanel() {
const user = useAuth((s) => s.user);
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: ["community", user?.id ?? "guest"],
queryKey: ["community"],
queryFn: () => http.get<CommunityPayload>("/api/community"),
});
if (isLoading) {
return (
<div className="h-72 animate-pulse rounded-2xl border-2 border-border bg-muted/40" />
);
return <div className="h-72 animate-pulse rounded-2xl border-2 border-border bg-muted/40" />;
}
if (isError) {
@@ -50,40 +33,34 @@ export function CommunityPanel() {
);
}
const donation = data?.donation;
const socials = data?.socials ?? [];
return (
<div className="mx-auto max-w-xl">
<Panel
title="حمایت مالی"
description="بدون کارت عمومی — اگر لاگین باشید، گفتگو با تیم باز می‌شود."
icon={<HeartHandshake className="h-5 w-5" />}
tint="bg-accent/15 text-accent"
title="شبکه‌های پرگار"
description="راه‌های رسمی ارتباط با برنامه. لینک شخصی هر کاربر روی پروفایل خودش است."
icon={<Share2 className="h-5 w-5" />}
tint="bg-primary/15 text-primary"
>
{donation?.enabled ? (
<div className="space-y-4 pt-1">
<p className="text-sm leading-relaxed text-muted-foreground">{donation.note}</p>
<div className="rounded-2xl border border-border bg-muted/20 p-5 text-sm leading-relaxed">
جزئیات واریز فقط در پیام خصوصی رد و بدل می‌شود؛ اینجا چیزی عمومی نیست.
</div>
{user ? (
<Button
className="w-full gap-2"
size="lg"
onClick={() => openDonationChat(donation.contactId, donation.draft)}
>
<MessageSquareHeart className="h-4 w-4" /> پیام دربارهٔ دونیت
</Button>
) : (
<Button asChild className="w-full gap-2" size="lg">
<Link href="/login">
<LogIn className="h-4 w-4" /> ورود برای هماهنگی دونیت
</Link>
</Button>
)}
</div>
{socials.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">هنوز شبکه‌ای ثبت نشده است.</p>
) : (
<p className="py-8 text-center text-sm text-muted-foreground">حمایت فعلاً غیرفعال است.</p>
<ul className="space-y-2 pt-1">
{socials.map((s) => (
<li key={s.url}>
<a
href={s.url}
target="_blank"
rel="noreferrer"
className="flex items-center justify-between gap-3 rounded-xl border-2 border-border/70 px-3 py-2.5 text-sm font-bold transition-colors hover:border-primary/50 hover:bg-primary/5"
>
<span>{s.name}</span>
<ExternalLink className="h-4 w-4 shrink-0 text-muted-foreground" />
</a>
</li>
))}
</ul>
)}
</Panel>
</div>
+17
View File
@@ -0,0 +1,17 @@
import { Snowflake } from "lucide-react";
export function isAccountMonochrome(account?: { isFrozen?: boolean; isClosed?: boolean } | null) {
return Boolean(account?.isFrozen || account?.isClosed);
}
export function FreezeMark({ closed }: { closed?: boolean }) {
const label = closed ? "حساب بسته شده" : "حساب فریز شده";
return (
<Snowflake
aria-hidden={false}
role="img"
aria-label={label}
className="size-[0.85em] shrink-0"
/>
);
}
+65
View File
@@ -0,0 +1,65 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Search } from "lucide-react";
import { http } from "@/lib/api";
type Hit = { kind: string; title: string; href: string };
export function GlobalSearch() {
const router = useRouter();
const [q, setQ] = useState("");
const [hits, setHits] = useState<Hit[]>([]);
const [open, setOpen] = useState(false);
useEffect(() => {
const query = q.trim();
if (query.length < 2) {
setHits([]);
return;
}
const t = setTimeout(() => {
void http
.get<{ results: Hit[] }>(`/api/search?q=${encodeURIComponent(query)}`)
.then((res) => setHits(res.results ?? []))
.catch(() => setHits([]));
}, 250);
return () => clearTimeout(t);
}, [q]);
return (
<div className="relative min-w-0 w-28 sm:w-56">
<Search className="pointer-events-none absolute start-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<input
value={q}
onChange={(e) => {
setQ(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
placeholder="جستجو"
className="h-9 w-full rounded-full border border-border bg-background/70 ps-8 pe-3 text-sm outline-none focus:border-primary"
/>
{open && hits.length > 0 && (
<div className="absolute z-50 mt-1 w-full overflow-hidden rounded-xl border bg-popover shadow-lg">
{hits.map((h) => (
<button
key={h.kind + h.href + h.title}
type="button"
className="block w-full px-3 py-2 text-start text-sm hover:bg-muted"
onClick={() => {
setOpen(false);
setQ("");
router.push(h.href);
}}
>
<span className="text-[10px] text-muted-foreground">{h.kind}</span>
<span className="block truncate font-bold">{h.title}</span>
</button>
))}
</div>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
+12 -3
View File
@@ -28,6 +28,8 @@ import {
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { cn, formatFaNumber } from "@/lib/utils";
import { GlobalSearch } from "@/components/global-search";
import { isVerifiedRole, VerifiedBadge } from "@/components/verified-badge";
const navLinks = [
{ href: "/cap", label: "درخت مهارت" },
@@ -63,6 +65,7 @@ export function Navbar() {
<BrandMark />
</Link>
<div className="flex items-center gap-2">
<GlobalSearch />
<ThemeToggle />
{pathname !== "/login" && (
<Button asChild size="sm" className="font-bold">
@@ -173,7 +176,10 @@ export function Navbar() {
className="h-11 w-11"
/>
<div className="min-w-0">
<p className="truncate text-sm font-extrabold">{liveUser.name}</p>
<p className="flex min-w-0 items-center gap-1 text-sm font-extrabold">
<span className="truncate">{liveUser.name}</span>
{isVerifiedRole(liveUser.role) && <VerifiedBadge role={liveUser.role} />}
</p>
<p className="truncate text-[11px] text-muted-foreground">{liveUser.email}</p>
<p className="text-[11px] text-primary">{roleLabel(liveUser.role)}</p>
</div>
@@ -181,6 +187,9 @@ export function Navbar() {
<DropdownMenuItem onClick={() => router.push("/profile")}>
<User /> پروفایل من
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push("/settings")}>
<User /> تنظیمات
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setStudioOpen(true)}>
<User /> ویرایش آواتار
</DropdownMenuItem>
@@ -200,8 +209,8 @@ export function Navbar() {
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={studioOpen} onOpenChange={setStudioOpen}>
<DialogContent className="max-h-[85vh] max-w-sm gap-3 overflow-y-auto chat-scroll rounded-2xl">
<DialogHeader>
<DialogContent className="flex max-h-[min(85vh,40rem)] max-w-sm flex-col gap-3 overflow-hidden rounded-2xl">
<DialogHeader className="shrink-0">
<DialogTitle className="text-center">ویرایش آواتار</DialogTitle>
</DialogHeader>
<AvatarStudio user={liveUser} onClose={() => setStudioOpen(false)} />
@@ -0,0 +1,48 @@
import Link from "next/link";
import type { LucideIcon } from "lucide-react";
import { SearchX } from "lucide-react";
import { Button } from "@/components/ui/button";
const PERSIAN_LETTER = /[\u0600-\u06FF]/;
/** Keep a short Persian sentence. Drop blank, English, trace-like text, and a copy of the title. */
export function notFoundSentence(
candidate: string | null | undefined,
fallback: string,
title?: string,
): string {
const msg = candidate?.replace(/\s+/g, " ").trim() ?? "";
if (!msg || msg.length > 180 || !PERSIAN_LETTER.test(msg)) return fallback;
if (title && msg === title.trim()) return fallback;
if (/stack trace|uncaught|typeerror|referenceerror|at \S+\(/i.test(msg)) return fallback;
return msg;
}
export function NotFoundState({
title,
description,
href,
actionLabel,
icon: Icon = SearchX,
}: {
title: string;
description: string;
href: string;
actionLabel: string;
icon?: LucideIcon;
}) {
return (
<div className="mx-auto w-full max-w-lg py-6 sm:py-10">
<div className="rounded-2xl border border-border bg-card px-6 py-12 text-center sm:px-10 sm:py-16">
<div className="mx-auto grid h-14 w-14 place-items-center rounded-2xl bg-muted text-muted-foreground">
<Icon className="h-6 w-6" strokeWidth={1.75} aria-hidden />
</div>
<h1 className="mt-6 text-xl font-black tracking-tight sm:text-2xl">{title}</h1>
<p className="mx-auto mt-3 max-w-sm text-sm leading-relaxed text-muted-foreground">{description}</p>
<Button asChild className="mt-8">
<Link href={href}>{actionLabel}</Link>
</Button>
</div>
</div>
);
}
+87 -61
View File
@@ -11,11 +11,11 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { http } from "@/lib/api";
import type { NotificationItem } from "@/lib/types";
import { useRouter } from "next/navigation";
import { SafeMarkdown } from "@/components/safe-markdown";
export function NotificationBell() {
const router = useRouter();
@@ -32,11 +32,15 @@ export function NotificationBell() {
qc.invalidateQueries({ queryKey: ["me"] });
};
const categoryDot: Record<string, string> = {
progress: "bg-primary",
gamification: "bg-gold",
mentor: "bg-accent",
event: "bg-secondary",
const categoryMeta: Record<string, { label: string; dot: string }> = {
progress: { label: "پیشرفت", dot: "bg-primary" },
gamification: { label: "بازی‌سازی", dot: "bg-gold" },
mentor: { label: "منتور", dot: "bg-accent" },
event: { label: "رویداد", dot: "bg-secondary" },
urgent: { label: "فوری", dot: "bg-destructive" },
curriculum: { label: "درس", dot: "bg-primary" },
community: { label: "جامعه", dot: "bg-accent" },
system: { label: "سیستم", dot: "bg-muted-foreground" },
};
return (
@@ -51,67 +55,89 @@ export function NotificationBell() {
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 p-0">
<div className="flex items-center justify-between rounded-t-md border-b border-border bg-muted/40 px-3 py-2.5">
<span className="text-sm font-semibold">اعلان‌ها</span>
<div className="flex items-center gap-1">
{unread > 0 && <Badge variant="destructive" className="px-2 py-0">{unread} جدید</Badge>}
<Button variant="ghost" size="sm" onClick={markAll} className="h-7 gap-1 px-2 text-xs">
<CheckCheck className="h-3.5 w-3.5" /> خواندن همه
<DropdownMenuContent
align="end"
side="bottom"
collisionPadding={12}
className="w-[min(22rem,calc(100vw-1.25rem))] overflow-hidden p-0"
>
<div dir="rtl" className="text-start">
<div className="flex items-center justify-between gap-3 border-b border-border px-3 py-2.5">
<div className="min-w-0">
<p className="text-sm font-semibold">اعلان‌ها</p>
<p className="text-[11px] text-muted-foreground">
{unread > 0 ? `${unread} خوانده‌نشده` : "همه خوانده شده"}
</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={markAll}
disabled={unread === 0}
className="h-8 w-8 shrink-0"
aria-label="خواندن همه"
title="خواندن همه"
>
<CheckCheck className="h-4 w-4" />
</Button>
</div>
</div>
<div className="max-h-[360px] overflow-y-auto">
{isLoading && (
<div className="space-y-3 px-3 py-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-start gap-3">
<Skeleton className="mt-1.5 h-2 w-2 rounded-full" />
<div className="flex-1 space-y-1.5">
<div className="max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto">
{isLoading && (
<div className="space-y-3 px-3 py-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-1.5">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-1/3" />
</div>
</div>
))}
</div>
)}
{!isLoading && (!data?.notifications || data.notifications.length === 0) && (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
اعلانی ندارید.
<br />
<span className="text-xs">با قبولی در یک آزمون یا ثبت‌نام در رویداد، اعلان می‌گیرید.</span>
</div>
)}
{data?.notifications.map((n) => (
<button
key={n.id}
onClick={() => {
if (n.route) router.push(n.route);
}}
className={cn(
"flex w-full items-start gap-3 border-b border-border px-3 py-3 text-right transition-colors hover:bg-muted/60",
!n.readAt && "bg-primary/5"
)}
>
<span className={cn("mt-1.5 h-2 w-2 shrink-0 rounded-full", categoryDot[n.category] ?? "bg-primary")} />
<span className="flex-1 space-y-0.5">
<span className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{n.title}</span>
<span className="shrink-0 text-[10px] text-muted-foreground">
{formatDistanceToNow(new Date(n.createdAt), { addSuffix: true, locale: faIR })}
))}
</div>
)}
{!isLoading && (!data?.notifications || data.notifications.length === 0) && (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
اعلانی ندارید.
<br />
<span className="text-xs">با قبولی در یک آزمون یا ثبت‌نام در رویداد، اعلان می‌گیرید.</span>
</div>
)}
{data?.notifications.map((n) => {
const cat = categoryMeta[n.category] ?? { label: "اعلان", dot: "bg-primary" };
return (
<button
key={n.id}
type="button"
onClick={() => {
if (n.route) router.push(n.route);
}}
className={cn(
"flex w-full items-start gap-2.5 border-b border-border px-3 py-2.5 text-start transition-colors last:border-b-0 hover:bg-muted/60",
!n.readAt && "bg-primary/[0.06]",
)}
>
<span className={cn("mt-1.5 h-2 w-2 shrink-0 rounded-full", n.readAt ? "bg-border" : cat.dot)} />
<span className="min-w-0 flex-1">
<span dir="auto" className="block truncate text-sm font-medium [unicode-bidi:plaintext]">
{n.title}
</span>
{n.body && <SafeMarkdown text={n.body} lines={2} className="mt-0.5 text-xs text-muted-foreground" />}
<span className="mt-1 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span>{cat.label}</span>
<span aria-hidden>·</span>
<span>{formatDistanceToNow(new Date(n.createdAt), { addSuffix: true, locale: faIR })}</span>
</span>
</span>
</span>
{n.body && <span className="block text-xs text-muted-foreground">{n.body}</span>}
</span>
</button>
))}
</button>
);
})}
</div>
<button
type="button"
onClick={() => router.push("/notifications")}
className="w-full border-t border-border bg-muted/40 py-2.5 text-xs font-semibold text-primary hover:bg-muted"
>
مشاهده همه اعلان‌ها
</button>
</div>
<button
onClick={() => router.push("/notifications")}
className="w-full rounded-b-md bg-muted/40 py-2.5 text-xs font-semibold text-primary hover:bg-muted"
>
مشاهده همه اعلان‌ها
</button>
</DropdownMenuContent>
</DropdownMenu>
);
+13 -11
View File
@@ -8,10 +8,10 @@ import { faIR } from "date-fns/locale";
import { http } from "@/lib/api";
import type { NotificationItem } from "@/lib/types";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/empty-state";
import { cn } from "@/lib/utils";
import { SafeMarkdown } from "@/components/safe-markdown";
const categoryMap: Record<
string,
@@ -21,6 +21,10 @@ const categoryMap: Record<
gamification: { label: "بازی‌سازی", dot: "bg-gold", classes: "bg-gold/15 text-gold" },
mentor: { label: "منتور", dot: "bg-accent", classes: "bg-accent/15 text-accent" },
event: { label: "رویداد", dot: "bg-secondary", classes: "bg-secondary/15 text-secondary" },
urgent: { label: "فوری", dot: "bg-destructive", classes: "bg-destructive/10 text-destructive" },
curriculum: { label: "درس", dot: "bg-primary", classes: "bg-primary/10 text-primary" },
community: { label: "جامعه", dot: "bg-accent", classes: "bg-accent/15 text-accent" },
system: { label: "سیستم", dot: "bg-muted-foreground", classes: "bg-muted text-foreground" },
};
export function NotificationsPanel() {
@@ -82,17 +86,15 @@ export function NotificationsPanel() {
!n.readAt ? "border-primary/30 bg-primary/5" : "border-border bg-card"
)}
>
<span className={cn("mt-2 h-2.5 w-2.5 shrink-0 rounded-full", cat.dot)} />
<span className="flex-1 space-y-1">
<span className="flex flex-wrap items-center gap-2">
<span className="font-medium">{n.title}</span>
<Badge variant="outline" className={cn("px-1.5 py-0 text-[10px] rounded-lg", cat.classes)}>
{cat.label}
</Badge>
<span className={cn("mt-2 h-2 w-2 shrink-0 rounded-full", n.readAt ? "bg-border" : cat.dot)} />
<span className="min-w-0 flex-1">
<span dir="auto" className="block truncate font-medium [unicode-bidi:plaintext]">
{n.title}
</span>
{n.body && <span className="block text-sm text-muted-foreground">{n.body}</span>}
<span className="block text-[11px] text-muted-foreground/70">
{formatDistanceToNow(new Date(n.createdAt), { addSuffix: true, locale: faIR })}
{n.body && <SafeMarkdown text={n.body} lines={2} className="mt-0.5 text-sm text-muted-foreground" />}
<span className="mt-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span className={cn("rounded-md px-1.5 py-0.5", cat.classes)}>{cat.label}</span>
<span>{formatDistanceToNow(new Date(n.createdAt), { addSuffix: true, locale: faIR })}</span>
</span>
</span>
</button>
+254
View File
@@ -0,0 +1,254 @@
"use client";
import Link from "next/link";
import { useEffect, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { BannerEditor } from "@/components/banner-editor";
import { FreezeMark, isAccountMonochrome } from "@/components/freeze-mark";
import { toast } from "@/components/providers";
import { Button } from "@/components/ui/button";
import { UserAvatar, avatarPropsOf, type AvatarVariantName } from "@/components/ui/user-avatar";
import { isVerifiedRole, VerifiedBadge } from "@/components/verified-badge";
import { apiForm, http, toUserError } from "@/lib/api";
import { queryClient } from "@/lib/query-client";
import type { User } from "@/lib/types";
import { cn } from "@/lib/utils";
export type ProfileHeaderModel = {
name: string;
role?: string | null;
banner?: string;
createdAt?: string;
avatarVariant?: AvatarVariantName;
avatarPalette?: string;
avatarPhoto?: string;
isFrozen?: boolean;
isClosed?: boolean;
};
export function membershipLine(createdAt?: string) {
if (!createdAt) return "";
const date = new Date(createdAt);
if (Number.isNaN(date.getTime())) return "";
return `عضویت از ${date.toLocaleDateString("fa-IR", { year: "numeric", month: "long", day: "numeric" })}`;
}
function useHoverBanner() {
const [on, setOn] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(hover: hover) and (min-width: 640px)");
const sync = () => setOn(mq.matches);
sync();
mq.addEventListener("change", sync);
return () => mq.removeEventListener("change", sync);
}, []);
return on;
}
/** Same header card on the owner profile, a public profile, and anyone opened from the league. */
export function ProfileHeader({
person,
secondary,
editable = false,
bannerBusy = false,
onEditBanner,
onRemoveBanner,
onEditAvatar,
children,
}: {
person: ProfileHeaderModel;
/** When set, replaces the membership line. Owner and public profiles leave this empty. */
secondary?: string;
editable?: boolean;
bannerBusy?: boolean;
onEditBanner?: () => void;
onRemoveBanner?: () => void;
onEditAvatar?: () => void;
children?: ReactNode;
}) {
const [bannerHot, setBannerHot] = useState(false);
const hoverBanner = useHoverBanner();
const hideBanner = () => setBannerHot(false);
const banner = person.banner ?? "";
const bannerLabel = banner ? "جایگزینی بنر" : "گذاشتن بنر";
const line = secondary ?? membershipLine(person.createdAt);
const canEditBanner = editable && !!onEditBanner;
return (
<header className={cn("relative overflow-hidden rounded-2xl border border-border bg-card", isAccountMonochrome(person) && "[filter:grayscale(1)]")}>
<div className="pointer-events-none absolute inset-0" aria-hidden>
{banner ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={banner} alt="" className="h-full w-full object-cover" />
) : (
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-accent/20 to-muted" />
)}
<div className="absolute inset-0 bg-gradient-to-t from-card from-20% via-card/50 via-45% to-transparent" />
</div>
{canEditBanner && hoverBanner ? (
<div
className="absolute inset-x-0 top-0 z-10 h-44"
onPointerEnter={() => setBannerHot(true)}
onPointerLeave={hideBanner}
>
<div
className={`absolute inset-0 bg-background/45 backdrop-blur-[2px] transition-opacity ${
bannerHot ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
}`}
>
<div className="flex h-full items-center justify-center">
<div className="flex flex-wrap items-center justify-center gap-2 px-3">
<button
type="button"
className="rounded-lg bg-card px-3 py-1.5 text-xs font-bold text-foreground shadow-sm"
disabled={bannerBusy}
onClick={onEditBanner}
>
{bannerLabel}
</button>
{banner && onRemoveBanner && (
<button
type="button"
className="rounded-lg bg-card px-3 py-1.5 text-xs font-bold text-foreground shadow-sm disabled:opacity-50"
disabled={bannerBusy}
onClick={onRemoveBanner}
>
برداشتن بنر
</button>
)}
</div>
</div>
</div>
</div>
) : canEditBanner ? (
<button
type="button"
className="absolute inset-0 z-10"
aria-label={bannerLabel}
onClick={onEditBanner}
/>
) : null}
{editable && (
<div className="absolute end-3 top-3 z-30">
<Button type="button" variant="outline" size="sm" className="h-7 border-border/70 bg-card/80 px-2.5 text-[11px] shadow-none backdrop-blur" asChild>
<Link href="/settings">تنظیمات</Link>
</Button>
</div>
)}
<div className="pointer-events-none relative z-20 h-44" />
<div className="pointer-events-none relative z-20 -mt-12 flex items-end justify-start gap-4 px-5 pb-5">
{editable && onEditAvatar ? (
<button
type="button"
onClick={onEditAvatar}
onPointerEnter={hideBanner}
aria-label="ویرایش آواتار"
className="group/avatar pointer-events-auto relative z-20 h-24 w-24 shrink-0 rounded-full"
>
<UserAvatar name={person.name} className="h-24 w-24 ring-4 ring-card" {...avatarPropsOf(person)} />
<span
aria-hidden
className={`pointer-events-none absolute inset-0 grid place-items-center rounded-full bg-background/60 px-1 text-center text-[10px] font-bold leading-tight ring-4 ring-card transition-opacity ${
hoverBanner
? "opacity-0 group-hover/avatar:opacity-100 group-focus-visible/avatar:opacity-100"
: "opacity-0"
}`}
>
ویرایش آواتار
</span>
</button>
) : (
<UserAvatar name={person.name} className="pointer-events-auto h-24 w-24 shrink-0 ring-4 ring-card" {...avatarPropsOf(person)} />
)}
<div
className="pointer-events-auto flex w-fit max-w-full min-w-0 flex-col items-start gap-1"
onPointerEnter={(event) => {
event.stopPropagation();
hideBanner();
}}
>
<h1 className="flex max-w-full items-center gap-1 text-2xl font-black leading-tight tracking-tight">
<span className="min-w-0 truncate">{person.name}</span>
{isVerifiedRole(person.role) && <VerifiedBadge role={person.role} />}
{isAccountMonochrome(person) && <FreezeMark closed={person.isClosed} />}
</h1>
{line ? <p className="w-fit max-w-full text-sm text-muted-foreground">{line}</p> : null}
</div>
</div>
{children}
</header>
);
}
export function OwnerProfileHeader({ user, onEditAvatar }: { user: User; onEditAvatar: () => void }) {
const [editorOpen, setEditorOpen] = useState(false);
const [busy, setBusy] = useState(false);
const profileQ = useQuery({
queryKey: ["me-profile"],
queryFn: () => http.get<{ profile: { banner: string } }>("/api/me/profile"),
});
const banner = profileQ.data?.profile.banner ?? "";
const upload = async (blob: Blob) => {
setBusy(true);
try {
const form = new FormData();
form.append("file", new File([blob], "banner.png", { type: "image/png" }), "banner.png");
const { url } = await apiForm<{ url: string }>("/api/me/avatar-photo", form);
await http.put("/api/me/profile", { banner: url });
await queryClient.invalidateQueries({ queryKey: ["me-profile"] });
toast.success("بنر ذخیره شد");
return true;
} catch (e) {
toast.error(toUserError(e, "ذخیره بنر ممکن نشد"));
return false;
} finally {
setBusy(false);
}
};
const removeBanner = async () => {
setBusy(true);
try {
await http.put("/api/me/profile", { banner: "" });
await queryClient.invalidateQueries({ queryKey: ["me-profile"] });
setEditorOpen(false);
} catch (e) {
toast.error(toUserError(e, "برداشتن بنر ممکن نشد"));
} finally {
setBusy(false);
}
};
return (
<ProfileHeader
person={{
name: user.name,
role: user.role,
banner,
createdAt: user.createdAt,
avatarVariant: user.avatarVariant,
avatarPalette: user.avatarPalette,
avatarPhoto: user.avatarPhoto,
isFrozen: user.isFrozen,
isClosed: user.isClosed,
}}
editable
bannerBusy={busy}
onEditBanner={() => setEditorOpen(true)}
onRemoveBanner={banner ? () => void removeBanner() : undefined}
onEditAvatar={onEditAvatar}
>
<BannerEditor
open={editorOpen}
busy={busy}
onOpenChange={setEditorOpen}
onRemove={banner ? () => void removeBanner() : undefined}
onSave={async (blob) => {
const ok = await upload(blob);
if (ok) setEditorOpen(false);
}}
/>
</ProfileHeader>
);
}
+8 -1
View File
@@ -16,7 +16,14 @@ export function Providers({ children }: { children: React.ReactNode }) {
<MotionConfig reducedMotion="user" transition={softTween}>
<ErrorBoundary area="app">{children}</ErrorBoundary>
</MotionConfig>
<Toaster position="top-center" richColors closeButton />
<Toaster
position="top-center"
richColors
offset={76}
mobileOffset={{ top: "4.25rem", left: "0.75rem", right: "0.75rem" }}
closeButton
toastOptions={{ duration: 4000 }}
/>
</ThemeProvider>
</QueryClientProvider>
);
+19 -1
View File
@@ -15,7 +15,8 @@ import {
Sparkles,
Zap,
} from "lucide-react";
import { api, http, toUserError } from "@/lib/api";
import { NotFoundState, notFoundSentence } from "@/components/not-found-state";
import { ApiError, api, http, toUserError } from "@/lib/api";
import type { QuizData, QuizResult } from "@/lib/types";
import { HeartsMeter } from "@/components/hearts-meter";
import { useLiveUser } from "@/hooks/use-live-user";
@@ -36,9 +37,11 @@ export function QuizExperience({ lessonId }: { lessonId: number }) {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const idOk = Number.isFinite(lessonId) && lessonId > 0;
const { data, isLoading, isError, error: loadError } = useQuery({
queryKey: ["quiz", lessonId],
queryFn: () => http.get<QuizData>(`/api/lessons/${lessonId}/quiz`),
enabled: idOk,
retry: false,
meta: { silentError: true },
});
@@ -73,6 +76,21 @@ export function QuizExperience({ lessonId }: { lessonId: number }) {
}
};
if (!idOk || (loadError instanceof ApiError && loadError.status === 404)) {
return (
<NotFoundState
title="آزمون پیدا نشد"
description={notFoundSentence(
loadError ? toUserError(loadError, "") : "",
"این آزمون در سامانه نیست.",
"آزمون پیدا نشد",
)}
href="/cap"
actionLabel="بازگشت به درخت مهارت"
/>
);
}
if (isLoading) {
return (
<div className="flex min-h-[50vh] items-center justify-center">
+14 -5
View File
@@ -5,7 +5,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowLeft, ArrowRight, Loader2, UserPlus } from "lucide-react";
import { useAuth } from "@/lib/auth-store";
import { http, toUserError } from "@/lib/api";
import { ApiError, http, toUserError } from "@/lib/api";
import type { User } from "@/lib/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -72,9 +72,11 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
const [captchaKey, setCaptchaKey] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [captchaError, setCaptchaError] = useState<string | null>(null);
const goNext = () => {
setError(null);
setCaptchaError(null);
if (step === 1) {
const nErr = validateName(name);
if (nErr) {
@@ -111,6 +113,7 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
const goBack = () => {
setError(null);
setCaptchaError(null);
setStep((s) => Math.max(1, s - 1));
};
@@ -127,7 +130,7 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
return;
}
if (!challenge) {
setError("ابتدا کد امنیتی را بارگذاری کنید");
setCaptchaError("ابتدا کد امنیتی را بارگذاری کنید");
setCaptchaKey((k) => k + 1);
return;
}
@@ -152,7 +155,9 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
router.push("/cap");
} catch (err) {
setError(toUserError(err, "ثبت‌نام ناموفق بود"));
setCaptchaKey((k) => k + 1);
if (!(err instanceof ApiError) || err.status !== 429) {
setCaptchaKey((k) => k + 1);
}
} finally {
setLoading(false);
}
@@ -304,12 +309,16 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
value={captchaAnswer}
onChange={setCaptchaAnswer}
onChallenge={setChallenge}
onError={setError}
onError={setCaptchaError}
/>
</>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
{(error || captchaError) && (
<p className="text-sm text-destructive" role="alert">
{error || captchaError}
</p>
)}
<div className="flex items-stretch gap-2.5 pt-1">
{step > 1 && (
+66
View File
@@ -0,0 +1,66 @@
"use client";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
/** Renders a small markdown subset. Raw HTML is escaped by React text nodes. */
export function SafeMarkdown({
text,
className,
lines,
}: {
text: string;
className?: string;
/** When set, only the first block is shown, clamped to this many lines. */
lines?: 2 | 3;
}) {
const blocks = text.replace(/\r\n/g, "\n").split(/\n{2,}/);
const shown = lines ? blocks.slice(0, 1) : blocks;
return (
<div className={className}>
{shown.map((block, i) => (
<p
key={i}
dir="auto"
className={cn(
"break-words [unicode-bidi:plaintext]",
lines === 2 && "line-clamp-2",
lines === 3 && "line-clamp-3",
!lines && "whitespace-pre-wrap",
)}
>
{inline(block)}
</p>
))}
</div>
);
}
function inline(src: string): ReactNode[] {
const re = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|\*\*([^*]+)\*\*|`([^`]+)`/g;
const nodes: ReactNode[] = [];
let last = 0;
let m: RegExpExecArray | null;
let k = 0;
while ((m = re.exec(src))) {
if (m.index > last) nodes.push(src.slice(last, m.index));
if (m[1] && m[2]) {
nodes.push(
<a key={k++} href={m[2]} target="_blank" rel="noreferrer" className="underline">
{m[1]}
</a>,
);
} else if (m[3]) {
nodes.push(<strong key={k++}>{m[3]}</strong>);
} else if (m[4]) {
nodes.push(
<code key={k++} className="rounded bg-muted px-1">
{m[4]}
</code>,
);
}
last = m.index + m[0].length;
}
if (last < src.length) nodes.push(src.slice(last));
return nodes;
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
export type SectionNavItem<T extends string = string> = {
id: T;
label: string;
};
export type SectionNavGroup<T extends string = string> = {
title?: string;
items: SectionNavItem<T>[];
};
const DESKTOP_STICKY_TOP = 80;
export function SectionColumns({ children }: { children: ReactNode }) {
return (
<div className="sm:grid sm:grid-cols-[15rem_minmax(0,1fr)] sm:items-start sm:gap-8">
{children}
</div>
);
}
export function SectionNav<T extends string>({
label,
groups,
activeId,
onSelect,
}: {
label: string;
groups: SectionNavGroup<T>[];
activeId: T;
onSelect: (id: T) => void;
}) {
const navRef = useRef<HTMLElement>(null);
const [stick, setStick] = useState(false);
useEffect(() => {
const el = navRef.current;
if (!el) return;
const measure = () => {
const desktop = window.matchMedia("(min-width: 640px)").matches;
setStick(desktop && el.offsetHeight <= window.innerHeight - DESKTOP_STICKY_TOP);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(el);
window.addEventListener("resize", measure);
return () => {
observer.disconnect();
window.removeEventListener("resize", measure);
};
}, []);
return (
<nav
ref={navRef}
aria-label={label}
className={cn(
"z-20 -mx-1 mb-4 flex gap-2 bg-background/95 px-1 py-2 backdrop-blur max-sm:sticky max-sm:top-16 max-sm:overflow-x-auto",
"sm:z-10 sm:mx-0 sm:mb-0 sm:flex-col sm:gap-3 sm:self-start sm:overflow-visible sm:rounded-2xl sm:border-2 sm:border-border sm:bg-card sm:p-2 sm:backdrop-blur-none",
stick && "sm:sticky sm:top-20",
)}
>
{groups.map((group) => (
<div
key={group.title ?? group.items[0]?.id ?? "group"}
className="contents sm:flex sm:flex-col sm:gap-1"
>
{group.title ? (
<p className="hidden px-3 pt-2 text-sm font-bold text-muted-foreground sm:block">{group.title}</p>
) : null}
{group.items.map((item) => {
const selected = activeId === item.id;
return (
<button
key={item.id}
type="button"
aria-current={selected ? "true" : undefined}
onClick={() => onSelect(item.id)}
className={cn(
"shrink-0 rounded-full border px-3.5 py-1.5 text-sm font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"sm:w-full sm:rounded-xl sm:px-3 sm:py-2 sm:text-start",
selected
? "border-primary bg-primary text-primary-foreground sm:border-transparent sm:bg-primary/10 sm:text-primary"
: "border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground sm:border-transparent sm:bg-transparent",
)}
>
{item.label}
</button>
);
})}
</div>
))}
</nav>
);
}
export function SectionPanel({
id,
titleId,
title,
description,
children,
}: {
id?: string;
titleId: string;
title: string;
description: string;
children: ReactNode;
}) {
return (
<section id={id} aria-labelledby={titleId} className="min-w-0 rounded-2xl border-2 border-border bg-card">
<header className="border-b border-border/70 px-5 py-4 sm:px-6">
<h2 id={titleId} className="text-lg font-black">
{title}
</h2>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
</header>
<div className="p-5 sm:p-6">{children}</div>
</section>
);
}
+28 -1
View File
@@ -40,4 +40,31 @@ const DropdownMenuItem = React.forwardRef<
));
DropdownMenuItem.displayName = "DropdownMenuItem";
export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem };
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2.5 py-1.5 text-[11px] font-bold text-muted-foreground", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = "DropdownMenuLabel";
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />
));
DropdownMenuSeparator.displayName = "DropdownMenuSeparator";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
};
@@ -19,6 +19,16 @@ export const AVATAR_VARIANTS: { name: AvatarVariantName; label: string }[] = [
{ name: "bauhaus", label: "باهاوس" },
];
/** Same palettes the avatar studio offers, reused for the wide profile banner. */
export const AVATAR_PALETTES: { label: string; colors: string[] }[] = [
{ label: "پیش‌فرض", colors: AVATAR_COLORS },
{ label: "رزین", colors: ["#0f172a", "#38bdf8", "#f0abfc", "#a5f3fc", "#7c3aed", "#f5d0fe"] },
{ label: "صحرایی", colors: ["#1c1917", "#fbbf24", "#fb923c", "#fde68a", "#f97316", "#fed7aa"] },
{ label: "اسکاندی", colors: ["#022c22", "#34d399", "#fef3c7", "#a7f3d0", "#166534", "#d1fae5"] },
{ label: "نئون", colors: ["#18181b", "#a3e635", "#22d3ee", "#f97316", "#e879f9", "#fb7185"] },
{ label: "سلطنتی", colors: ["#20123a", "#9370db", "#e6e6fa", "#6b21a8", "#c084fc", "#f3e8ff"] },
];
export function avatarPaletteOf(raw?: string): string[] {
if (!raw || !raw.startsWith("[")) return AVATAR_COLORS;
try {
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { BadgeCheck } from "lucide-react";
import { cn } from "@/lib/utils";
export function isVerifiedRole(role?: string | null): boolean {
return role === "admin" || role === "mentor";
}
function roleLabel(role?: string | null): string | null {
if (role === "admin") return "ادمین";
if (role === "mentor") return "منتور";
return null;
}
/** Seal sized just under the surrounding name's cap height. Hover and focus show the role. */
export function VerifiedBadge({ role, className }: { role?: string | null; className?: string }) {
const label = roleLabel(role);
const tipId = useId();
const anchor = useRef<HTMLSpanElement>(null);
const hideTimer = useRef<number | null>(null);
const [open, setOpen] = useState(false);
const [focusable, setFocusable] = useState(false);
const [box, setBox] = useState<{ left: number; top: number; below: boolean } | null>(null);
useEffect(() => {
const el = anchor.current;
if (!el) return;
setFocusable(!el.closest("button, a, [role='button']"));
}, []);
const place = () => {
const el = anchor.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const below = rect.top < 36;
setBox({
left: rect.left + rect.width / 2,
top: below ? rect.bottom : rect.top,
below,
});
};
useEffect(() => {
if (!open) return;
const onMove = () => place();
window.addEventListener("scroll", onMove, true);
window.addEventListener("resize", onMove);
return () => {
window.removeEventListener("scroll", onMove, true);
window.removeEventListener("resize", onMove);
};
}, [open]);
useEffect(() => {
return () => {
if (hideTimer.current != null) window.clearTimeout(hideTimer.current);
};
}, []);
const show = (event?: { stopPropagation: () => void }) => {
event?.stopPropagation();
if (hideTimer.current != null) window.clearTimeout(hideTimer.current);
place();
setOpen(true);
};
const scheduleHide = () => {
if (hideTimer.current != null) window.clearTimeout(hideTimer.current);
hideTimer.current = window.setTimeout(() => setOpen(false), 60);
};
if (!label) return null;
return (
<>
<span
ref={anchor}
tabIndex={focusable ? 0 : undefined}
className="pointer-events-auto inline-flex shrink-0 cursor-default items-center rounded-sm leading-none outline-none focus-visible:ring-2 focus-visible:ring-sky-500/70"
onPointerEnter={show}
onPointerLeave={scheduleHide}
onFocus={() => show()}
onBlur={() => setOpen(false)}
aria-describedby={open ? tipId : undefined}
>
<BadgeCheck
aria-hidden={false}
role="img"
aria-label="تأییدشده"
className={cn("size-[0.85em] text-sky-500", className)}
/>
</span>
{open && box
? createPortal(
<span
style={{
position: "fixed",
left: box.left,
top: box.below ? box.top - 4 : box.top + 4,
transform: box.below ? "translate(-50%, 0)" : "translate(-50%, -100%)",
paddingTop: box.below ? 8 : 0,
paddingBottom: box.below ? 0 : 8,
}}
className="pointer-events-auto z-[200]"
onPointerEnter={show}
onPointerLeave={scheduleHide}
>
<span
id={tipId}
role="tooltip"
className="block whitespace-nowrap rounded-md border border-border bg-popover px-2 py-1 text-xs font-bold text-popover-foreground shadow-md"
>
{label}
</span>
</span>,
document.body,
)
: null}
</>
);
}