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,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CheckCircle2, Loader2, RefreshCw, ShieldCheck, Timer, Zap } from "lucide-react";
|
||||
import { CheckCircle2, Loader2, ShieldCheck, Timer, Zap } from "lucide-react";
|
||||
import { BrandMark } from "@/components/brand-mark";
|
||||
import { useAuth } from "@/lib/auth-store";
|
||||
import { http, toUserError } from "@/lib/api";
|
||||
@@ -15,6 +15,7 @@ import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { CaptchaField, type CaptchaChallenge } from "@/components/captcha-field";
|
||||
|
||||
const perks = [
|
||||
{ icon: Timer, text: "زمان تماشای راستیآزماییشده — بدون میانبر" },
|
||||
@@ -22,11 +23,6 @@ const perks = [
|
||||
{ icon: CheckCircle2, text: "لیگ هفتگی، رویداد زنده و منتور یکبهیک" },
|
||||
];
|
||||
|
||||
type CaptchaChallenge = {
|
||||
challengeId: string;
|
||||
imageBase64: string;
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const setSession = useAuth((s) => s.setSession);
|
||||
@@ -34,7 +30,7 @@ export default function LoginPage() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [captchaAnswer, setCaptchaAnswer] = useState("");
|
||||
const [challenge, setChallenge] = useState<CaptchaChallenge | null>(null);
|
||||
const [captchaLoading, setCaptchaLoading] = useState(true);
|
||||
const [captchaKey, setCaptchaKey] = useState(0);
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -44,29 +40,11 @@ export default function LoginPage() {
|
||||
queryFn: () => http.get<{ enabled: boolean }>("/api/auth/register-status"),
|
||||
});
|
||||
|
||||
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(() => {
|
||||
void loadCaptcha();
|
||||
}, [loadCaptcha]);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!challenge) {
|
||||
setError("ابتدا کد امنیتی را بارگذاری کنید");
|
||||
void loadCaptcha();
|
||||
setCaptchaKey((k) => k + 1);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
@@ -83,7 +61,7 @@ export default function LoginPage() {
|
||||
router.push("/cap");
|
||||
} catch (err) {
|
||||
setError(toUserError(err, "ورود ناموفق بود"));
|
||||
void loadCaptcha();
|
||||
setCaptchaKey((k) => k + 1);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -153,54 +131,14 @@ export default function LoginPage() {
|
||||
placeholder="رمز عبور"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label htmlFor="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="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="captcha"
|
||||
value={captchaAnswer}
|
||||
onChange={setCaptchaAnswer}
|
||||
onChallenge={setChallenge}
|
||||
onError={setError}
|
||||
/>
|
||||
<label className="flex items-center justify-between gap-3 text-sm">
|
||||
<span>مرا به یاد داشته باش</span>
|
||||
<Switch
|
||||
@@ -215,7 +153,7 @@ export default function LoginPage() {
|
||||
variant="gradient"
|
||||
className="h-12 w-full"
|
||||
size="lg"
|
||||
disabled={loading || captchaLoading || !challenge}
|
||||
disabled={loading || !challenge}
|
||||
>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Zap className="h-4 w-4" />} ورود
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Flame, Heart, Sparkles, User as UserIcon } from "lucide-react";
|
||||
import { http, toUserError } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth-store";
|
||||
import type { PublicProfile } from "@/lib/types";
|
||||
import { Panel } from "@/components/panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { UserAvatar, avatarPropsOf } from "@/components/ui/user-avatar";
|
||||
import { formatFaNumber, formatStreakLabel } from "@/lib/utils";
|
||||
|
||||
const AdminProfileManage = dynamic(
|
||||
() => import("@/components/admin/profile-manage").then((m) => m.AdminProfileManage),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
function roleLabel(role: string) {
|
||||
if (role === "mentor") return "منتور";
|
||||
if (role === "admin") return "ادمین";
|
||||
return "هنرجو";
|
||||
}
|
||||
|
||||
export default function PublicProfilePage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const id = Number(params.id);
|
||||
const enabled = Number.isFinite(id) && id > 0;
|
||||
const isAdmin = useAuth((s) => s.user?.role === "admin");
|
||||
|
||||
const profileQ = useQuery({
|
||||
queryKey: ["public-profile", id],
|
||||
queryFn: () => http.get<{ user: PublicProfile }>(`/api/users/${id}`),
|
||||
enabled,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl py-16 text-center text-sm text-muted-foreground">
|
||||
کاربر پیدا نشد.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (profileQ.isPending) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-4" aria-busy="true" aria-label="در حال بارگذاری پروفایل">
|
||||
<Skeleton className="h-40 rounded-2xl" />
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-20 rounded-2xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (profileQ.isError || !profileQ.data?.user) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-4 py-16 text-center">
|
||||
<p className="text-sm font-bold text-destructive">
|
||||
{toUserError(profileQ.error, "کاربر پیدا نشد")}
|
||||
</p>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/unwrap">بازگشت</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const person = profileQ.data.user;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<Panel
|
||||
title={person.name}
|
||||
description={roleLabel(person.role)}
|
||||
icon={<UserIcon className="h-5 w-5" />}
|
||||
tint="bg-primary/10 text-primary"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4 sm:flex-row sm:items-start">
|
||||
<UserAvatar name={person.name} className="h-20 w-20" {...avatarPropsOf(person)} />
|
||||
<div className="min-w-0 flex-1 space-y-2 text-center sm:text-start">
|
||||
<h1 className="text-2xl font-black tracking-tight">{person.name}</h1>
|
||||
<p className="font-latin text-sm font-bold" dir="ltr">
|
||||
@{person.username}
|
||||
</p>
|
||||
{person.isLocked && (
|
||||
<p className="text-xs font-bold text-destructive">حساب قفل است</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Stat icon={<Sparkles className="h-4 w-4 text-gold" />} label="امتیاز" value={formatFaNumber(person.xp)} />
|
||||
<Stat icon={<Heart className="h-4 w-4 text-destructive" />} label="قلبها" value={formatFaNumber(person.hearts)} />
|
||||
<Stat
|
||||
icon={<Flame className="h-4 w-4 text-accent" />}
|
||||
label="زنجیرهٔ فعلی"
|
||||
value={formatStreakLabel(person.streakCurrent)}
|
||||
/>
|
||||
<Stat
|
||||
icon={<Flame className="h-4 w-4 text-muted-foreground" />}
|
||||
label="بلندترین زنجیره"
|
||||
value={formatStreakLabel(person.streakLongest)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isAdmin && <AdminProfileManage userId={person.id} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-border bg-card px-4 py-3">
|
||||
{icon}
|
||||
<div>
|
||||
<p className="text-[11px] font-bold text-muted-foreground">{label}</p>
|
||||
<p className="text-lg font-black tabular-nums">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { mediaObjectKey } from "@/lib/media";
|
||||
import { loadCachedMedia } from "@/lib/transfer";
|
||||
|
||||
/** Local bytes for an image or similar object. A replacement has a new key and loads again. Video is not cached. */
|
||||
export function useCachedObjectUrl(url: string, cacheable = true): string {
|
||||
const key = mediaObjectKey(url);
|
||||
const [local, setLocal] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!cacheable || !url) {
|
||||
setLocal("");
|
||||
return;
|
||||
}
|
||||
let dead = false;
|
||||
let objectUrl = "";
|
||||
void loadCachedMedia(url)
|
||||
.then((blob) => {
|
||||
if (dead) return;
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setLocal(objectUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!dead) setLocal(url);
|
||||
});
|
||||
return () => {
|
||||
dead = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [cacheable, key, url]);
|
||||
|
||||
if (!cacheable) return url;
|
||||
return local;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Storage path of a signed /media URL, without the rotating signature. */
|
||||
export function mediaObjectKey(src: string | null | undefined): string {
|
||||
if (!src) return "";
|
||||
const cut = src.indexOf("?");
|
||||
const path = cut >= 0 ? src.slice(0, cut) : src;
|
||||
const i = path.indexOf("/media/");
|
||||
return i >= 0 ? path.slice(i) : path;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useAuth } from "./auth-store";
|
||||
import { ApiError, refreshSession, throwFromResponse } from "./api";
|
||||
import { mediaObjectKey } from "./media";
|
||||
|
||||
const mediaCacheName = "pargar-media-v1";
|
||||
|
||||
function apiBase(): string {
|
||||
const fromEnv = process.env.NEXT_PUBLIC_API_URL;
|
||||
if (fromEnv !== undefined && fromEnv !== "") return fromEnv.replace(/\/$/, "");
|
||||
return "";
|
||||
}
|
||||
|
||||
function requestId(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID().replace(/-/g, "").slice(0, 16);
|
||||
}
|
||||
return Math.random().toString(16).slice(2, 18);
|
||||
}
|
||||
|
||||
async function refreshOnce(): Promise<string | null> {
|
||||
await refreshSession();
|
||||
return useAuth.getState().accessToken;
|
||||
}
|
||||
|
||||
type XHRResult = { status: number; body: string; blob: Blob | null };
|
||||
|
||||
function xhrSend(
|
||||
method: "GET" | "POST",
|
||||
url: string,
|
||||
opts: {
|
||||
token: string | null;
|
||||
body?: FormData;
|
||||
responseType: "json" | "blob";
|
||||
onProgress?: (ratio: number) => void;
|
||||
},
|
||||
): Promise<XHRResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(method, url);
|
||||
xhr.withCredentials = true;
|
||||
xhr.setRequestHeader("X-Request-ID", requestId());
|
||||
if (opts.token) xhr.setRequestHeader("Authorization", `Bearer ${opts.token}`);
|
||||
xhr.responseType = opts.responseType === "blob" ? "blob" : "text";
|
||||
const progress = (e: ProgressEvent) => {
|
||||
if (!opts.onProgress) return;
|
||||
if (e.lengthComputable && e.total > 0) opts.onProgress(Math.min(1, e.loaded / e.total));
|
||||
};
|
||||
if (method === "POST") xhr.upload.onprogress = progress;
|
||||
else xhr.onprogress = progress;
|
||||
xhr.onload = () => {
|
||||
resolve({
|
||||
status: xhr.status,
|
||||
body: opts.responseType === "json" ? String(xhr.response ?? "") : "",
|
||||
blob: opts.responseType === "blob" ? (xhr.response as Blob) : null,
|
||||
});
|
||||
};
|
||||
xhr.onerror = () => reject(new ApiError("ارتباط قطع شد", 0));
|
||||
xhr.send(opts.body ?? null);
|
||||
});
|
||||
}
|
||||
|
||||
/** Multipart upload that reports how full the transfer is. */
|
||||
export async function apiFormProgress<T>(
|
||||
path: string,
|
||||
form: FormData,
|
||||
onProgress?: (ratio: number) => void,
|
||||
): Promise<T> {
|
||||
const url = `${apiBase()}${path}`;
|
||||
const send = (token: string | null) =>
|
||||
xhrSend("POST", url, { token, body: form, responseType: "json", onProgress });
|
||||
|
||||
let res = await send(useAuth.getState().accessToken);
|
||||
if (res.status === 401 && (useAuth.getState().refreshToken || useAuth.getState().user)) {
|
||||
res = await send(await refreshOnce());
|
||||
}
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
const fake = new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
|
||||
await throwFromResponse(fake, `Upload failed (${res.status})`);
|
||||
}
|
||||
onProgress?.(1);
|
||||
return JSON.parse(res.body) as T;
|
||||
}
|
||||
|
||||
/** Download into memory and report fill ratio. Does not open a tab. */
|
||||
export async function fetchBlob(
|
||||
url: string,
|
||||
onProgress?: (ratio: number) => void,
|
||||
): Promise<Blob> {
|
||||
const res = await xhrSend("GET", url, { token: null, responseType: "blob", onProgress });
|
||||
if (res.status < 200 || res.status >= 300 || !res.blob) {
|
||||
throw new ApiError("دانلود ممکن نشد", res.status);
|
||||
}
|
||||
onProgress?.(1);
|
||||
return res.blob;
|
||||
}
|
||||
|
||||
async function mediaCache(): Promise<Cache | null> {
|
||||
if (typeof caches === "undefined") return null;
|
||||
try {
|
||||
return await caches.open(mediaCacheName);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Bytes already saved for this object. A new photo, video, or voice is a new key. */
|
||||
export async function readCachedMedia(url: string): Promise<Blob | null> {
|
||||
try {
|
||||
const key = mediaObjectKey(url);
|
||||
if (!key) return null;
|
||||
const cache = await mediaCache();
|
||||
const hit = await cache?.match(key);
|
||||
if (!hit) return null;
|
||||
return hit.blob();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCachedMedia(url: string, blob: Blob): Promise<void> {
|
||||
try {
|
||||
const key = mediaObjectKey(url);
|
||||
if (!key) return;
|
||||
const cache = await mediaCache();
|
||||
if (!cache) return;
|
||||
await cache.put(
|
||||
key,
|
||||
new Response(blob, { headers: { "Content-Type": blob.type || "application/octet-stream" } }),
|
||||
);
|
||||
} catch {
|
||||
// A failed cache still leaves the file ready to save.
|
||||
}
|
||||
}
|
||||
|
||||
function triggerDownload(blob: Blob, filename: string) {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = objectUrl;
|
||||
a.download = filename || "file";
|
||||
a.rel = "noopener";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a remote file after it has finished downloading.
|
||||
* Image, audio, and other files are kept by object key, so a later save does not fetch again.
|
||||
* Video is streamed and is not kept this way.
|
||||
*/
|
||||
export async function saveRemoteFile(
|
||||
url: string,
|
||||
filename: string,
|
||||
onProgress?: (ratio: number) => void,
|
||||
opts?: { cache?: boolean },
|
||||
): Promise<void> {
|
||||
const cache = opts?.cache !== false;
|
||||
if (cache) {
|
||||
const hit = await readCachedMedia(url);
|
||||
if (hit) {
|
||||
onProgress?.(1);
|
||||
triggerDownload(hit, filename);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const blob = await fetchBlob(url, onProgress);
|
||||
if (cache) await writeCachedMedia(url, blob);
|
||||
triggerDownload(blob, filename);
|
||||
}
|
||||
|
||||
/** One network fetch per object. Later views reuse the saved bytes until the object is replaced. */
|
||||
export async function loadCachedMedia(url: string): Promise<Blob> {
|
||||
const hit = await readCachedMedia(url);
|
||||
if (hit) return hit;
|
||||
const blob = await fetchBlob(url);
|
||||
await writeCachedMedia(url, blob);
|
||||
return blob;
|
||||
}
|
||||
@@ -193,6 +193,7 @@ export interface ChatMessage {
|
||||
replyTo?: number;
|
||||
pinnedAt?: string;
|
||||
pinned?: boolean;
|
||||
editedAt?: string;
|
||||
attachment?: ChatAttachment;
|
||||
reactions?: ChatReaction[];
|
||||
}
|
||||
@@ -264,6 +265,21 @@ export interface MCQQuestion {
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
export interface PublicProfile {
|
||||
id: number;
|
||||
name: string;
|
||||
username: string;
|
||||
role: Role;
|
||||
xp: number;
|
||||
hearts: number;
|
||||
streakCurrent: number;
|
||||
streakLongest: number;
|
||||
avatarVariant?: User["avatarVariant"];
|
||||
avatarPalette?: string;
|
||||
avatarPhoto?: string;
|
||||
isLocked: boolean;
|
||||
}
|
||||
|
||||
export interface AdminStats {
|
||||
users: number;
|
||||
lessons: number;
|
||||
|
||||
Reference in New Issue
Block a user