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:
@@ -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: "جلسات و کارگاهها" },
|
||||
|
||||
@@ -24,6 +24,7 @@ const PROTECTED_PREFIXES = [
|
||||
"/quiz",
|
||||
"/notifications",
|
||||
"/lockout",
|
||||
"/profile",
|
||||
];
|
||||
|
||||
function needsAuth(p: string) {
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user