feat: release v0.1.3 chat, profiles, and media

Keep read receipts for the other person only, and let photos, voice, and files stay cached until they are actually replaced.
This commit is contained in:
ahp
2026-10-09 19:05:59 +03:30
parent 3cce2158e9
commit d9a2abb44f
45 changed files with 2337 additions and 573 deletions
+8 -65
View File
@@ -1,6 +1,7 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
@@ -16,7 +17,6 @@ import {
Globe,
GripVertical,
Heart,
KeyRound,
Layers,
Loader2,
Lock,
@@ -454,6 +454,10 @@ function UsersPanel() {
(currentUser?.role === "mentor" && u.role === "student");
return (
<div key={u.id} className="flex items-center gap-3 px-3 py-2.5">
<Link
href={`/profile/${u.id}`}
className="flex min-w-0 flex-1 items-center gap-3 rounded-md outline-none hover:text-primary focus-visible:ring-2 focus-visible:ring-ring"
>
<UserAvatar name={u.name} className="h-9 w-9" {...avatarPropsOf(u)} />
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 truncate text-sm font-medium">
@@ -475,6 +479,7 @@ function UsersPanel() {
{u.role === "admin" || u.role === "mentor" ? "∞" : formatFaNumber(u.hearts)}
</span>
</div>
</Link>
<div className="flex shrink-0 gap-1.5">
{canMutate && u.id !== currentUser?.id && (
u.isLocked ? (
@@ -515,7 +520,7 @@ function UsersPanel() {
);
}
function UserDialog({
export function UserDialog({
user,
onClose,
onSaved,
@@ -529,18 +534,13 @@ function UserDialog({
const [form, setForm] = useState<User>(user ?? ({} as User));
const [password, setPassword] = useState("");
const [securityAnswer, setSecurityAnswer] = useState("");
const [resetAnswer, setResetAnswer] = useState("");
const [resetPassword, setResetPassword] = useState("");
const [saving, setSaving] = useState(false);
const [resetting, setResetting] = useState(false);
useEffect(() => {
if (user) {
setForm(user);
setPassword("");
setSecurityAnswer("");
setResetAnswer("");
setResetPassword("");
}
}, [user]);
@@ -633,29 +633,6 @@ function UserDialog({
}
};
const doReset = async () => {
if (!form?.id) return;
const pwErr = validatePassword(resetPassword);
if (!resetAnswer.trim() || pwErr) {
toast.error(pwErr ?? "جواب امنیتی و رمز جدید (حداقل ۸ کاراکتر) لازم است");
return;
}
setResetting(true);
try {
await http.post(`/api/admin/users/${form.id}/reset-password`, {
securityAnswer: resetAnswer,
newPassword: resetPassword,
});
toast.success("رمز عبور ریست شد");
setResetAnswer("");
setResetPassword("");
} catch (e) {
toast.error(toUserError(e));
} finally {
setResetting(false);
}
};
return (
<Dialog open={!!user} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
@@ -664,7 +641,7 @@ function UserDialog({
<DialogDescription>
{isNew
? "حساب جدید با تلفن و سوال امنیتی. رمز حداقل ۸ کاراکتر."
: "اطلاعات کاربر را به‌روزرسانی کنید. ریست رمز فقط با جواب امنیتی."}
: "اطلاعات کاربر را به‌روزرسانی کنید."}
</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
@@ -768,40 +745,6 @@ function UserDialog({
/>
</div>
)}
{!isNew && (
<div className="space-y-2 rounded-md border border-dashed border-border p-3">
<p className="text-sm font-medium">ریست رمز با سوال امنیتی</p>
{form.securityQuestion ? (
<p className="text-xs text-muted-foreground">سوال: {form.securityQuestion}</p>
) : (
<p className="text-xs text-destructive">سوال امنیتی تعریف نشده است.</p>
)}
<PasswordInput
value={resetAnswer}
onChange={(e) => setResetAnswer(e.target.value)}
placeholder="جواب امنیتی"
dir="rtl"
autoComplete="off"
/>
<PasswordInput
value={resetPassword}
onChange={(e) => setResetPassword(e.target.value)}
placeholder="رمز جدید (حداقل ۸ کاراکتر)"
minLength={8}
autoComplete="new-password"
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={resetting || !form.hasSecurityAnswer}
onClick={doReset}
>
{resetting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <KeyRound className="h-3.5 w-3.5" />}
ریست رمز
</Button>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>انصراف</Button>
@@ -68,16 +68,18 @@ export function InvitesPanel() {
const [editBusy, setEditBusy] = useState(false);
const [page, setPage] = useState(1);
const [q, setQ] = useState("");
const [status, setStatus] = useState<"" | RegistrationInvite["status"]>("");
useEffect(() => {
setPage(1);
}, [q]);
}, [q, status]);
const { data, isLoading } = useQuery({
queryKey: ["admin", "invites", q, page],
queryKey: ["admin", "invites", q, status, page],
queryFn: () => {
const params = new URLSearchParams(buildPageQuery(page));
if (q.trim()) params.set("q", q.trim());
if (status) params.set("status", status);
return http.get<Paginated<RegistrationInvite>>(`/api/admin/invites?${params.toString()}`);
},
});
@@ -296,14 +298,43 @@ export function InvitesPanel() {
</CardHeader>
<CardContent className="space-y-4">
<ListToolbar>
<div className="flex flex-wrap items-center gap-2">
{(
[
["", "همه"],
["active", "فعال"],
["paused", "متوقف"],
["exhausted", "تکمیل‌شده"],
["revoked", "لغو شده"],
] as const
).map(([value, label]) => (
<Button
key={value || "all"}
type="button"
size="sm"
variant={status === value ? "default" : "outline"}
onClick={() => setStatus(value)}
>
{label}
</Button>
))}
</div>
<Input
placeholder="جستجو با برچسب…"
value={q}
onChange={(e) => setQ(e.target.value)}
className="max-w-xs"
/>
{q && (
<Button type="button" size="sm" variant="ghost" onClick={() => setQ("")}>
{(q || status) && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
setQ("");
setStatus("");
}}
>
پاک کردن
</Button>
)}
@@ -315,7 +346,7 @@ export function InvitesPanel() {
)}
{!isLoading && invites.length === 0 && (
<p className="py-6 text-center text-sm text-muted-foreground">
{q.trim() ? "نتیجه‌ای یافت نشد." : "هنوز لینک عضویتی ساخته نشده است."}
{q.trim() || status ? "نتیجه‌ای یافت نشد." : "هنوز لینک عضویتی ساخته نشده است."}
</p>
)}
<div className="space-y-2">
@@ -1,7 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
Flame,
@@ -34,6 +34,12 @@ import {
} from "@/components/ui/dialog";
import { toast } from "@/components/providers";
import { cn, formatFaNumber } from "@/lib/utils";
import {
DEFAULT_PAGE_SIZE,
ListPagination,
buildPageQuery,
type Paginated,
} from "@/components/admin/list-pagination";
type SortKey = "xp" | "progress" | "activity" | "streak";
type FilterKey = "all" | "active" | "locked";
@@ -58,6 +64,11 @@ export function LearningPanel() {
const [sort, setSort] = useState<SortKey>("xp");
const [selectedId, setSelectedId] = useState<number | null>(null);
const [unlocking, setUnlocking] = useState(false);
const [page, setPage] = useState(1);
useEffect(() => {
setPage(1);
}, [q, filter, sort]);
const overviewQ = useQuery({
queryKey: ["admin", "learning", "overview"],
@@ -66,11 +77,17 @@ export function LearningPanel() {
});
const studentsQ = useQuery({
queryKey: ["admin", "learning", "students", q],
queryFn: () =>
http.get<{ students: StudentLearningRow[] }>(
`/api/admin/learning/students?q=${encodeURIComponent(q)}`
),
queryKey: ["admin", "learning", "students", q, filter, sort, page],
queryFn: () => {
const params = new URLSearchParams(buildPageQuery(page));
if (q.trim()) params.set("q", q.trim());
if (filter !== "all") params.set("filter", filter);
params.set("sort", sort);
return http.get<Paginated<StudentLearningRow>>(
`/api/admin/learning/students?${params.toString()}`,
);
},
placeholderData: keepPreviousData,
retry: 1,
});
@@ -98,27 +115,9 @@ export function LearningPanel() {
};
const overview = overviewQ.data?.overview;
const students = useMemo(() => {
let rows = [...(studentsQ.data?.students ?? [])];
if (filter === "active") rows = rows.filter((r) => r.isActive && !r.isLocked);
if (filter === "locked") rows = rows.filter((r) => r.isLocked);
rows.sort((a, b) => {
switch (sort) {
case "progress":
return b.progressPct - a.progressPct;
case "streak":
return b.streakCurrent - a.streakCurrent;
case "activity": {
const at = a.lastActivityAt ? new Date(a.lastActivityAt).getTime() : 0;
const bt = b.lastActivityAt ? new Date(b.lastActivityAt).getTime() : 0;
return bt - at;
}
default:
return b.xp - a.xp;
}
});
return rows;
}, [studentsQ.data?.students, filter, sort]);
const students = studentsQ.data?.items ?? [];
const studentsTotal = studentsQ.data?.total ?? 0;
const studentsPageSize = studentsQ.data?.pageSize ?? DEFAULT_PAGE_SIZE;
const cards = overview
? [
@@ -177,7 +176,7 @@ export function LearningPanel() {
<div>
<CardTitle className="text-base">جدول هنرجویان</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
{formatFaNumber(students.length)} نفر · برای جزئیات روی ردیف بزنید
{formatFaNumber(studentsTotal)} نفر · برای جزئیات روی ردیف بزنید
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
@@ -295,6 +294,12 @@ export function LearningPanel() {
<p className="py-10 text-center text-sm text-muted-foreground">هنرجویی یافت نشد.</p>
)}
</div>
<ListPagination
page={page}
pageSize={studentsPageSize}
total={studentsTotal}
onPageChange={setPage}
/>
</CardContent>
</Card>
@@ -305,9 +310,11 @@ export function LearningPanel() {
{detailQ.data?.learning.user.name ?? "جزئیات یادگیری"}
</DialogTitle>
<DialogDescription>
{detailQ.data
? `@${detailQ.data.learning.user.username} · ${pct(detailQ.data.learning.user.progressPct)} مسیر`
: "در حال بارگذاری…"}
{detailQ.isError
? toUserError(detailQ.error, "بارگذاری جزئیات ممکن نشد")
: detailQ.data
? `@${detailQ.data.learning.user.username} · ${pct(detailQ.data.learning.user.progressPct)} مسیر`
: "در حال بارگذاری…"}
</DialogDescription>
</DialogHeader>
<div className="space-y-5 px-6 py-5">
@@ -1,7 +1,7 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { Loader2 } from "lucide-react";
import { http, toUserError } from "@/lib/api";
import type { CertificatePhysicalOrder, PhysicalCertSettings, PhysicalOrderStatus } from "@/lib/types";
@@ -11,6 +11,11 @@ import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { toast } from "@/components/providers";
import { formatFaNumber } from "@/lib/utils";
import {
DEFAULT_PAGE_SIZE,
ListPagination,
buildPageQuery,
} from "@/components/admin/list-pagination";
const STATUS_LABEL: Record<PhysicalOrderStatus, string> = {
requested: "درخواست‌شده",
@@ -27,16 +32,29 @@ const NEXT: Partial<Record<PhysicalOrderStatus, PhysicalOrderStatus>> = {
export function PhysicalOrdersPanel() {
const qc = useQueryClient();
const [statusFilter, setStatusFilter] = useState("");
const [page, setPage] = useState(1);
const [notes, setNotes] = useState<Record<number, string>>({});
const [tracking, setTracking] = useState<Record<number, string>>({});
const [busy, setBusy] = useState<number | null>(null);
useEffect(() => {
setPage(1);
}, [statusFilter]);
const { data, isLoading } = useQuery({
queryKey: ["admin", "physical-orders", statusFilter],
queryFn: () =>
http.get<{ orders: CertificatePhysicalOrder[]; physical: PhysicalCertSettings }>(
`/api/admin/physical-orders?status=${encodeURIComponent(statusFilter)}`,
),
queryKey: ["admin", "physical-orders", statusFilter, page],
queryFn: () => {
const params = new URLSearchParams(buildPageQuery(page));
if (statusFilter) params.set("status", statusFilter);
return http.get<{
orders: CertificatePhysicalOrder[];
physical: PhysicalCertSettings;
total: number;
page: number;
pageSize: number;
}>(`/api/admin/physical-orders?${params.toString()}`);
},
placeholderData: keepPreviousData,
});
const update = async (id: number, status: PhysicalOrderStatus) => {
@@ -151,6 +169,12 @@ export function PhysicalOrdersPanel() {
{!isLoading && (data?.orders ?? []).length === 0 && (
<p className="py-8 text-center text-sm text-muted-foreground">سفارشی نیست.</p>
)}
<ListPagination
page={page}
pageSize={data?.pageSize ?? DEFAULT_PAGE_SIZE}
total={data?.total ?? 0}
onPageChange={setPage}
/>
</CardContent>
</Card>
);
@@ -0,0 +1,245 @@
"use client";
import { useState, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { KeyRound, Loader2, Lock, Mail, Pencil, Phone, Shield, ShieldQuestion, Trash2, Unlock } from "lucide-react";
import { UserDialog } from "@/components/admin-studio";
import { useConfirm } from "@/components/admin/use-confirm";
import { Panel } from "@/components/panel";
import { toast } from "@/components/providers";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { Skeleton } from "@/components/ui/skeleton";
import { http, toUserError } from "@/lib/api";
import { useAuth } from "@/lib/auth-store";
import { validatePassword } from "@/lib/validation";
import { cn } from "@/lib/utils";
import type { User } from "@/lib/types";
export function AdminProfileManage({ userId }: { userId: number }) {
const me = useAuth((s) => s.user);
const router = useRouter();
const qc = useQueryClient();
const { confirm, dialog } = useConfirm();
const [busy, setBusy] = useState<"lock" | "delete" | "reset" | null>(null);
const [editing, setEditing] = useState<User | null>(null);
const [resetOpen, setResetOpen] = useState(false);
const [nextPassword, setNextPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const accountQ = useQuery({
queryKey: ["admin", "user", userId],
queryFn: () => http.get<{ user: User }>(`/api/admin/users/${userId}`),
retry: 1,
});
const refresh = () => {
qc.invalidateQueries({ queryKey: ["admin", "user", userId] });
qc.invalidateQueries({ queryKey: ["public-profile", userId] });
qc.invalidateQueries({ queryKey: ["admin", "users"] });
qc.invalidateQueries({ queryKey: ["admin", "stats"] });
};
const account = accountQ.data?.user;
const isSelf = account?.id === me?.id;
const act = async (kind: "lock" | "unlock") => {
setBusy("lock");
try {
await http.post(`/api/admin/users/${userId}/${kind}`);
toast.success(kind === "lock" ? "حساب قفل شد" : "حساب فعال شد");
refresh();
} catch (e) {
toast.error(toUserError(e));
} finally {
setBusy(null);
}
};
const closeReset = () => {
setResetOpen(false);
setNextPassword("");
setConfirmPassword("");
};
const resetPassword = async () => {
const pwErr = validatePassword(nextPassword, confirmPassword);
if (pwErr) {
toast.error(pwErr);
return;
}
setBusy("reset");
try {
await http.post(`/api/admin/users/${userId}/reset-password`, { newPassword: nextPassword });
toast.success("رمز عبور عوض شد");
closeReset();
} catch (e) {
toast.error(toUserError(e));
} finally {
setBusy(null);
}
};
const del = async () => {
if (!account) {
return;
}
if (
!(await confirm(
"حذف کاربر",
`حساب «${account.name}» (${account.email}) برای همیشه حذف شود؟ این عمل بازگشت‌ناپذیر است.`,
))
) {
return;
}
setBusy("delete");
try {
await http.del(`/api/admin/users/${account.id}`);
toast.success("کاربر حذف شد");
router.push("/admin?tab=users");
} catch (e) {
toast.error(toUserError(e));
} finally {
setBusy(null);
}
};
return (
<>
{dialog}
<Panel
title="مدیریت حساب"
description="فقط ادمین این بخش را می‌بیند."
icon={<Shield className="h-5 w-5" />}
tint="bg-destructive/10 text-destructive"
>
{accountQ.isPending && (
<div className="space-y-2" aria-busy="true">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-9 w-full" />
</div>
)}
{accountQ.isError && (
<p className="text-sm font-bold text-destructive">{toUserError(accountQ.error, "حساب پیدا نشد")}</p>
)}
{account && (
<div className="space-y-4">
<dl className="grid gap-3 sm:grid-cols-2">
<AccountFact icon={<Mail className="h-3.5 w-3.5" />} label="ایمیل" value={account.email} ltr />
<AccountFact
icon={<Phone className="h-3.5 w-3.5" />}
label="تلفن"
value={account.phone || "ثبت نشده"}
ltr
/>
<AccountFact
className="sm:col-span-2"
icon={<ShieldQuestion className="h-3.5 w-3.5" />}
label="سوال امنیتی"
value={account.securityQuestion || "تعریف نشده"}
/>
</dl>
<div className="space-y-2 border-t border-border/70 pt-4">
{!isSelf &&
(account.isLocked ? (
<Button size="sm" variant="success" disabled={busy !== null} onClick={() => act("unlock")}>
{busy === "lock" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Unlock className="h-3.5 w-3.5" />}
فعال‌سازی
</Button>
) : (
<Button size="sm" variant="outline" disabled={busy !== null} onClick={() => act("lock")}>
{busy === "lock" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Lock className="h-3.5 w-3.5" />}
قفل
</Button>
))}
<div className={cn("grid gap-2", isSelf ? "sm:grid-cols-2" : "sm:grid-cols-3")}>
<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 && (
<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>
</div>
</div>
)}
</Panel>
<UserDialog user={editing} onClose={() => setEditing(null)} onSaved={refresh} />
<Dialog open={resetOpen} onOpenChange={(open) => !open && closeReset()}>
<DialogContent>
<DialogHeader>
<DialogTitle>ریست رمز</DialogTitle>
<DialogDescription>رمز جدید همین حساب را بنویسید.</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<div className="space-y-1.5">
<Label htmlFor="reset-password">رمز جدید</Label>
<PasswordInput
id="reset-password"
value={nextPassword}
onChange={(e) => setNextPassword(e.target.value)}
autoComplete="new-password"
placeholder="حداقل ۸ کاراکتر"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="reset-password-confirm">تکرار رمز</Label>
<PasswordInput
id="reset-password-confirm"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
placeholder="همان رمز را دوباره بنویسید"
/>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={closeReset}>
انصراف
</Button>
<Button onClick={() => void resetPassword()} disabled={busy === "reset"}>
{busy === "reset" ? <Loader2 className="h-4 w-4 animate-spin" /> : <KeyRound className="h-4 w-4" />}
ذخیره رمز
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function AccountFact({
icon,
label,
value,
ltr,
className,
}: {
icon: ReactNode;
label: string;
value: string;
ltr?: boolean;
className?: string;
}) {
return (
<div className={cn("rounded-xl border border-border/80 bg-muted/25 px-3.5 py-3", className)}>
<dt className="flex items-center gap-1.5 text-[11px] font-bold text-muted-foreground">
{icon}
{label}
</dt>
<dd className={cn("mt-1.5 text-sm font-semibold leading-relaxed [overflow-wrap:anywhere]", ltr && "font-latin")} dir={ltr ? "ltr" : undefined}>
{value}
</dd>
</div>
);
}
@@ -142,7 +142,7 @@ export function StatsPanel() {
const s = data?.stats;
const cards = [
{ label: "هنرجویان", value: s?.users ?? 0, icon: Users, klass: "text-primary bg-primary/10", hint: "حساب‌های فعال در سامانه" },
{ label: "هنرجویان", value: s?.users ?? 0, icon: Users, klass: "text-primary bg-primary/10", hint: "هنرجویان با حساب فعال" },
{ label: "درس‌ها", value: s?.lessons ?? 0, icon: BookOpen, klass: "text-secondary bg-secondary/10", hint: "درس‌های ثبت‌شده در درخت" },
{ label: "سوالات", value: s?.questions ?? 0, icon: Activity, klass: "text-accent bg-accent/10", hint: "بانک سوالات آزمون" },
{ label: "رویدادها", value: s?.events ?? 0, icon: CalendarPlus, klass: "text-gold bg-gold/10", hint: "جلسات و کارگاه‌ها" },
+1
View File
@@ -24,6 +24,7 @@ const PROTECTED_PREFIXES = [
"/quiz",
"/notifications",
"/lockout",
"/profile",
];
function needsAuth(p: string) {
+155
View File
@@ -0,0 +1,155 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { RefreshCw } from "lucide-react";
import { http, toUserError } from "@/lib/api";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export type CaptchaChallenge = {
challengeId: string;
imageBase64: string;
expiresIn?: number;
};
const FALLBACK_SECONDS = 120;
export function CaptchaField({
id,
value,
onChange,
onChallenge,
onError,
}: {
id: string;
value: string;
onChange: (value: string) => void;
onChallenge: (challenge: CaptchaChallenge | null) => void;
onError?: (message: string | null) => void;
}) {
const [challenge, setChallenge] = useState<CaptchaChallenge | null>(null);
const [loading, setLoading] = useState(true);
const [left, setLeft] = useState(FALLBACK_SECONDS);
const gen = useRef(0);
const rotating = useRef(false);
const onChallengeRef = useRef(onChallenge);
const onChangeRef = useRef(onChange);
const onErrorRef = useRef(onError);
onChallengeRef.current = onChallenge;
onChangeRef.current = onChange;
onErrorRef.current = onError;
const load = useCallback(async (silent: boolean) => {
if (silent && rotating.current) return;
if (silent) rotating.current = true;
const ticket = ++gen.current;
if (!silent) setLoading(true);
try {
const data = await http.get<CaptchaChallenge>("/api/auth/captcha");
if (ticket !== gen.current) return;
setChallenge(data);
onChallengeRef.current(data);
setLeft(Math.max(15, Math.round(data.expiresIn || FALLBACK_SECONDS)));
onChangeRef.current("");
onErrorRef.current?.(null);
} catch (err) {
if (ticket !== gen.current) return;
if (!silent) {
setChallenge(null);
onChallengeRef.current(null);
}
onErrorRef.current?.(toUserError(err, "بارگذاری کد امنیتی ممکن نشد"));
} finally {
rotating.current = false;
if (ticket === gen.current && !silent) setLoading(false);
}
}, []);
useEffect(() => {
void load(false);
}, [load]);
useEffect(() => {
if (!challenge) return;
const timer = window.setInterval(() => {
setLeft((n) => {
if (n <= 1) {
void load(true);
return n;
}
return n - 1;
});
}, 1000);
return () => window.clearInterval(timer);
}, [challenge, load]);
const minutes = String(Math.floor(left / 60)).padStart(1, "0");
const seconds = String(left % 60).padStart(2, "0");
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor={id}>کد امنیتی</Label>
<button
type="button"
onClick={() => void load(false)}
disabled={loading}
className="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-4 hover:underline disabled:opacity-50"
>
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
تازه‌سازی
{challenge && (
<span className="tabular-nums text-muted-foreground" dir="ltr">
{minutes}:{seconds}
</span>
)}
</button>
</div>
<div className="flex items-stretch gap-3">
<div
className="relative flex h-14 min-w-[9.5rem] shrink-0 items-center justify-center overflow-hidden rounded-xl border border-border bg-muted/60 shadow-inner"
aria-live="polite"
>
{challenge?.imageBase64 ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`data:image/png;base64,${challenge.imageBase64}`}
alt="کد امنیتی"
width={160}
height={52}
className="h-full w-full object-contain select-none"
draggable={false}
/>
) : (
<span className="text-sm text-muted-foreground">{loading ? "…" : "—"}</span>
)}
{challenge && (
<span
aria-hidden
className="absolute inset-x-0 bottom-0 h-0.5 bg-primary/15"
>
<span
className="block h-full bg-primary transition-[width] duration-1000 ease-linear"
style={{ width: `${Math.min(100, (left / Math.max(challenge.expiresIn || FALLBACK_SECONDS, 1)) * 100)}%` }}
/>
</span>
)}
</div>
<Input
id={id}
type="text"
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
value={value}
onChange={(e) => onChange(e.target.value.toUpperCase())}
required
dir="ltr"
className="h-14 text-center font-mono tracking-widest uppercase placeholder:text-center"
placeholder="کد تصویر"
maxLength={6}
/>
</div>
</div>
);
}
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Crown, Sparkles, Timer, Trophy } from "lucide-react";
import { http, toUserError } from "@/lib/api";
@@ -110,10 +111,11 @@ export function LeaderboardPanel() {
{rest.map((e) => {
const isMe = !!user && e.userId === user.id;
return (
<div
<Link
key={e.userId}
href={`/profile/${e.userId}`}
className={cn(
"flex items-center gap-3 rounded-xl border px-4 py-2.5",
"flex items-center gap-3 rounded-xl border px-4 py-2.5 transition-colors hover:bg-muted/40",
isMe ? "border-primary/50 bg-primary/5 ring-1 ring-primary/30" : "border-border bg-card"
)}
>
@@ -135,7 +137,7 @@ export function LeaderboardPanel() {
<span className="shrink-0 text-sm font-bold text-gold">
{formatFaNumber(Math.round(e.xp))} امتیاز
</span>
</div>
</Link>
);
})}
{rest.length === 0 && entries.length === 0 && (
@@ -197,9 +199,10 @@ function PodiumCard({
}) {
const isMe = myId != null && entry.userId === myId;
return (
<div
<Link
href={`/profile/${entry.userId}`}
className={cn(
"flex flex-col items-center rounded-2xl border-2 border-border bg-card px-3 py-4 text-center shadow-offset-sm",
"flex flex-col items-center rounded-2xl border-2 border-border bg-card px-3 py-4 text-center shadow-offset-sm transition-colors hover:bg-muted/40",
tall ? "pb-6 pt-5" : "pb-4",
isMe && "border-primary/50 ring-2 ring-primary/30",
className
@@ -225,7 +228,7 @@ function PodiumCard({
>
{entry.rank === 1 ? <Crown className="h-4 w-4" /> : formatFaNumber(entry.rank)}
</div>
</div>
</Link>
);
}
File diff suppressed because it is too large Load Diff
+15 -79
View File
@@ -1,9 +1,9 @@
"use client";
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useState, type ReactNode } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowLeft, ArrowRight, Loader2, RefreshCw, UserPlus } from "lucide-react";
import { ArrowLeft, ArrowRight, Loader2, UserPlus } from "lucide-react";
import { useAuth } from "@/lib/auth-store";
import { http, toUserError } from "@/lib/api";
import type { User } from "@/lib/types";
@@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/ui/password-input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { CaptchaField, type CaptchaChallenge } from "@/components/captcha-field";
import {
validateEmail,
validateName,
@@ -30,11 +31,6 @@ const STEPS = [
{ id: 3, title: "گذرواژه", hint: "رمز و کد امنیتی" },
] as const;
type CaptchaChallenge = {
challengeId: string;
imageBase64: string;
};
type RegisterFormProps = {
endpoint: string;
submitLabel: string;
@@ -73,30 +69,10 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
const [securityAnswer, setSecurityAnswer] = useState("");
const [captchaAnswer, setCaptchaAnswer] = useState("");
const [challenge, setChallenge] = useState<CaptchaChallenge | null>(null);
const [captchaLoading, setCaptchaLoading] = useState(false);
const [captchaKey, setCaptchaKey] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadCaptcha = useCallback(async () => {
setCaptchaLoading(true);
setCaptchaAnswer("");
try {
const data = await http.get<CaptchaChallenge>("/api/auth/captcha");
setChallenge(data);
} catch (err) {
setChallenge(null);
setError(toUserError(err, "بارگذاری کد امنیتی ممکن نشد"));
} finally {
setCaptchaLoading(false);
}
}, []);
useEffect(() => {
if (step === 3) {
void loadCaptcha();
}
}, [step, loadCaptcha]);
const goNext = () => {
setError(null);
if (step === 1) {
@@ -152,7 +128,7 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
}
if (!challenge) {
setError("ابتدا کد امنیتی را بارگذاری کنید");
void loadCaptcha();
setCaptchaKey((k) => k + 1);
return;
}
setLoading(true);
@@ -176,7 +152,7 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
router.push("/cap");
} catch (err) {
setError(toUserError(err, "ثبت‌نام ناموفق بود"));
void loadCaptcha();
setCaptchaKey((k) => k + 1);
} finally {
setLoading(false);
}
@@ -322,54 +298,14 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
placeholder="همان رمز را دوباره وارد کنید"
/>
</Field>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor="reg-captcha">کد امنیتی</Label>
<button
type="button"
onClick={() => void loadCaptcha()}
disabled={captchaLoading}
className="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-4 hover:underline disabled:opacity-50"
>
<RefreshCw className={`h-3.5 w-3.5 ${captchaLoading ? "animate-spin" : ""}`} />
تازه‌سازی
</button>
</div>
<div className="flex items-stretch gap-3">
<div
className="relative flex h-14 min-w-[9.5rem] shrink-0 items-center justify-center overflow-hidden rounded-xl border border-border bg-muted/60 shadow-inner"
aria-live="polite"
>
{captchaLoading || !challenge?.imageBase64 ? (
<span className="text-sm text-muted-foreground">…</span>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`data:image/png;base64,${challenge.imageBase64}`}
alt="کد امنیتی"
width={160}
height={52}
className="h-full w-full object-contain select-none"
draggable={false}
/>
)}
</div>
<Input
id="reg-captcha"
type="text"
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
value={captchaAnswer}
onChange={(e) => setCaptchaAnswer(e.target.value.toUpperCase())}
required
dir="ltr"
className="h-14 text-center font-mono tracking-widest uppercase placeholder:text-center"
placeholder="کد تصویر"
maxLength={6}
/>
</div>
</div>
<CaptchaField
key={captchaKey}
id="reg-captcha"
value={captchaAnswer}
onChange={setCaptchaAnswer}
onChallenge={setChallenge}
onError={setError}
/>
</>
)}
@@ -400,7 +336,7 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
variant="gradient"
className="h-12 min-w-0 flex-1"
size="lg"
disabled={loading || captchaLoading || !challenge}
disabled={loading || !challenge}
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <UserPlus className="h-4 w-4" />}
{submitLabel}
+46 -6
View File
@@ -1,8 +1,9 @@
"use client";
import { useEffect, useRef, useState } from "react";
import BoringAvatar from "boring-avatars";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import { mediaObjectKey } from "@/lib/media";
const AVATAR_COLORS = ["#7d6df2", "#0f9b8e", "#16a34a", "#d97706", "#9333ea", "#0891b2", "#65a30d", "#f59e0b"];
export { AVATAR_COLORS };
@@ -41,6 +42,40 @@ export function avatarPropsOf(u: AvatarOwner): { src: string | null; variant: Av
};
}
function useSettledPhoto(src?: string | null): string | null {
const [painted, setPainted] = useState<string | null>(null);
const paintedRef = useRef<string | null>(null);
useEffect(() => {
if (!src) {
paintedRef.current = null;
setPainted(null);
return;
}
if (src === paintedRef.current) return;
let cancel = false;
const img = new Image();
img.onload = () => {
if (cancel) return;
paintedRef.current = src;
setPainted(src);
};
img.onerror = () => {
if (cancel) return;
if (mediaObjectKey(src) !== mediaObjectKey(paintedRef.current)) {
paintedRef.current = null;
setPainted(null);
}
};
img.src = src;
return () => {
cancel = true;
};
}, [src]);
return painted;
}
export function UserAvatar({
name,
src,
@@ -54,12 +89,17 @@ export function UserAvatar({
palette?: string[];
className?: string;
}) {
if (src) {
const painted = useSettledPhoto(src);
if (painted) {
return (
<Avatar className={cn("ring-1 ring-black/5 dark:ring-white/10", className)}>
<AvatarImage src={src} alt={name} />
<AvatarFallback />
</Avatar>
<span
className={cn(
"relative inline-grid shrink-0 place-items-center overflow-hidden rounded-full bg-background ring-1 ring-black/5 dark:ring-white/10",
className
)}
>
<img src={painted} alt={name} className="h-full w-full object-cover" draggable={false} />
</span>
);
}
return (