feat: release v0.2.0 profiles, chat, freeze, and bots
Ship the product pass on the v0.1.3 schema with additive migrations, a replica migration lock, and bot accounts kept out of people lists.
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth-store";
|
||||
|
||||
export default function BotDocsPage() {
|
||||
const user = useAuth((s) => s.user);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (user && user.role !== "admin") {
|
||||
router.replace("/admin");
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
if (!user || user.role !== "admin") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
<header className="space-y-2">
|
||||
<p className="text-xs font-bold text-muted-foreground">
|
||||
<Link href="/admin?tab=bots" className="underline-offset-4 hover:underline">
|
||||
ربات
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="text-2xl font-black tracking-tight">راهنمای ربات</h1>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
با یک کلید API میتوانید از بیرون پرگار به هنرجو پیام بفرستید و جوابهای او را بخوانید.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="space-y-3 rounded-2xl border border-border bg-card p-5">
|
||||
<h2 className="text-base font-black">ساخت کلید</h2>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
در مدیریت، بخش ربات، یک نام بگذارید و «ساخت ربات» را بزنید. پاسخ ساخت شامل فیلد <Code>token</Code> است.
|
||||
توکن فقط یک بار نشان داده میشود. بعد از بستن یا بارگذاری دوبارهٔ صفحه دیگر برنمیگردد؛ سرور فقط هش آن را نگه میدارد.
|
||||
</p>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
هر ردیف نام، زمان ساخت، وضعیت و تعداد درخواستهای تأییدشده را نشان میدهد. با «توقف» کلید متوقف میشود و با «فعالکردن» دوباره کار میکند.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 rounded-2xl border border-border bg-card p-5">
|
||||
<h2 className="text-base font-black">احراز هویت</h2>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
هر دو مسیر زیر هدر <Code>X-Bot-Token</Code> میخواهند. توکن خالی، ناشناس یا متوقف با وضعیت ۴۰۱ و پیام «توکن ربات نامعتبر است» رد میشود.
|
||||
درخواست ناموفق در شمارنده حساب نمیشود. هر فراخوانی که توکن فعالش پذیرفته شود، یک واحد به شمار درخواست همان کلید اضافه میکند.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 rounded-2xl border border-border bg-card p-5">
|
||||
<h2 className="text-base font-black">ارسال پیام</h2>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
<Code>POST /api/bot/send</Code> پیامی از طرف ربات در گفتگوی هنرجو میسازد. بدنه JSON:
|
||||
</p>
|
||||
<ul className="list-disc space-y-1 pe-5 text-sm leading-relaxed text-muted-foreground">
|
||||
<li>
|
||||
<Code>userId</Code> شناسهٔ عددی هنرجو. لازم است.
|
||||
</li>
|
||||
<li>
|
||||
<Code>text</Code> متن پیام. لازم است و نباید خالی باشد.
|
||||
</li>
|
||||
<li>
|
||||
<Code>buttons</Code> آرایهٔ دکمهها. هر عضو شیئی با فیلد <Code>text</Code> است. در گفتگو، زدن دکمه همان متن را به عنوان پیام هنرجو برای ربات میفرستد.
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
پاسخ موفق: <Code>{`{ "messageId": 123 }`}</Code>. اگر <Code>userId</Code> یا <Code>text</Code> نباشد، وضعیت ۴۰۰ و پیام «userId و text لازم است».
|
||||
</p>
|
||||
<Example
|
||||
title="نمونه"
|
||||
code={`curl -X POST /api/bot/send \\
|
||||
-H 'Content-Type: application/json' \\
|
||||
-H 'X-Bot-Token: YOUR_TOKEN' \\
|
||||
-d '{"userId":12,"text":"سلام","buttons":[{"text":"باشه"}]}'`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 rounded-2xl border border-border bg-card p-5">
|
||||
<h2 className="text-base font-black">خواندن پاسخها</h2>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
<Code>GET /api/bot/updates</Code> صف پاسخهایی را برمیگرداند که هنرجو برای این ربات فرستاده است؛ چه متن آزاد، چه متن یک دکمه.
|
||||
پارامتر <Code>offset</Code> آخرین شناسهای است که قبلاً دیدهاید. فقط ردیفهای با <Code>id</Code> بزرگتر برمیگردند، حداکثر ۵۰ تا. اگر نفرستید، از صفر شروع میشود.
|
||||
</p>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
پاسخ: <Code>{`{ "updates": [ { "id": 1, "payload": { "from": 12, "name": "نام", "text": "جواب" }, "createdAt": "..." } ] }`}</Code>.
|
||||
<Code>from</Code> شناسهٔ فرستنده است، <Code>name</Code> نام او و <Code>text</Code> متن پیام. اگر چیزی نباشد، <Code>updates</Code> آرایهٔ خالی است.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Code({ children }: { children: string }) {
|
||||
return (
|
||||
<code dir="ltr" className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[0.8em]">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
function Example({ title, code }: { title: string; code: string }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-bold text-muted-foreground">{title}</p>
|
||||
<pre dir="ltr" className="overflow-x-auto rounded-2xl border border-border bg-muted/40 p-3 text-left font-mono text-xs leading-relaxed">
|
||||
{code}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Loader2, Package } from "lucide-react";
|
||||
import { CertificateCard } from "@/components/certificate-card";
|
||||
import { NotFoundState, notFoundSentence } from "@/components/not-found-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { http, toUserError } from "@/lib/api";
|
||||
@@ -96,12 +96,16 @@ export default function CertificatePage() {
|
||||
|
||||
if (error || !data?.certificate) {
|
||||
return (
|
||||
<div className="mx-auto max-w-lg px-4 py-16 text-center">
|
||||
<p className="text-lg font-bold">گواهینامه پیدا نشد</p>
|
||||
<Button asChild className="mt-4" variant="outline">
|
||||
<Link href="/">بازگشت</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<NotFoundState
|
||||
title="گواهینامه پیدا نشد"
|
||||
description={notFoundSentence(
|
||||
error ? toUserError(error, "") : "",
|
||||
"این گواهینامه در سامانه نیست.",
|
||||
"گواهینامه پیدا نشد",
|
||||
)}
|
||||
href="/"
|
||||
actionLabel="بازگشت به خانه"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Suspense, useEffect, useRef } from "react";
|
||||
import { Suspense, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Award,
|
||||
ArrowLeft,
|
||||
@@ -28,6 +28,7 @@ import { useAuth } from "@/lib/auth-store";
|
||||
import { cn, formatFaNumber, formatStreakLabel } from "@/lib/utils";
|
||||
import { clampHearts, hasInfiniteHearts, INFINITE_HEARTS_LABEL, MAX_HEARTS } from "@/lib/hearts";
|
||||
import { toast } from "@/components/providers";
|
||||
import { SafeMarkdown } from "@/components/safe-markdown";
|
||||
|
||||
export default function CapPage() {
|
||||
return (
|
||||
@@ -150,6 +151,8 @@ function CapPageInner() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PinnedAnnouncement />
|
||||
|
||||
{lowHearts && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border-2 border-destructive/30 bg-destructive/5 px-5 py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -157,7 +160,7 @@ function CapPageInner() {
|
||||
<div>
|
||||
<p className="text-sm font-extrabold text-destructive">فقط {formatFaNumber(hearts)} جان مانده</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
یک اشتباه دیگر حساب را قفل میکند. قبل از آزمون مطمئن شوید؛ اگر گیر کردید با منتور حرف بزنید.
|
||||
جانها خودشان برمیگردند. تا برگشتن یک جان، آزمون تازه صبر میکند.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -450,6 +453,244 @@ function CapSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
type CapAnnouncement = { title: string; body: string; route: string };
|
||||
|
||||
function PinnedAnnouncement() {
|
||||
const router = useRouter();
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
const gesture = useRef<{
|
||||
x: number;
|
||||
y: number;
|
||||
scroll: number;
|
||||
index: number;
|
||||
pointerId: number;
|
||||
axis: "x" | "y" | null;
|
||||
dx: number;
|
||||
} | null>(null);
|
||||
const moved = useRef(0);
|
||||
const [index, setIndex] = useState(0);
|
||||
const q = useQuery({
|
||||
queryKey: ["announcement-pinned"],
|
||||
queryFn: () =>
|
||||
http.get<{ announcement: CapAnnouncement | null; announcements?: CapAnnouncement[] }>("/api/announcements/pinned"),
|
||||
});
|
||||
const items = q.data?.announcements?.length
|
||||
? q.data.announcements
|
||||
: q.data?.announcement
|
||||
? [q.data.announcement]
|
||||
: [];
|
||||
const count = items.length;
|
||||
const indexRef = useRef(0);
|
||||
const hoverRef = useRef(false);
|
||||
const downRef = useRef(false);
|
||||
const programmatic = useRef(0);
|
||||
const scrollSeq = useRef(0);
|
||||
const wantScroll = useRef(false);
|
||||
const scrollBehavior = useRef<ScrollBehavior>("smooth");
|
||||
const [epoch, setEpoch] = useState(0);
|
||||
|
||||
const stride = (el: HTMLDivElement) => {
|
||||
const card = el.firstElementChild as HTMLElement | null;
|
||||
const gap = 12;
|
||||
return (card?.offsetWidth || el.clientWidth || 1) + gap;
|
||||
};
|
||||
|
||||
const goTo = (next: number, behavior: ScrollBehavior = "smooth") => {
|
||||
if (count < 2) return;
|
||||
const i = ((next % count) + count) % count;
|
||||
const from = indexRef.current;
|
||||
const neighbor = Math.abs(i - from) === 1;
|
||||
scrollBehavior.current = neighbor && behavior === "smooth" ? "smooth" : "auto";
|
||||
wantScroll.current = true;
|
||||
indexRef.current = i;
|
||||
setIndex(i);
|
||||
setEpoch((n) => n + 1);
|
||||
};
|
||||
|
||||
const stepBy = (delta: number) => {
|
||||
const from = indexRef.current;
|
||||
const dest = from + delta < 0 ? count - 1 : from + delta >= count ? 0 : from + delta;
|
||||
goTo(dest, Math.abs(dest - from) === 1 ? "smooth" : "auto");
|
||||
};
|
||||
|
||||
const stepRef = useRef(stepBy);
|
||||
stepRef.current = stepBy;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = scroller.current;
|
||||
if (!el || count < 2 || !wantScroll.current) return;
|
||||
wantScroll.current = false;
|
||||
const token = scrollSeq.current + 1;
|
||||
scrollSeq.current = token;
|
||||
programmatic.current = token;
|
||||
const left = indexRef.current * stride(el);
|
||||
let settledOnce = false;
|
||||
const finish = (force: boolean) => {
|
||||
if (settledOnce || scrollSeq.current !== token) return;
|
||||
if (gesture.current?.axis === "x") return;
|
||||
const target = indexRef.current * stride(el);
|
||||
const settled = Math.abs(el.scrollLeft - target) <= 2;
|
||||
if (!settled && !force) return;
|
||||
if (!settled) {
|
||||
el.style.scrollBehavior = "auto";
|
||||
el.scrollLeft = target;
|
||||
}
|
||||
settledOnce = true;
|
||||
if (programmatic.current === token) programmatic.current = 0;
|
||||
el.removeEventListener("scrollend", onEnd);
|
||||
};
|
||||
const onEnd = () => {
|
||||
if (scrollSeq.current !== token || !programmatic.current) return;
|
||||
finish(false);
|
||||
};
|
||||
el.addEventListener("scrollend", onEnd);
|
||||
el.style.scrollBehavior = scrollBehavior.current === "smooth" ? "smooth" : "auto";
|
||||
el.scrollTo({ left, behavior: scrollBehavior.current });
|
||||
const soon = window.setTimeout(() => finish(false), scrollBehavior.current === "auto" ? 40 : 700);
|
||||
const later = window.setTimeout(() => finish(true), 1200);
|
||||
return () => {
|
||||
window.clearTimeout(soon);
|
||||
window.clearTimeout(later);
|
||||
el.removeEventListener("scrollend", onEnd);
|
||||
};
|
||||
}, [index, epoch, count]);
|
||||
|
||||
useEffect(() => {
|
||||
if (count < 2) return;
|
||||
const id = window.setInterval(() => {
|
||||
if (hoverRef.current || downRef.current) return;
|
||||
stepRef.current(1);
|
||||
}, 6000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [count, epoch]);
|
||||
|
||||
if (count === 0) return null;
|
||||
|
||||
const hold = (hover: boolean | null, down: boolean | null) => {
|
||||
if (hover != null) hoverRef.current = hover;
|
||||
if (down != null) downRef.current = down;
|
||||
};
|
||||
|
||||
const go = (next: number) => goTo(next);
|
||||
|
||||
const onTrackPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
const el = scroller.current;
|
||||
if (!el) return;
|
||||
gesture.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
scroll: el.scrollLeft,
|
||||
index: indexRef.current,
|
||||
pointerId: e.pointerId,
|
||||
axis: null,
|
||||
dx: 0,
|
||||
};
|
||||
moved.current = 0;
|
||||
};
|
||||
|
||||
const onTrackPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const g = gesture.current;
|
||||
if (!g || g.pointerId !== e.pointerId) return;
|
||||
const dx = e.clientX - g.x;
|
||||
const dy = e.clientY - g.y;
|
||||
g.dx = dx;
|
||||
if (g.axis == null) {
|
||||
if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return;
|
||||
g.axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y";
|
||||
}
|
||||
if (g.axis !== "x" || count < 2) return;
|
||||
const el = scroller.current;
|
||||
if (!el) return;
|
||||
if (!el.hasPointerCapture(e.pointerId)) el.setPointerCapture(e.pointerId);
|
||||
const width = stride(el);
|
||||
const max = width * (count - 1);
|
||||
const pulled = g.scroll - dx;
|
||||
const limited = Math.min(g.scroll + width, Math.max(g.scroll - width, pulled));
|
||||
el.style.scrollBehavior = "auto";
|
||||
el.scrollLeft = Math.min(max, Math.max(0, limited));
|
||||
};
|
||||
|
||||
const onTrackPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const g = gesture.current;
|
||||
if (!g || g.pointerId !== e.pointerId) return;
|
||||
moved.current = Math.max(Math.abs(g.dx), Math.abs(e.clientY - g.y));
|
||||
const el = scroller.current;
|
||||
if (el?.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
|
||||
if (g.axis !== "x" || count < 2) {
|
||||
gesture.current = null;
|
||||
if (el) el.style.scrollBehavior = "";
|
||||
return;
|
||||
}
|
||||
const threshold = 40;
|
||||
let next = g.index;
|
||||
if (g.dx <= -threshold) next = g.index + 1;
|
||||
else if (g.dx >= threshold) next = g.index - 1;
|
||||
go(next);
|
||||
gesture.current = null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
onPointerEnter={() => hold(true, null)}
|
||||
onPointerLeave={() => {
|
||||
if (gesture.current?.axis === "x") {
|
||||
hold(false, null);
|
||||
return;
|
||||
}
|
||||
hold(false, false);
|
||||
setEpoch((n) => n + 1);
|
||||
}}
|
||||
onPointerDown={() => hold(null, true)}
|
||||
onPointerUp={() => hold(null, false)}
|
||||
onPointerCancel={() => hold(null, false)}
|
||||
>
|
||||
<div
|
||||
ref={scroller}
|
||||
dir="ltr"
|
||||
style={{ direction: "ltr" }}
|
||||
className="flex gap-3 touch-pan-y select-none overflow-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
onPointerDown={onTrackPointerDown}
|
||||
onPointerMove={onTrackPointerMove}
|
||||
onPointerUp={onTrackPointerUp}
|
||||
onPointerCancel={onTrackPointerUp}
|
||||
>
|
||||
{items.map((a, i) => (
|
||||
<button
|
||||
key={`${a.title}-${i}`}
|
||||
type="button"
|
||||
dir="rtl"
|
||||
draggable={false}
|
||||
onClick={() => {
|
||||
if (Math.abs(moved.current) > 12) return;
|
||||
if (a.route) router.push(a.route);
|
||||
}}
|
||||
className="block w-[calc(100%-2rem)] min-w-[calc(100%-2rem)] max-w-[calc(100%-2rem)] shrink-0 touch-pan-y rounded-2xl border-2 border-primary/30 bg-primary/5 px-5 py-4 text-start"
|
||||
>
|
||||
<p className="text-xs font-bold text-primary">اطلاعیه</p>
|
||||
<p className="mt-1 text-base font-black">{a.title}</p>
|
||||
<SafeMarkdown text={a.body} lines={3} className="mt-1 text-sm text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{items.length > 1 && (
|
||||
<div className="mt-2 flex items-center justify-center gap-1.5">
|
||||
{items.map((a, i) => (
|
||||
<button
|
||||
key={`${a.title}-dot-${i}`}
|
||||
type="button"
|
||||
aria-label={`اطلاعیه ${i + 1}`}
|
||||
onClick={() => go(i)}
|
||||
className={cn("h-1.5 rounded-full transition-all", i === index ? "w-4 bg-primary" : "w-1.5 bg-primary/30")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ZapIcon(props: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
|
||||
|
||||
@@ -254,6 +254,31 @@
|
||||
border-radius: 999px;
|
||||
border-width: 0;
|
||||
}
|
||||
textarea.chat-composer {
|
||||
unicode-bidi: plaintext;
|
||||
text-align: start;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
field-sizing: content;
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
textarea.chat-composer:placeholder-shown {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
textarea.chat-composer::placeholder {
|
||||
unicode-bidi: plaintext;
|
||||
text-align: start;
|
||||
}
|
||||
.chat-bubble-text {
|
||||
unicode-bidi: plaintext;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
max-width: 100%;
|
||||
text-align: start;
|
||||
}
|
||||
.section-kicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -89,8 +89,7 @@ export default function LockoutPage() {
|
||||
<p className="text-xs font-bold tracking-wider text-destructive">حساب قفل شد</p>
|
||||
<h1 className="mt-2 text-3xl font-bold tracking-tight">جانهایتان تمام شد</h1>
|
||||
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
|
||||
هر پاسخ اشتباه در آزمون یک جان کم میکند. با تمام شدن سه جان، یادگیری و آزمون قفل میشود تا
|
||||
تیم مسیر را بررسی کند — خودتان نمیتوانید قفل را باز کنید.
|
||||
جانها معمولاً خودشان برمیگردند. این صفحه وقتی دیده میشود که مدیریت حساب را قفل کرده باشد.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex items-center gap-2">
|
||||
@@ -144,7 +143,7 @@ export default function LockoutPage() {
|
||||
className={cn("w-full text-muted-foreground")}
|
||||
onClick={() => router.push("/unwrap?tab=community")}
|
||||
>
|
||||
جامعه و حمایت
|
||||
شبکههای پرگار
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
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";
|
||||
import { ApiError, http, toUserError } from "@/lib/api";
|
||||
import type { User } from "@/lib/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -34,6 +34,7 @@ export default function LoginPage() {
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [captchaError, setCaptchaError] = useState<string | null>(null);
|
||||
|
||||
const { data: registerStatus } = useQuery({
|
||||
queryKey: ["auth", "register-status"],
|
||||
@@ -43,7 +44,7 @@ export default function LoginPage() {
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!challenge) {
|
||||
setError("ابتدا کد امنیتی را بارگذاری کنید");
|
||||
setCaptchaError("ابتدا کد امنیتی را بارگذاری کنید");
|
||||
setCaptchaKey((k) => k + 1);
|
||||
return;
|
||||
}
|
||||
@@ -61,7 +62,10 @@ export default function LoginPage() {
|
||||
router.push("/cap");
|
||||
} catch (err) {
|
||||
setError(toUserError(err, "ورود ناموفق بود"));
|
||||
setCaptchaKey((k) => k + 1);
|
||||
// 429 is returned before the captcha is consumed, so keep this challenge.
|
||||
if (!(err instanceof ApiError) || err.status !== 429) {
|
||||
setCaptchaKey((k) => k + 1);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -137,7 +141,7 @@ export default function LoginPage() {
|
||||
value={captchaAnswer}
|
||||
onChange={setCaptchaAnswer}
|
||||
onChallenge={setChallenge}
|
||||
onError={setError}
|
||||
onError={setCaptchaError}
|
||||
/>
|
||||
<label className="flex items-center justify-between gap-3 text-sm">
|
||||
<span>مرا به یاد داشته باش</span>
|
||||
@@ -147,7 +151,11 @@ export default function LoginPage() {
|
||||
aria-label="مرا به یاد داشته باش"
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
{(error || captchaError) && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{error || captchaError}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="gradient"
|
||||
|
||||
@@ -1,58 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { BrandMark } from "@/components/brand-mark";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth, useAuthHydrated } from "@/lib/auth-store";
|
||||
import { Home, LogIn, Trees } from "lucide-react";
|
||||
import { NotFoundState } from "@/components/not-found-state";
|
||||
|
||||
export default function NotFound() {
|
||||
const hydrated = useAuthHydrated();
|
||||
const user = useAuth((s) => s.user);
|
||||
const loggedIn = hydrated && !!user;
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-lg flex-col items-center px-4 py-16 text-center">
|
||||
<div className="relative w-full overflow-hidden rounded-2xl border-2 border-border bg-card px-8 py-14 shadow-offset">
|
||||
<div className="dot-grid dot-grid-primary pointer-events-none absolute -end-10 -top-10 h-40 w-40 opacity-60" aria-hidden />
|
||||
<p className="text-7xl font-black tabular-nums text-primary/25">۴۰۴</p>
|
||||
<div className="mt-4 flex justify-center">
|
||||
<BrandMark locale="fa" size="lg" />
|
||||
</div>
|
||||
<h1 className="mt-6 text-2xl font-black tracking-tight">این مسیر پیدا نشد</h1>
|
||||
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
|
||||
{loggedIn
|
||||
? "این آدرس در سامانه نیست. از درخت مهارت یا مرکز ادامه دهید."
|
||||
: "صفحهای که دنبالش بودید پیدا نشد. میتوانید به خانه برگردید یا وارد حساب شوید."}
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
{loggedIn ? (
|
||||
<>
|
||||
<Button asChild className="gap-2">
|
||||
<Link href="/cap">
|
||||
<Trees className="h-4 w-4" /> درخت مهارت
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="gap-2">
|
||||
<Link href="/unwrap?tab=community">مرکز · حمایت</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button asChild className="gap-2">
|
||||
<Link href="/">
|
||||
<Home className="h-4 w-4" /> خانه
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="gap-2">
|
||||
<Link href="/login">
|
||||
<LogIn className="h-4 w-4" /> ورود
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NotFoundState
|
||||
title="صفحه پیدا نشد"
|
||||
description="این آدرس در سامانه نیست. از خانه میتوانید دوباره شروع کنید."
|
||||
href="/"
|
||||
actionLabel="بازگشت به خانه"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowRight, Lock, TreePine } from "lucide-react";
|
||||
import { VideoPlayer } from "@/components/video-player";
|
||||
import { NotFoundState, notFoundSentence } from "@/components/not-found-state";
|
||||
import { ApiError, http, toUserError } from "@/lib/api";
|
||||
import type { Lesson, LessonProgress } from "@/lib/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -24,6 +25,7 @@ function isLessonLockedError(error: unknown): boolean {
|
||||
export default function PlayerPage() {
|
||||
const { lessonId } = useParams<{ lessonId: string }>();
|
||||
const id = Number(lessonId);
|
||||
const idOk = Number.isFinite(id) && id > 0;
|
||||
const router = useRouter();
|
||||
const user = useAuth((s) => s.user);
|
||||
|
||||
@@ -37,7 +39,7 @@ export default function PlayerPage() {
|
||||
progress: LessonProgress | null;
|
||||
quizStatus: string;
|
||||
}>(`/api/lessons/${id}`),
|
||||
enabled: !!user && (!user.isLocked || hasInfiniteHearts(user.role)),
|
||||
enabled: idOk && !!user && (!user.isLocked || hasInfiniteHearts(user.role)),
|
||||
staleTime: 5 * 60_000,
|
||||
retry: (count, err) => {
|
||||
if (isLessonLockedError(err)) return false;
|
||||
@@ -53,6 +55,17 @@ export default function PlayerPage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!idOk) {
|
||||
return (
|
||||
<NotFoundState
|
||||
title="درس پیدا نشد"
|
||||
description="این درس در درخت مهارت نیست."
|
||||
href="/cap"
|
||||
actionLabel="بازگشت به درخت مهارت"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
@@ -79,6 +92,16 @@ export default function PlayerPage() {
|
||||
if (isLessonLockedError(error)) {
|
||||
return <LockedLessonState onBack={() => router.push("/cap")} />;
|
||||
}
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
return (
|
||||
<NotFoundState
|
||||
title="درس پیدا نشد"
|
||||
description={notFoundSentence(toUserError(error, ""), "این درس در درخت مهارت نیست.", "درس پیدا نشد")}
|
||||
href="/cap"
|
||||
actionLabel="بازگشت به درخت مهارت"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="mx-auto max-w-lg space-y-4 py-16 text-center">
|
||||
<p className="font-medium text-destructive">{toUserError(error, "بارگذاری درس ممکن نشد")}</p>
|
||||
|
||||
@@ -2,17 +2,15 @@
|
||||
|
||||
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 { Flame, Heart, Sparkles } from "lucide-react";
|
||||
import { ProfileHeader } from "@/components/profile-header";
|
||||
import { NotFoundState, notFoundSentence } from "@/components/not-found-state";
|
||||
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(
|
||||
@@ -20,12 +18,6 @@ const AdminProfileManage = dynamic(
|
||||
{ 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);
|
||||
@@ -41,9 +33,12 @@ export default function PublicProfilePage() {
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl py-16 text-center text-sm text-muted-foreground">
|
||||
کاربر پیدا نشد.
|
||||
</div>
|
||||
<NotFoundState
|
||||
title="کاربر پیدا نشد"
|
||||
description="این شناسه در سامانه نیست."
|
||||
href="/unwrap"
|
||||
actionLabel="بازگشت به مرکز"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,14 +57,16 @@ export default function PublicProfilePage() {
|
||||
|
||||
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>
|
||||
<NotFoundState
|
||||
title="کاربر پیدا نشد"
|
||||
description={notFoundSentence(
|
||||
toUserError(profileQ.error, ""),
|
||||
"این شناسه در سامانه نیست.",
|
||||
"کاربر پیدا نشد",
|
||||
)}
|
||||
href="/unwrap"
|
||||
actionLabel="بازگشت به مرکز"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,40 +74,55 @@ export default function PublicProfilePage() {
|
||||
|
||||
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>
|
||||
<ProfileHeader
|
||||
person={{
|
||||
name: person.name,
|
||||
role: person.role,
|
||||
banner: person.banner,
|
||||
createdAt: person.private ? undefined : person.createdAt,
|
||||
avatarVariant: person.avatarVariant,
|
||||
avatarPalette: person.avatarPalette,
|
||||
avatarPhoto: person.avatarPhoto,
|
||||
isFrozen: person.isFrozen,
|
||||
isClosed: person.isClosed,
|
||||
}}
|
||||
secondary={person.private ? "این پروفایل خصوصی است" : undefined}
|
||||
/>
|
||||
{person.isLocked && <p className="text-xs font-bold text-destructive">حساب قفل است</p>}
|
||||
|
||||
{person.private ? (
|
||||
<p className="text-sm text-muted-foreground">جز نام و آواتار چیزی نمایش داده نمیشود.</p>
|
||||
) : (
|
||||
<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)} />
|
||||
{person.xp != null && <Stat icon={<Sparkles className="h-4 w-4 text-gold" />} label="امتیاز" value={formatFaNumber(person.xp)} />}
|
||||
{person.hearts != null && <Stat icon={<Heart className="h-4 w-4 text-destructive" />} label="قلبها" value={formatFaNumber(person.hearts)} />}
|
||||
{person.streakCurrent != null && (
|
||||
<Stat
|
||||
icon={<Flame className="h-4 w-4 text-accent" />}
|
||||
label="زنجیرهٔ فعلی"
|
||||
value={formatStreakLabel(person.streakCurrent)}
|
||||
/>
|
||||
)}
|
||||
{person.streakLongest != null && (
|
||||
<Stat
|
||||
icon={<Flame className="h-4 w-4 text-muted-foreground" />}
|
||||
label="بلندترین زنجیره"
|
||||
value={formatStreakLabel(person.streakLongest)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(person.socialLinks ?? []).length > 0 && (
|
||||
<ul className="space-y-1 text-sm">
|
||||
{person.socialLinks!.map((l) => (
|
||||
<li key={l.url}>
|
||||
<a href={l.url} className="text-primary underline" target="_blank" rel="noreferrer">
|
||||
{l.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{isAdmin && <AdminProfileManage userId={person.id} />}
|
||||
</div>
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Flame, Heart, Sparkles, Trophy, User as UserIcon } from "lucide-react";
|
||||
import { Flame, Heart, Sparkles, Trophy } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "@/lib/auth-store";
|
||||
import { http, toUserError } from "@/lib/api";
|
||||
import type { LeaderboardData } from "@/lib/types";
|
||||
import { Panel } from "@/components/panel";
|
||||
import type { LeaderboardData, MeetingEvent } from "@/lib/types";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { UserAvatar, avatarPropsOf } from "@/components/ui/user-avatar";
|
||||
import { ProfileCertificateSlot } from "@/components/profile-certificate-slot";
|
||||
import { OwnerProfileHeader } from "@/components/profile-header";
|
||||
import { ChallengeCards } from "@/components/challenge-cards";
|
||||
import { AvatarStudio } from "@/components/avatar-studio";
|
||||
import {
|
||||
@@ -20,8 +20,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { formatFaNumber, formatStreakLabel } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
import { formatFaNumber, formatJalaliStamp, formatStreakLabel } from "@/lib/utils";
|
||||
|
||||
export default function ProfilePage() {
|
||||
const user = useAuth((s) => s.user);
|
||||
@@ -45,56 +44,7 @@ export default function ProfilePage() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<Panel
|
||||
title="پروفایل من"
|
||||
description="هویت، آمار یادگیری، چالشها و گواهینامه در یک نگاه."
|
||||
icon={<UserIcon className="h-5 w-5" />}
|
||||
tint="bg-primary/10 text-primary"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => setStudioOpen(true)}>
|
||||
ویرایش آواتار
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4 sm:flex-row sm:items-start">
|
||||
<UserAvatar name={user.name} className="h-20 w-20" {...avatarPropsOf(user)} />
|
||||
<div className="min-w-0 flex-1 space-y-2 text-center sm:text-start">
|
||||
<h1 className="text-2xl font-black tracking-tight">{user.name}</h1>
|
||||
<dl className="mt-1 space-y-2 text-sm">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<dt className="shrink-0 text-xs text-muted-foreground">نام کاربری</dt>
|
||||
<dd className="min-w-0 font-latin font-bold tracking-tight" dir="ltr">
|
||||
@{user.username}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<dt className="shrink-0 text-xs text-muted-foreground">ایمیل</dt>
|
||||
<dd className="min-w-0 break-all font-latin font-medium" dir="ltr">
|
||||
{user.email}
|
||||
</dd>
|
||||
</div>
|
||||
{user.phone && (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<dt className="shrink-0 text-xs text-muted-foreground">تلفن</dt>
|
||||
<dd className="min-w-0 font-latin font-medium" dir="ltr">
|
||||
{user.phone}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<dt className="shrink-0 text-xs text-muted-foreground">عضویت از</dt>
|
||||
<dd className="min-w-0 font-medium">
|
||||
{new Date(user.createdAt).toLocaleDateString("fa-IR", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
<OwnerProfileHeader user={user} onEditAvatar={() => setStudioOpen(true)} />
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<StatCard icon={<Sparkles className="h-4 w-4 text-gold" />} label="امتیاز کل" value={formatFaNumber(user.xp)} />
|
||||
@@ -139,14 +89,16 @@ export default function ProfilePage() {
|
||||
<ChallengeCards />
|
||||
</div>
|
||||
|
||||
<ProfileActivity />
|
||||
|
||||
<div>
|
||||
<h2 className="mb-2 text-sm font-black text-muted-foreground">گواهینامه</h2>
|
||||
<ProfileCertificateSlot />
|
||||
</div>
|
||||
|
||||
<Dialog open={studioOpen} onOpenChange={setStudioOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogContent className="flex max-h-[min(85vh,40rem)] max-w-lg flex-col gap-3 overflow-hidden">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle className="text-center">ویرایش آواتار</DialogTitle>
|
||||
</DialogHeader>
|
||||
<AvatarStudio user={user} onClose={() => setStudioOpen(false)} />
|
||||
@@ -156,6 +108,172 @@ export default function ProfilePage() {
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileActivity() {
|
||||
const activityQ = useQuery({
|
||||
queryKey: ["me-activity"],
|
||||
queryFn: () => http.get<{ days: { day: string; count: number }[] }>("/api/me/activity"),
|
||||
});
|
||||
const eventsQ = useQuery({
|
||||
queryKey: ["events", "profile"],
|
||||
queryFn: () => http.get<{ events: MeetingEvent[] }>("/api/events"),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-2 text-sm font-black text-muted-foreground">فعالیت</h2>
|
||||
<Card>
|
||||
<CardContent className="space-y-6 p-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-black">تاریخچهٔ ماهانه</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">هر بار یک ماه. پررنگتر یعنی فعالیت بیشتر.</p>
|
||||
{activityQ.isPending ? (
|
||||
<Skeleton className="mt-3 h-24 rounded-xl" aria-busy="true" aria-label="در حال بارگذاری نمودار فعالیت" />
|
||||
) : activityQ.isError ? (
|
||||
<p className="mt-3 text-sm font-bold text-destructive">{toUserError(activityQ.error, "بارگذاری فعالیت ممکن نشد")}</p>
|
||||
) : (
|
||||
<ActivityGrid days={activityQ.data?.days ?? []} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-black">رویدادها</h3>
|
||||
{eventsQ.isPending ? (
|
||||
<p className="mt-2 text-sm text-muted-foreground" aria-busy="true">
|
||||
در حال بارگذاری…
|
||||
</p>
|
||||
) : eventsQ.isError ? (
|
||||
<p className="mt-2 text-sm font-bold text-destructive">{toUserError(eventsQ.error, "بارگذاری رویدادها ممکن نشد")}</p>
|
||||
) : (eventsQ.data?.events ?? []).length === 0 ? (
|
||||
<p className="mt-2 text-sm text-muted-foreground">رویدادی نیست.</p>
|
||||
) : (
|
||||
<ul className="mt-3 divide-y divide-border rounded-xl border border-border">
|
||||
{(eventsQ.data?.events ?? []).map((ev) => (
|
||||
<li key={ev.id} className="flex items-baseline justify-between gap-3 px-3 py-2.5 text-sm">
|
||||
<span className="font-bold">{ev.title}</span>
|
||||
<time className="shrink-0 text-xs text-muted-foreground" dateTime={ev.startsAt}>
|
||||
{formatJalaliStamp(ev.startsAt)}
|
||||
</time>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function activityCountLabel(count: number) {
|
||||
if (count <= 0) return "بدون فعالیت";
|
||||
return `${formatFaNumber(count)} فعالیت`;
|
||||
}
|
||||
|
||||
const WEEKDAYS = ["ش", "ی", "د", "س", "چ", "پ", "ج"];
|
||||
|
||||
function ActivityGrid({ days }: { days: { day: string; count: number }[] }) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const map = new Map(days.map((d) => [d.day.slice(0, 10), d.count]));
|
||||
const order: string[] = [];
|
||||
const months = new Map<string, { label: string; cells: { day: string; count: number }[] }>();
|
||||
const today = new Date();
|
||||
today.setHours(12, 0, 0, 0);
|
||||
for (let i = 119; i >= 0; i--) {
|
||||
const d = new Date(today);
|
||||
d.setDate(today.getDate() - i);
|
||||
const key = d.toISOString().slice(0, 10);
|
||||
const id = jalaliMonthId(d);
|
||||
if (!months.has(id)) {
|
||||
order.push(id);
|
||||
months.set(id, { label: jalaliMonth(d), cells: [] });
|
||||
}
|
||||
months.get(id)!.cells.push({ day: key, count: map.get(key) ?? 0 });
|
||||
}
|
||||
const newestFirst = [...order].reverse();
|
||||
const safeIndex = Math.min(index, Math.max(newestFirst.length - 1, 0));
|
||||
const id = newestFirst[safeIndex];
|
||||
const month = id ? months.get(id) : undefined;
|
||||
if (!month || !id) return null;
|
||||
const pad = weekdayOffset(month.cells[0]?.day ?? "");
|
||||
|
||||
return (
|
||||
<div className="mt-3" role="group" aria-label="تاریخچهٔ ماهانهٔ فعالیت، به تاریخ جلالی">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<Button type="button" variant="outline" size="sm" disabled={safeIndex <= 0} onClick={() => setIndex(safeIndex - 1)}>
|
||||
ماه جدیدتر
|
||||
</Button>
|
||||
<h4 className="text-sm font-black">{month.label}</h4>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={safeIndex >= newestFirst.length - 1}
|
||||
onClick={() => setIndex(safeIndex + 1)}
|
||||
>
|
||||
ماه قدیمیتر
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1" dir="ltr">
|
||||
{WEEKDAYS.map((name) => (
|
||||
<span key={name} className="text-center text-[10px] font-bold text-muted-foreground" dir="rtl">
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
{Array.from({ length: pad }, (_, i) => (
|
||||
<span key={`${id}-pad-${i}`} />
|
||||
))}
|
||||
{month.cells.map((c) => {
|
||||
const date = jalaliDay(c.day);
|
||||
const countLabel = activityCountLabel(c.count);
|
||||
return (
|
||||
<button
|
||||
key={c.day}
|
||||
type="button"
|
||||
aria-label={`${date}، ${countLabel}`}
|
||||
className="group relative z-0 aspect-square min-h-0 w-full min-w-0 cursor-default appearance-none rounded-sm border-0 p-0 outline-none hover:z-30 focus-visible:z-30 focus-visible:ring-2 focus-visible:ring-ring"
|
||||
style={{ background: c.count === 0 ? "var(--muted)" : `color-mix(in srgb, var(--primary) ${30 + Math.min(c.count, 6) * 12}%, white)` }}
|
||||
>
|
||||
<span
|
||||
role="tooltip"
|
||||
dir="rtl"
|
||||
className="pointer-events-none absolute bottom-[calc(100%+0.35rem)] left-1/2 z-30 hidden w-max -translate-x-1/2 rounded-lg border border-border bg-card px-2.5 py-1.5 text-start text-sm leading-snug text-card-foreground shadow-soft group-hover:block group-focus-visible:block"
|
||||
>
|
||||
<span className="block font-bold whitespace-nowrap">{date}</span>
|
||||
<span className="block whitespace-nowrap text-muted-foreground">{countLabel}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function weekdayOffset(day: string) {
|
||||
if (!day) return 0;
|
||||
const d = new Date(day + "T12:00:00");
|
||||
return (d.getDay() + 1) % 7;
|
||||
}
|
||||
|
||||
function jalaliMonthId(d: Date) {
|
||||
const parts = new Intl.DateTimeFormat("en-u-ca-persian", { year: "numeric", month: "2-digit" }).formatToParts(d);
|
||||
const year = parts.find((p) => p.type === "year")?.value ?? "";
|
||||
const month = parts.find((p) => p.type === "month")?.value ?? "";
|
||||
return `${year}-${month}`;
|
||||
}
|
||||
|
||||
function jalaliMonth(d: Date) {
|
||||
const parts = new Intl.DateTimeFormat("fa-IR-u-ca-persian", { month: "long", year: "numeric" }).formatToParts(d);
|
||||
const month = parts.find((p) => p.type === "month")?.value ?? "";
|
||||
const year = parts.find((p) => p.type === "year")?.value ?? "";
|
||||
return `${month} ${year}`;
|
||||
}
|
||||
|
||||
function jalaliDay(day: string) {
|
||||
return new Intl.DateTimeFormat("fa-IR-u-ca-persian", { year: "numeric", month: "short", day: "numeric" }).format(
|
||||
new Date(day + "T12:00:00"),
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||||
return (
|
||||
<Card>
|
||||
|
||||
@@ -0,0 +1,874 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CalendarDays, Flame, Sparkles } from "lucide-react";
|
||||
import { AccountIdentityFields, AccountUsernameSection } from "@/components/account-identity-fields";
|
||||
import { SectionColumns, SectionNav, SectionPanel } from "@/components/section-nav";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ApiError, http, toUserError } from "@/lib/api";
|
||||
import { validateEmail, validateName, validatePassword, validatePhone, validateUsername } from "@/lib/validation";
|
||||
import { useAuth } from "@/lib/auth-store";
|
||||
import { clearMediaCache } from "@/lib/transfer";
|
||||
import { toast } from "@/components/providers";
|
||||
import type { Conversation } from "@/lib/types";
|
||||
import { formatFaNumber, formatJalaliStamp, formatStreakLabel } from "@/lib/utils";
|
||||
|
||||
type SocialLink = { name: string; url: string };
|
||||
|
||||
type Extras = {
|
||||
banner: string;
|
||||
public: boolean;
|
||||
showActivity: boolean;
|
||||
showStats: boolean;
|
||||
showPath: boolean;
|
||||
showBadges: boolean;
|
||||
socialLinks: SocialLink[];
|
||||
theme: string;
|
||||
};
|
||||
|
||||
type Activity = { days: { day: string; count: number }[]; weekDays: number; monthDays: number };
|
||||
type BadgeItem = { id: string; title: string; unlocked: boolean };
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: "account", label: "حساب", title: "حساب", description: "نام، ایمیل و تلفن را اینجا ذخیره میکنید. نام کاربری جدا عوض میشود. برای گذرواژه، گذرواژهٔ فعلی لازم است." },
|
||||
{ id: "privacy", label: "حریم", title: "حریم", description: "مشخص کنید دیگران در پروفایل شما چه چیزی ببینند." },
|
||||
{ id: "stats", label: "آمار", title: "آمار", description: "امتیاز، زنجیره و نشانهای همین حساب." },
|
||||
{ id: "export", label: "خروجی", title: "خروجی", description: "گفتگوها را دانلود کنید یا حافظه موقت را از همین دستگاه حذف کنید." },
|
||||
] as const;
|
||||
|
||||
const EXPORT_PAGE_SIZE = 8;
|
||||
|
||||
type SectionId = (typeof SECTIONS)[number]["id"];
|
||||
|
||||
export default function SettingsPage() {
|
||||
const user = useAuth((s) => s.user);
|
||||
const setUser = useAuth((s) => s.setUser);
|
||||
const profileQ = useQuery({
|
||||
queryKey: ["me-profile"],
|
||||
queryFn: () => http.get<{ profile: Extras }>("/api/me/profile"),
|
||||
enabled: !!user,
|
||||
});
|
||||
const activityQ = useQuery({
|
||||
queryKey: ["me-activity"],
|
||||
queryFn: () => http.get<Activity>("/api/me/activity"),
|
||||
enabled: !!user && (profileQ.data?.profile.showActivity ?? true),
|
||||
});
|
||||
const badgesQ = useQuery({
|
||||
queryKey: ["me-achievements"],
|
||||
queryFn: () => http.get<{ achievements: BadgeItem[] }>("/api/me/achievements"),
|
||||
enabled: !!user && (profileQ.data?.profile.showBadges ?? true),
|
||||
});
|
||||
const [section, setSection] = useState<SectionId>("account");
|
||||
const [exportingId, setExportingId] = useState<number | null>(null);
|
||||
const [exportPage, setExportPage] = useState(1);
|
||||
const exportChatsQ = useQuery({
|
||||
queryKey: ["settings-export-chats", user?.id],
|
||||
queryFn: () => http.get<{ conversations: Conversation[] }>("/api/chats/conversations"),
|
||||
enabled: !!user && section === "export",
|
||||
});
|
||||
const exportChats = exportChatsQ.data?.conversations ?? [];
|
||||
const exportPageCount = Math.max(1, Math.ceil(exportChats.length / EXPORT_PAGE_SIZE));
|
||||
const exportPageSafe = Math.min(Math.max(exportPage, 1), exportPageCount);
|
||||
const exportPageStart = (exportPageSafe - 1) * EXPORT_PAGE_SIZE;
|
||||
const exportPageChats = exportChats.slice(exportPageStart, exportPageStart + EXPORT_PAGE_SIZE);
|
||||
const exportSizes = useQueries({
|
||||
queries: exportChats.map((chat) => ({
|
||||
queryKey: ["me-export-meta", user?.id, chat.partner.id],
|
||||
queryFn: () => http.get<{ bytes: number; chat: number; lastAt?: string }>(`/api/me/export?meta=1&chat=${chat.partner.id}`),
|
||||
enabled: !!user && section === "export",
|
||||
})),
|
||||
});
|
||||
const [form, setForm] = useState<Extras | null>(null);
|
||||
const [displayName, setDisplayName] = useState(user?.name ?? "");
|
||||
const [email, setEmail] = useState(user?.email ?? "");
|
||||
const [phone, setPhone] = useState(user?.phone ?? "");
|
||||
const [nextUsername, setNextUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [identityDirty, setIdentityDirty] = useState(false);
|
||||
const [savingIdentity, setSavingIdentity] = useState(false);
|
||||
const [savingUsername, setSavingUsername] = useState(false);
|
||||
const [savingPassword, setSavingPassword] = useState(false);
|
||||
const [freezeConfirm, setFreezeConfirm] = useState(false);
|
||||
const [freezePassword, setFreezePassword] = useState("");
|
||||
const [freezeBusy, setFreezeBusy] = useState(false);
|
||||
const qc = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
const profile = profileQ.data?.profile;
|
||||
if (!profile) return;
|
||||
setForm({
|
||||
...profile,
|
||||
socialLinks: normalizeLinks(profile.socialLinks),
|
||||
});
|
||||
}, [profileQ.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || identityDirty) return;
|
||||
setDisplayName(user.name ?? "");
|
||||
setEmail(user.email ?? "");
|
||||
setPhone(user.phone ?? "");
|
||||
}, [user, identityDirty]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const usernameCooling = Boolean(
|
||||
user.usernameCooldownUntil && new Date(user.usernameCooldownUntil).getTime() > Date.now(),
|
||||
);
|
||||
const usernameRemaining = user.usernameChangesRemaining ?? 3;
|
||||
const active = SECTIONS.find((item) => item.id === section) ?? SECTIONS[0];
|
||||
const activityOn = profileQ.data?.profile.showActivity ?? true;
|
||||
const badgesOn = profileQ.data?.profile.showBadges ?? true;
|
||||
|
||||
const patch = (partial: Partial<Extras>) => {
|
||||
setForm((prev) => (prev ? { ...prev, ...partial } : prev));
|
||||
};
|
||||
|
||||
const saveProfile = async () => {
|
||||
if (!form) return;
|
||||
try {
|
||||
await http.put("/api/me/profile", {
|
||||
public: form.public,
|
||||
showActivity: form.showActivity,
|
||||
showStats: form.showStats,
|
||||
showPath: form.showPath,
|
||||
showBadges: form.showBadges,
|
||||
socialLinks: form.socialLinks.slice(0, 5),
|
||||
banner: form.banner,
|
||||
});
|
||||
toast.success("تنظیمات ذخیره شد");
|
||||
} catch (e) {
|
||||
toast.error(toUserError(e, "ذخیره ممکن نشد"));
|
||||
}
|
||||
};
|
||||
|
||||
const saveIdentity = async () => {
|
||||
const nameErr = validateName(displayName);
|
||||
if (nameErr) {
|
||||
toast.error(nameErr);
|
||||
return;
|
||||
}
|
||||
const emailErr = validateEmail(email);
|
||||
if (emailErr) {
|
||||
toast.error(emailErr);
|
||||
return;
|
||||
}
|
||||
const phoneErr = validatePhone(phone, false);
|
||||
if (phoneErr) {
|
||||
toast.error(phoneErr);
|
||||
return;
|
||||
}
|
||||
setSavingIdentity(true);
|
||||
try {
|
||||
const res = await http.put<{ user: typeof user }>("/api/me/account", {
|
||||
name: displayName.trim(),
|
||||
email: email.trim().toLowerCase(),
|
||||
phone: phone.trim(),
|
||||
});
|
||||
setUser(res.user);
|
||||
setDisplayName(res.user.name ?? "");
|
||||
setEmail(res.user.email ?? "");
|
||||
setPhone(res.user.phone ?? "");
|
||||
setIdentityDirty(false);
|
||||
toast.success("مشخصات ذخیره شد");
|
||||
} catch (e) {
|
||||
toast.error(toUserError(e, "ذخیره مشخصات ممکن نشد"));
|
||||
} finally {
|
||||
setSavingIdentity(false);
|
||||
}
|
||||
};
|
||||
|
||||
const changeUsername = async () => {
|
||||
const err = validateUsername(nextUsername);
|
||||
if (err) {
|
||||
toast.error(err);
|
||||
return;
|
||||
}
|
||||
const next = nextUsername.trim().toLowerCase();
|
||||
if (next === user.username) {
|
||||
toast.success("نام کاربری همان است");
|
||||
return;
|
||||
}
|
||||
setSavingUsername(true);
|
||||
try {
|
||||
const res = await http.put<{ user: typeof user }>("/api/me/username", { username: next });
|
||||
setUser(res.user);
|
||||
setNextUsername("");
|
||||
toast.success("نام کاربری تغییر کرد");
|
||||
} catch (e) {
|
||||
if (!(e instanceof ApiError && e.status === 429)) {
|
||||
toast.error(toUserError(e, "تغییر نام کاربری ممکن نشد"));
|
||||
}
|
||||
} finally {
|
||||
setSavingUsername(false);
|
||||
}
|
||||
};
|
||||
|
||||
const savePassword = async () => {
|
||||
const err = validatePassword(password);
|
||||
if (err) {
|
||||
toast.error(err);
|
||||
return;
|
||||
}
|
||||
setSavingPassword(true);
|
||||
try {
|
||||
const res = await http.put<{ user: typeof user }>("/api/me/account", {
|
||||
currentPassword,
|
||||
password,
|
||||
});
|
||||
setUser(res.user);
|
||||
setPassword("");
|
||||
setCurrentPassword("");
|
||||
toast.success("گذرواژه ذخیره شد");
|
||||
} catch (e) {
|
||||
toast.error(toUserError(e, "تغییر گذرواژه ممکن نشد"));
|
||||
} finally {
|
||||
setSavingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
const freezeAccount = async () => {
|
||||
if (!freezeConfirm) {
|
||||
setFreezeConfirm(true);
|
||||
return;
|
||||
}
|
||||
if (!freezePassword.trim()) {
|
||||
toast.error("گذرواژهٔ فعلی را بنویسید");
|
||||
return;
|
||||
}
|
||||
setFreezeBusy(true);
|
||||
try {
|
||||
const res = await http.post<{ user: typeof user }>("/api/me/freeze", { currentPassword: freezePassword });
|
||||
setUser(res.user);
|
||||
setFreezeConfirm(false);
|
||||
setFreezePassword("");
|
||||
await qc.invalidateQueries({ queryKey: ["me"] });
|
||||
toast.success("حساب فریز شد");
|
||||
} catch (e) {
|
||||
toast.error(toUserError(e, "فریز حساب ممکن نشد"));
|
||||
} finally {
|
||||
setFreezeBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const unfreezeAccount = async () => {
|
||||
setFreezeBusy(true);
|
||||
try {
|
||||
const res = await http.post<{ user: typeof user }>("/api/me/unfreeze");
|
||||
setUser(res.user);
|
||||
await qc.invalidateQueries({ queryKey: ["me"] });
|
||||
toast.success("فریز لغو شد");
|
||||
} catch (e) {
|
||||
toast.error(toUserError(e, "لغو فریز ممکن نشد"));
|
||||
} finally {
|
||||
setFreezeBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDate = formatFreezeCloseDate(user.closesAt || freezeCloseIso(user.frozenAt));
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<header className="mb-6 space-y-1">
|
||||
<h1 className="text-2xl font-black tracking-tight">تنظیمات</h1>
|
||||
<p className="text-sm text-muted-foreground">حساب، حریم و دادههای همین حساب.</p>
|
||||
</header>
|
||||
|
||||
<SectionColumns>
|
||||
<SectionNav
|
||||
label="بخشهای تنظیمات"
|
||||
groups={[{ items: SECTIONS.map((item) => ({ id: item.id, label: item.label })) }]}
|
||||
activeId={section}
|
||||
onSelect={setSection}
|
||||
/>
|
||||
|
||||
<SectionPanel
|
||||
id="settings-section"
|
||||
titleId="settings-section-title"
|
||||
title={active.title}
|
||||
description={active.description}
|
||||
>
|
||||
{section === "account" && (
|
||||
<div className="space-y-8">
|
||||
<AccountIdentityFields
|
||||
idPrefix="settings"
|
||||
name={displayName}
|
||||
email={email}
|
||||
phone={phone}
|
||||
onName={(value) => {
|
||||
setIdentityDirty(true);
|
||||
setDisplayName(value);
|
||||
}}
|
||||
onEmail={(value) => {
|
||||
setIdentityDirty(true);
|
||||
setEmail(value);
|
||||
}}
|
||||
onPhone={(value) => {
|
||||
setIdentityDirty(true);
|
||||
setPhone(value);
|
||||
}}
|
||||
action={
|
||||
<Button type="button" disabled={savingIdentity} onClick={() => void saveIdentity()}>
|
||||
ذخیره مشخصات
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<AccountUsernameSection
|
||||
mode="self"
|
||||
idPrefix="settings"
|
||||
current={user.username}
|
||||
next={nextUsername}
|
||||
onNext={setNextUsername}
|
||||
disabled={usernameCooling}
|
||||
hint={
|
||||
usernameCooling && user.usernameCooldownUntil ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
سه بار نام کاربری را عوض کردهاید. تغییر بعدی از {formatJalaliStamp(user.usernameCooldownUntil)} ممکن است.
|
||||
</p>
|
||||
) : usernameAllowanceLabel(usernameRemaining) ? (
|
||||
<p className="text-xs font-bold text-muted-foreground">{usernameAllowanceLabel(usernameRemaining)}</p>
|
||||
) : null
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
disabled={usernameCooling || savingUsername || usernameRemaining <= 0}
|
||||
onClick={() => void changeUsername()}
|
||||
>
|
||||
تغییر نام کاربری
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="space-y-4 border-t border-border pt-6" aria-labelledby="settings-password-heading">
|
||||
<div className="space-y-1">
|
||||
<h3 id="settings-password-heading" className="text-sm font-black">گذرواژه</h3>
|
||||
<p className="text-xs text-muted-foreground">برای ذخیره، گذرواژهٔ فعلی لازم است.</p>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field id="settings-new-password" label="گذرواژهٔ جدید">
|
||||
<PasswordInput
|
||||
id="settings-new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
dir="ltr"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Field>
|
||||
<Field id="settings-current-password" label="گذرواژهٔ فعلی">
|
||||
<PasswordInput
|
||||
id="settings-current-password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
dir="ltr"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Button type="button" disabled={savingPassword} onClick={() => void savePassword()}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 border-t border-border pt-6" aria-labelledby="settings-freeze-heading">
|
||||
<h3 id="settings-freeze-heading" className="text-sm font-black">فریز حساب</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
فریز تا ۳۰ روز قابل بازگشت است، بعد از آن حساب بسته میشود و حذف دائمی فقط با مجوز ادمین است.
|
||||
</p>
|
||||
{user.isClosed ? (
|
||||
<p className="text-sm font-bold">حساب بسته شده است. لغو فریز ممکن نیست.</p>
|
||||
) : user.isFrozen ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm">
|
||||
{closeDate ? `این حساب در ${closeDate} بسته میشود.` : "این حساب تا ۳۰ روز دیگر بسته میشود."}
|
||||
</p>
|
||||
<Button type="button" variant="outline" disabled={freezeBusy} onClick={() => void unfreezeAccount()}>
|
||||
لغو فریز
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{freezeConfirm && (
|
||||
<>
|
||||
<p className="text-sm font-bold">برای فریز، گذرواژهٔ فعلی را بنویسید.</p>
|
||||
<Field id="settings-freeze-password" label="گذرواژهٔ فعلی">
|
||||
<PasswordInput
|
||||
id="settings-freeze-password"
|
||||
value={freezePassword}
|
||||
onChange={(e) => setFreezePassword(e.target.value)}
|
||||
dir="ltr"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant={freezeConfirm ? "destructive" : "outline"} disabled={freezeBusy} onClick={() => void freezeAccount()}>
|
||||
فریز حساب
|
||||
</Button>
|
||||
{freezeConfirm && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={freezeBusy}
|
||||
onClick={() => {
|
||||
setFreezeConfirm(false);
|
||||
setFreezePassword("");
|
||||
}}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === "privacy" && (
|
||||
<PrivacyFields
|
||||
form={form}
|
||||
loading={profileQ.isPending}
|
||||
error={profileQ.isError ? toUserError(profileQ.error, "بارگذاری حریم ممکن نشد") : null}
|
||||
onPatch={patch}
|
||||
onSave={() => void saveProfile()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{section === "stats" && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<StatTile
|
||||
icon={<Sparkles className="h-4 w-4 text-gold" />}
|
||||
label="امتیاز"
|
||||
value={formatFaNumber(user.xp)}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<Flame className="h-4 w-4 text-accent" />}
|
||||
label="زنجیرهٔ فعلی"
|
||||
value={formatStreakLabel(user.streakCurrent)}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<Flame className="h-4 w-4 text-muted-foreground" />}
|
||||
label="بلندترین زنجیره"
|
||||
value={formatStreakLabel(user.streakLongest)}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<CalendarDays className="h-4 w-4 text-primary" />}
|
||||
label="روزهای این هفته"
|
||||
value={countLabel(activityOn, activityQ.isPending, activityQ.data?.weekDays)}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<CalendarDays className="h-4 w-4 text-secondary" />}
|
||||
label="روزهای این ماه"
|
||||
value={countLabel(activityOn, activityQ.isPending, activityQ.data?.monthDays)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-black">نشانها</h3>
|
||||
{badgesOn ? (
|
||||
<div className="mt-3">
|
||||
<p className="mb-3 text-xs text-muted-foreground">رنگدارها باز شدهاند و کمرنگها هنوز قفلاند.</p>
|
||||
{badgesQ.isPending ? (
|
||||
<div className="flex flex-wrap gap-2" aria-busy="true" aria-label="در حال بارگذاری نشانها">
|
||||
<Skeleton className="h-6 w-24 rounded-full" />
|
||||
<Skeleton className="h-6 w-20 rounded-full" />
|
||||
<Skeleton className="h-6 w-28 rounded-full" />
|
||||
</div>
|
||||
) : badgesQ.isError ? (
|
||||
<p className="text-sm font-bold text-destructive">{toUserError(badgesQ.error, "بارگذاری نشانها ممکن نشد")}</p>
|
||||
) : (badgesQ.data?.achievements ?? []).length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">نشانی ثبت نشده.</p>
|
||||
) : (
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{(badgesQ.data?.achievements ?? []).map((b) => (
|
||||
<li key={b.id}>
|
||||
<Badge variant={b.unlocked ? "success" : "secondary"} className="rounded-full px-2.5 py-1">
|
||||
{b.title}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-muted-foreground">نمایش نشانها در بخش حریم خاموش است.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === "export" && (
|
||||
<div className="space-y-6">
|
||||
{exportChatsQ.isPending ? (
|
||||
<div className="space-y-3" aria-busy="true" aria-label="در حال بارگذاری گفتگوها">
|
||||
<Skeleton className="h-16 rounded-xl" />
|
||||
<Skeleton className="h-16 rounded-xl" />
|
||||
</div>
|
||||
) : exportChatsQ.isError ? (
|
||||
<p className="text-sm font-bold text-destructive">{toUserError(exportChatsQ.error, "بارگذاری گفتگوها ممکن نشد")}</p>
|
||||
) : exportChats.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">گفتگویی نیست.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<ul className="divide-y divide-border">
|
||||
{exportPageChats.map((chat, offset) => {
|
||||
const saved = chat.partner.saved || chat.partner.id === user.id;
|
||||
const label = saved ? "پیامهای ذخیرهشده" : chat.partner.name;
|
||||
const size = exportSizes[exportPageStart + offset];
|
||||
const last = chat.lastMessage?.createdAt || size?.data?.lastAt;
|
||||
return (
|
||||
<li key={chat.partner.id} className="flex flex-col gap-3 py-4 first:pt-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="truncate text-sm font-bold">{label}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{last ? `آخرین فعالیت: ${formatJalaliStamp(last)}` : "هنوز پیامی نیست"}
|
||||
</p>
|
||||
<p className="text-xs font-bold text-foreground">
|
||||
{size?.isPending
|
||||
? "در حال برآورد حجم…"
|
||||
: size?.isError
|
||||
? "برآورد حجم ممکن نشد"
|
||||
: `حجم تقریبی: ${formatApproxSize(size?.data?.bytes ?? 0)}`}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="shrink-0"
|
||||
disabled={exportingId === chat.partner.id}
|
||||
onClick={() => {
|
||||
setExportingId(chat.partner.id);
|
||||
void downloadChatExport(chat.partner.id, exportFileName(label, chat.partner.id)).finally(() => {
|
||||
setExportingId((cur) => (cur === chat.partner.id ? null : cur));
|
||||
});
|
||||
}}
|
||||
>
|
||||
دانلود
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{exportChats.length > EXPORT_PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={exportPageSafe <= 1}
|
||||
onClick={() => setExportPage(exportPageSafe - 1)}
|
||||
>
|
||||
قبلی
|
||||
</Button>
|
||||
<p className="text-sm font-bold tabular-nums text-muted-foreground">
|
||||
{formatFaNumber(exportPageSafe)} از {formatFaNumber(exportPageCount)}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={exportPageSafe >= exportPageCount}
|
||||
onClick={() => setExportPage(exportPageSafe + 1)}
|
||||
>
|
||||
بعدی
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<ul className="divide-y divide-border border-t border-border pt-4">
|
||||
<li className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="text-sm font-bold">حذف حافظه موقت</p>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">عکس و صدای ذخیرهشده روی همین دستگاه حذف میشود.</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="shrink-0"
|
||||
onClick={() => {
|
||||
void clearMediaCache().then(() => toast.success("حافظه موقت حذف شد"));
|
||||
}}
|
||||
>
|
||||
حذف
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</SectionPanel>
|
||||
</SectionColumns>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PrivacyFields({
|
||||
form,
|
||||
loading,
|
||||
error,
|
||||
onPatch,
|
||||
onSave,
|
||||
}: {
|
||||
form: Extras | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onPatch: (partial: Partial<Extras>) => void;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
if (loading || !form) {
|
||||
if (error) return <p className="text-sm font-bold text-destructive">{error}</p>;
|
||||
return (
|
||||
<div className="space-y-3" aria-busy="true" aria-label="در حال بارگذاری حریم">
|
||||
<Skeleton className="h-14 rounded-xl" />
|
||||
<Skeleton className="h-14 rounded-xl" />
|
||||
<Skeleton className="h-14 rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const links = form.socialLinks;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="divide-y divide-border">
|
||||
<PrivacyRow
|
||||
id="privacy-public"
|
||||
label="پروفایل عمومی"
|
||||
hint="تا وقتی خاموش باشد، دیگران فقط نام و آواتار را میبینند."
|
||||
checked={form.public}
|
||||
onCheckedChange={(v) => onPatch({ public: v })}
|
||||
/>
|
||||
<PrivacyRow
|
||||
id="privacy-activity"
|
||||
label="نمایش فعالیت"
|
||||
hint="روزهای فعال در پروفایل عمومی دیده میشود."
|
||||
checked={form.showActivity}
|
||||
onCheckedChange={(v) => onPatch({ showActivity: v })}
|
||||
/>
|
||||
<PrivacyRow
|
||||
id="privacy-stats"
|
||||
label="نمایش آمار"
|
||||
hint="امتیاز و زنجیره در پروفایل عمومی دیده میشود."
|
||||
checked={form.showStats}
|
||||
onCheckedChange={(v) => onPatch({ showStats: v })}
|
||||
/>
|
||||
<PrivacyRow
|
||||
id="privacy-path"
|
||||
label="نمایش مسیر"
|
||||
hint="مسیر یادگیری در پروفایل عمومی دیده میشود."
|
||||
checked={form.showPath}
|
||||
onCheckedChange={(v) => onPatch({ showPath: v })}
|
||||
/>
|
||||
<PrivacyRow
|
||||
id="privacy-badges"
|
||||
label="نمایش نشانها"
|
||||
hint="نشانها در پروفایل عمومی دیده میشوند."
|
||||
checked={form.showBadges}
|
||||
onCheckedChange={(v) => onPatch({ showBadges: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-t border-border pt-5">
|
||||
<div>
|
||||
<h3 className="text-sm font-black">لینکهای شخصی</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">حداکثر پنج لینک. نام و نشانی را وارد کنید.</p>
|
||||
</div>
|
||||
{links.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">هنوز لینکی اضافه نشده.</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{links.map((link, i) => (
|
||||
<li key={i} className="flex items-end gap-2">
|
||||
<div className="w-28 shrink-0 space-y-1.5 sm:w-36">
|
||||
<Label htmlFor={`settings-link-name-${i}`} className="text-xs font-bold text-muted-foreground">
|
||||
نام
|
||||
</Label>
|
||||
<Input
|
||||
id={`settings-link-name-${i}`}
|
||||
value={link.name}
|
||||
placeholder="تلگرام"
|
||||
onChange={(e) => {
|
||||
const socialLinks = links.map((row, idx) => (idx === i ? { ...row, name: e.target.value } : row));
|
||||
onPatch({ socialLinks });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<Label htmlFor={`settings-link-url-${i}`} className="text-xs font-bold text-muted-foreground">
|
||||
نشانی
|
||||
</Label>
|
||||
<Input
|
||||
id={`settings-link-url-${i}`}
|
||||
dir="ltr"
|
||||
value={link.url}
|
||||
placeholder="https://"
|
||||
onChange={(e) => {
|
||||
const socialLinks = links.map((row, idx) => (idx === i ? { ...row, url: e.target.value } : row));
|
||||
onPatch({ socialLinks });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-11 shrink-0"
|
||||
aria-label={`حذف لینک ${link.name || i + 1}`}
|
||||
onClick={() => onPatch({ socialLinks: links.filter((_, idx) => idx !== i) })}
|
||||
>
|
||||
حذف
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{links.length < 5 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPatch({ socialLinks: [...links, { name: "", url: "" }] })}
|
||||
>
|
||||
افزودن لینک
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="button" onClick={onSave}>
|
||||
ذخیره حریم
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PrivacyRow({
|
||||
id,
|
||||
label,
|
||||
hint,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
hint: string;
|
||||
checked: boolean;
|
||||
onCheckedChange: (v: boolean) => void;
|
||||
}) {
|
||||
const hintId = `${id}-hint`;
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 py-4 first:pt-0 last:pb-0">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<Label htmlFor={id} className="text-sm font-bold text-foreground">
|
||||
{label}
|
||||
</Label>
|
||||
<p id={hintId} className="text-xs leading-relaxed text-muted-foreground">
|
||||
{hint}
|
||||
</p>
|
||||
</div>
|
||||
<Switch id={id} checked={checked} onCheckedChange={onCheckedChange} aria-describedby={hintId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatFreezeCloseDate(iso?: string) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
return d.toLocaleDateString("fa-IR", { year: "numeric", month: "long", day: "numeric" });
|
||||
}
|
||||
|
||||
function freezeCloseIso(frozenAt?: string) {
|
||||
if (!frozenAt) return "";
|
||||
const start = new Date(frozenAt);
|
||||
if (Number.isNaN(start.getTime())) return "";
|
||||
const close = new Date(start);
|
||||
close.setDate(close.getDate() + 30);
|
||||
return close.toISOString();
|
||||
}
|
||||
|
||||
function usernameAllowanceLabel(remaining: number): string {
|
||||
if (remaining >= 3) return "۳ بار دیگر";
|
||||
if (remaining === 2) return "۲ بار دیگر";
|
||||
if (remaining === 1) return "۱ بار دیگر";
|
||||
return "";
|
||||
}
|
||||
|
||||
function Field({ id, label, children }: { id: string; label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={id} className="text-xs font-bold text-muted-foreground">
|
||||
{label}
|
||||
</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatTile({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-border bg-muted/30 px-3 py-3 sm:px-4">
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-card">{icon}</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-bold text-muted-foreground">{label}</p>
|
||||
<p className="text-lg font-black tabular-nums">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function countLabel(enabled: boolean, pending: boolean, value: number | undefined) {
|
||||
if (!enabled) return "خاموش";
|
||||
if (pending) return "…";
|
||||
return formatFaNumber(value ?? 0);
|
||||
}
|
||||
|
||||
function normalizeLinks(links: SocialLink[] | null | undefined): SocialLink[] {
|
||||
if (!Array.isArray(links)) return [];
|
||||
return links.slice(0, 5).map((row) => ({ name: row?.name ?? "", url: row?.url ?? "" }));
|
||||
}
|
||||
|
||||
function formatApproxSize(bytes: number) {
|
||||
if (!Number.isFinite(bytes) || bytes < 1024) return `حدود ${formatFaNumber(1)} کیلوبایت`;
|
||||
if (bytes < 1024 * 1024) return `حدود ${formatFaNumber(Math.round(bytes / 1024))} کیلوبایت`;
|
||||
const mb = bytes / (1024 * 1024);
|
||||
return `حدود ${formatFaNumber(mb, mb < 10 ? 1 : 0)} مگابایت`;
|
||||
}
|
||||
|
||||
function exportFileName(label: string, id: number) {
|
||||
const clean = label.replace(/[\\/:*?"<>|]/g, " ").replace(/\s+/g, " ").trim();
|
||||
return `${clean || `chat-${id}`}.zip`;
|
||||
}
|
||||
|
||||
async function downloadChatExport(chatId: number, filename: string) {
|
||||
const token = useAuth.getState().accessToken;
|
||||
const base = (process.env.NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, "");
|
||||
try {
|
||||
const res = await fetch(`${base}/api/me/export?chat=${chatId}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
toast.error("دانلود خروجی گفتگو ممکن نشد");
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
toast.error("دانلود خروجی گفتگو ممکن نشد");
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Award, CalendarDays, HeartHandshake, MessageSquareHeart, Sparkles } from "lucide-react";
|
||||
import { Award, CalendarDays, MessageSquareHeart, Share2, Sparkles } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
@@ -17,7 +18,9 @@ import { Badge } from "@/components/ui/badge";
|
||||
import type { Mentor } from "@/lib/types";
|
||||
import { http } from "@/lib/api";
|
||||
import type { ChatMessage, Conversation } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, formatJalaliStamp } from "@/lib/utils";
|
||||
import { useAuth } from "@/lib/auth-store";
|
||||
import { isVerifiedRole, VerifiedBadge } from "@/components/verified-badge";
|
||||
|
||||
type Tab = "events" | "chats" | "leagues" | "community";
|
||||
|
||||
@@ -69,7 +72,7 @@ function HubTabs() {
|
||||
جامعه، <span className="text-gradient-brand">استمرار و منتورها</span>
|
||||
</>
|
||||
}
|
||||
description="رویدادهای زنده برای تقویت مهارت، لیگهای هفتگی برای ثبات، گفتگو با منتور، و هماهنگی حمایت مالی."
|
||||
description="رویدادهای زنده برای تقویت مهارت، لیگهای هفتگی برای ثبات، گفتگو، و شبکههای پرگار."
|
||||
className="mb-0"
|
||||
/>
|
||||
|
||||
@@ -88,26 +91,11 @@ function HubTabs() {
|
||||
<span className="truncate">لیگها</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="community" className="h-10 gap-1.5 px-2 sm:px-3">
|
||||
<HeartHandshake className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">حمایت</span>
|
||||
<Share2 className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">شبکهها</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="events" className="mt-0 space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTab("community")}
|
||||
className="flex w-full items-center gap-3 rounded-2xl border-2 border-accent/30 bg-accent/5 px-4 py-3.5 text-start transition hover:bg-accent/10"
|
||||
>
|
||||
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-accent/15 text-accent">
|
||||
<HeartHandshake className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-extrabold">حمایت مالی</span>
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">
|
||||
هماهنگی دونیت از طریق گفتگو با تیم
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<EventsPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="chats" className="mt-0">
|
||||
@@ -128,17 +116,40 @@ function openChat(withId?: number) {
|
||||
window.dispatchEvent(new CustomEvent("pargar:chat", { detail: { open: true, with: withId } }));
|
||||
}
|
||||
|
||||
function roleWord(role?: string) {
|
||||
if (role === "admin") return "مدیر";
|
||||
if (role === "mentor") return "منتور";
|
||||
if (role === "student") return "هنرجو";
|
||||
return "کاربر";
|
||||
}
|
||||
|
||||
function ChatsLauncher() {
|
||||
const me = useAuth((s) => s.user);
|
||||
const staff = me?.role === "admin" || me?.role === "mentor";
|
||||
const [peoplePage, setPeoplePage] = useState(1);
|
||||
const [alpha, setAlpha] = useState(false);
|
||||
const [convPage, setConvPage] = useState(1);
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["mentors"],
|
||||
queryFn: () => http.get<{ mentors: Mentor[] }>("/api/mentors"),
|
||||
queryKey: ["mentors", peoplePage],
|
||||
queryFn: () => http.get<{ mentors: Mentor[]; total: number; pageSize: number }>(`/api/mentors?page=${peoplePage}&pageSize=20`),
|
||||
});
|
||||
const { data: convData, isLoading: convLoading } = useQuery({
|
||||
queryKey: ["conversations"],
|
||||
queryFn: () => http.get<{ conversations: Conversation[] }>("/api/chats/conversations"),
|
||||
queryKey: ["conversations", convPage],
|
||||
queryFn: () =>
|
||||
http.get<{ conversations: Conversation[]; total: number; pageSize: number }>(
|
||||
`/api/chats/conversations?page=${convPage}&pageSize=10`,
|
||||
),
|
||||
});
|
||||
const mentors = data?.mentors ?? [];
|
||||
const people = useMemo(() => {
|
||||
const list = [...mentors];
|
||||
if (alpha) list.sort((a, b) => a.name.localeCompare(b.name, "fa"));
|
||||
else list.sort((a, b) => Number(!!b.online) - Number(!!a.online) || a.name.localeCompare(b.name, "fa"));
|
||||
return list;
|
||||
}, [mentors, alpha]);
|
||||
const conversations = convData?.conversations ?? [];
|
||||
const peopleTotal = data?.total ?? mentors.length;
|
||||
const convTotal = convData?.total ?? conversations.length;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
@@ -156,7 +167,7 @@ function ChatsLauncher() {
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="گفتگوهای شما" description="مکالمات جاری با منتورها — روی هر کدام بزنید تا باز شود.">
|
||||
<Panel title="گفتگوهای شما" description="مکالمات جاری — روی هر کدام بزنید تا باز شود.">
|
||||
<div className="space-y-1.5 pt-1">
|
||||
{convLoading && (
|
||||
<div className="space-y-2">
|
||||
@@ -183,9 +194,12 @@ function ChatsLauncher() {
|
||||
<UserAvatar name={c.partner.name} className="h-10 w-10" {...avatarPropsOf(c.partner)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="truncate font-bold">{c.partner.name}</p>
|
||||
<p className="flex min-w-0 items-center gap-1 font-bold">
|
||||
<span className="truncate">{c.partner.name}</span>
|
||||
{isVerifiedRole(c.partner.role) && <VerifiedBadge role={c.partner.role} />}
|
||||
</p>
|
||||
{c.lastMessage && (
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">{timeAgo(c.lastMessage.createdAt)}</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">{formatJalaliStamp(c.lastMessage.createdAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
@@ -207,13 +221,38 @@ function ChatsLauncher() {
|
||||
))}
|
||||
{!convLoading && conversations.length === 0 && (
|
||||
<p className="px-1 text-sm text-muted-foreground">
|
||||
هنوز گفتگویی ندارید — از «شروع سریع» یک منتور انتخاب کنید.
|
||||
هنوز گفتگویی ندارید — از فهرست کنار، یک نفر را انتخاب کنید.
|
||||
</p>
|
||||
)}
|
||||
{convTotal > (convData?.pageSize ?? 10) && (
|
||||
<div className="flex items-center justify-between pt-2 text-xs">
|
||||
<Button type="button" size="sm" variant="outline" disabled={convPage <= 1} onClick={() => setConvPage((p) => p - 1)}>
|
||||
قبلی
|
||||
</Button>
|
||||
<span className="text-muted-foreground">{convPage}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={convPage * (convData?.pageSize ?? 10) >= convTotal}
|
||||
onClick={() => setConvPage((p) => p + 1)}
|
||||
>
|
||||
بعدی
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="شروع سریع با منتور" description="یک گفتگو را مستقیم باز کنید.">
|
||||
<Panel
|
||||
title={staff ? "شروع گفتگو" : "شروع سریع با منتور"}
|
||||
description="نقش هر نفر روی ردیف نوشته شده است."
|
||||
actions={
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => setAlpha((v) => !v)}>
|
||||
{alpha ? "آنلاین بالا" : "الفبایی"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-1 pt-1">
|
||||
{isLoading && (
|
||||
<div className="space-y-2">
|
||||
@@ -229,24 +268,46 @@ function ChatsLauncher() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{mentors.map((m) => (
|
||||
<button
|
||||
{people.map((m) => (
|
||||
<motion.button
|
||||
layout
|
||||
key={m.id}
|
||||
onClick={() => openChat(m.id)}
|
||||
className="flex w-full items-center gap-3 rounded-xl border-2 border-border/70 px-3 py-2.5 text-right transition-colors hover:border-primary/50 hover:bg-primary/5"
|
||||
>
|
||||
<UserAvatar name={m.name} className="h-10 w-10" {...avatarPropsOf(m)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-bold">{m.name}</p>
|
||||
<p className="text-xs text-muted-foreground">منتور</p>
|
||||
<p className="flex min-w-0 items-center gap-1 font-bold">
|
||||
<span className="truncate">{m.name}</span>
|
||||
{isVerifiedRole(m.role) && <VerifiedBadge role={m.role} />}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{roleWord(m.role)}</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-bold text-success">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-success" /> در دسترس
|
||||
<span className={cn("inline-flex items-center gap-1.5 text-xs font-bold", m.online ? "text-success" : "text-muted-foreground")}>
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", m.online ? "bg-success" : "bg-muted-foreground/40")} />
|
||||
{m.online ? "آنلاین" : "آفلاین"}
|
||||
</span>
|
||||
</button>
|
||||
</motion.button>
|
||||
))}
|
||||
{!isLoading && mentors.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">هنوز منتوری در دسترس نیست.</p>
|
||||
<p className="text-sm text-muted-foreground">{staff ? "کسی برای گفتگو نیست." : "هنوز منتوری در دسترس نیست."}</p>
|
||||
)}
|
||||
{peopleTotal > (data?.pageSize ?? 20) && (
|
||||
<div className="flex items-center justify-between pt-2 text-xs">
|
||||
<Button type="button" size="sm" variant="outline" disabled={peoplePage <= 1} onClick={() => setPeoplePage((p) => p - 1)}>
|
||||
قبلی
|
||||
</Button>
|
||||
<span className="text-muted-foreground">{peoplePage}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={peoplePage * (data?.pageSize ?? 20) >= peopleTotal}
|
||||
onClick={() => setPeoplePage((p) => p + 1)}
|
||||
>
|
||||
بعدی
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
@@ -256,19 +317,13 @@ function ChatsLauncher() {
|
||||
|
||||
function messagePreview(m: ChatMessage) {
|
||||
if (m.body) return m.body;
|
||||
if (m.attachment) {
|
||||
const atts = m.attachments?.length ? m.attachments : m.attachment ? [m.attachment] : [];
|
||||
if (atts.length > 1 && atts.every((a) => a.type === "image")) return `📷 ${atts.length.toLocaleString("fa-IR")} عکس`;
|
||||
if (atts.length > 1 && atts.every((a) => a.type === "file")) return `📎 ${atts.length.toLocaleString("fa-IR")} فایل`;
|
||||
if (atts.length > 1) return `📎 ${atts.length.toLocaleString("fa-IR")} پیوست`;
|
||||
if (atts[0]) {
|
||||
const labels: Record<string, string> = { image: "📷 عکس", video: "🎬 ویدیو", audio: "🎵 پیام صوتی" };
|
||||
return labels[m.attachment.type] ?? `📎 ${m.attachment.name}`;
|
||||
return labels[atts[0].type] ?? `📎 ${atts[0].name}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function timeAgo(iso: string) {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const m = Math.floor(diff / 60_000);
|
||||
if (m < 1) return "الان";
|
||||
if (m < 60) return `${m} دقیقه قبل`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h} ساعت قبل`;
|
||||
return new Date(iso).toLocaleDateString("fa-IR");
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
function Field({ id, label, children }: { id: string; label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={id} className="text-xs font-bold text-muted-foreground">
|
||||
{label}
|
||||
</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** نام، ایمیل، تلفن — same block on /settings and in the admin account editor. */
|
||||
export function AccountIdentityFields({
|
||||
idPrefix,
|
||||
name,
|
||||
email,
|
||||
phone,
|
||||
onName,
|
||||
onEmail,
|
||||
onPhone,
|
||||
phoneMaxLength,
|
||||
action,
|
||||
}: {
|
||||
idPrefix: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
onName: (value: string) => void;
|
||||
onEmail: (value: string) => void;
|
||||
onPhone: (value: string) => void;
|
||||
phoneMaxLength?: number;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-4" aria-labelledby={`${idPrefix}-identity-heading`}>
|
||||
<h3 id={`${idPrefix}-identity-heading`} className="text-sm font-black">هویت</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field id={`${idPrefix}-name`} label="نام">
|
||||
<Input id={`${idPrefix}-name`} value={name} onChange={(e) => onName(e.target.value)} autoComplete="name" />
|
||||
</Field>
|
||||
<Field id={`${idPrefix}-email`} label="ایمیل">
|
||||
<Input
|
||||
id={`${idPrefix}-email`}
|
||||
value={email}
|
||||
onChange={(e) => onEmail(e.target.value)}
|
||||
dir="ltr"
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
/>
|
||||
</Field>
|
||||
<Field id={`${idPrefix}-phone`} label="تلفن">
|
||||
<Input
|
||||
id={`${idPrefix}-phone`}
|
||||
value={phone}
|
||||
onChange={(e) => onPhone(e.target.value)}
|
||||
dir="ltr"
|
||||
autoComplete="tel"
|
||||
inputMode="tel"
|
||||
maxLength={phoneMaxLength}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
{action}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type UsernameSelf = {
|
||||
mode: "self";
|
||||
idPrefix: string;
|
||||
current: string;
|
||||
next: string;
|
||||
onNext: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
hint?: ReactNode;
|
||||
action?: ReactNode;
|
||||
};
|
||||
|
||||
type UsernameAdmin = {
|
||||
mode: "admin";
|
||||
idPrefix: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
/** نام کاربری follows هویت. Self-service keeps the cooldown fields; admin sets the name directly. */
|
||||
export function AccountUsernameSection(props: UsernameSelf | UsernameAdmin) {
|
||||
const headingId = `${props.idPrefix}-username-heading`;
|
||||
return (
|
||||
<section className="space-y-3 border-t border-border pt-6" aria-labelledby={headingId}>
|
||||
<h3 id={headingId} className="text-sm font-black">نام کاربری</h3>
|
||||
{props.mode === "self" && props.hint}
|
||||
{props.mode === "self" ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field id={`${props.idPrefix}-username-current`} label="نام کاربری فعلی">
|
||||
<Input
|
||||
id={`${props.idPrefix}-username-current`}
|
||||
value={props.current}
|
||||
readOnly
|
||||
dir="ltr"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</Field>
|
||||
<Field id={`${props.idPrefix}-username`} label="نام کاربری جدید">
|
||||
<Input
|
||||
id={`${props.idPrefix}-username`}
|
||||
value={props.next}
|
||||
onChange={(e) => props.onNext(e.target.value.toLowerCase())}
|
||||
dir="ltr"
|
||||
autoComplete="off"
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field id={`${props.idPrefix}-username`} label="نام کاربری">
|
||||
<Input
|
||||
id={`${props.idPrefix}-username`}
|
||||
value={props.value}
|
||||
onChange={(e) => props.onChange(e.target.value.toLowerCase())}
|
||||
dir="ltr"
|
||||
autoComplete="off"
|
||||
placeholder="مثلاً ahp"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
{props.mode === "self" ? props.action : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
Plus,
|
||||
Rocket,
|
||||
Save,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Target,
|
||||
Terminal,
|
||||
@@ -82,13 +81,14 @@ import {
|
||||
isValidAdminTab,
|
||||
type AdminTabId,
|
||||
} from "@/components/admin/nav";
|
||||
import { AdminPanelHeader } from "@/components/admin/panel-header";
|
||||
import { SectionColumns, SectionNav, SectionPanel } from "@/components/section-nav";
|
||||
import { useConfirm } from "@/components/admin/use-confirm";
|
||||
import { StatsPanel } from "@/components/admin/stats-panel";
|
||||
import { LearningPanel } from "@/components/admin/learning-panel";
|
||||
import { QuestionsPanel } from "@/components/admin/questions-panel";
|
||||
import { VideosPanel, VideoKeyPicker } from "@/components/admin/videos-panel";
|
||||
import { AnnouncePanel } from "@/components/admin/announce-panel";
|
||||
import { BotPanel } from "@/components/admin/bot-panel";
|
||||
import { PhysicalOrdersPanel } from "@/components/admin/physical-orders-panel";
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
@@ -96,6 +96,7 @@ import {
|
||||
buildPageQuery,
|
||||
type Paginated,
|
||||
} from "@/components/admin/list-pagination";
|
||||
import { AccountIdentityFields, AccountUsernameSection } from "@/components/account-identity-fields";
|
||||
import { AdminSelect, ListToolbar } from "@/components/admin/list-toolbar";
|
||||
import { SiteSettingsPanel } from "@/components/admin/site-settings-panel";
|
||||
import { RegistrationPanel } from "@/components/admin/registration-panel";
|
||||
@@ -196,113 +197,32 @@ export function AdminStudio() {
|
||||
})).filter((g) => g.items.length > 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="section-kicker">{isAdmin ? "استودیو ادمین" : "استودیو منتور"}</p>
|
||||
<h1 className="mt-1.5 text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
{isAdmin ? (
|
||||
<>
|
||||
پنل <span className="text-gradient-brand">مدیریت</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
پنل <span className="text-gradient-brand">منتور</span>
|
||||
</>
|
||||
)}
|
||||
</h1>
|
||||
<p className="mt-1.5 max-w-2xl text-sm leading-relaxed text-muted-foreground">
|
||||
{isAdmin
|
||||
? "افراد، ثبتنام، محتوای دوره، ارتباط و تنظیمات سایت — گروهبندیشده تا چیزی گم نشود."
|
||||
: MENTOR_HINT}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="accent" className="gap-1 rounded-lg px-3 py-1.5">
|
||||
<ShieldCheck className="h-4 w-4" /> {isAdmin ? "ادمین" : "منتور"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<header className="mb-6 space-y-1">
|
||||
<h1 className="text-2xl font-black tracking-tight">{isAdmin ? "مدیریت" : "منتور"}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isAdmin
|
||||
? "افراد، ثبتنام، محتوای دوره، ارتباط و تنظیمات سایت."
|
||||
: MENTOR_HINT}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Mobile: horizontal grouped chips */}
|
||||
<nav className="lg:hidden" aria-label={isAdmin ? "بخشهای مدیریت" : "بخشهای منتور"}>
|
||||
<div className="-mx-1 flex gap-3 overflow-x-auto px-1 pb-1">
|
||||
{visibleGroups.map((group) => (
|
||||
<div key={group.title} className="flex shrink-0 flex-col gap-1.5">
|
||||
<p className="px-1 text-[10px] font-bold text-muted-foreground">{group.title}</p>
|
||||
<div className="flex gap-1.5">
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = tab === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setTab(item.id)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 whitespace-nowrap rounded-xl px-3 py-2 text-xs font-bold transition-colors",
|
||||
active
|
||||
? "bg-primary text-primary-foreground shadow-offset-sm"
|
||||
: "border border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-start">
|
||||
<aside className="hidden w-52 shrink-0 lg:sticky lg:top-24 lg:block">
|
||||
<nav
|
||||
className="space-y-3 rounded-xl border border-border bg-card p-2.5"
|
||||
aria-label={isAdmin ? "منوی مدیریت" : "منوی منتور"}
|
||||
>
|
||||
{visibleGroups.map((group) => (
|
||||
<div key={group.title}>
|
||||
<p className="mb-1 px-2 text-[11px] font-bold text-muted-foreground">
|
||||
{group.title}
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = tab === item.id;
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab(item.id)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm font-bold transition-colors",
|
||||
active
|
||||
? "bg-primary text-primary-foreground shadow-offset-sm"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{item.label}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{navItem && (
|
||||
<AdminPanelHeader
|
||||
title={navItem.label}
|
||||
description={navItem.description}
|
||||
icon={navItem.icon}
|
||||
/>
|
||||
)}
|
||||
<SectionColumns>
|
||||
<SectionNav
|
||||
label={isAdmin ? "بخشهای مدیریت" : "بخشهای منتور"}
|
||||
groups={visibleGroups.map((group) => ({
|
||||
title: group.title,
|
||||
items: group.items.map((item) => ({ id: item.id, label: item.label })),
|
||||
}))}
|
||||
activeId={tab}
|
||||
onSelect={setTab}
|
||||
/>
|
||||
<SectionPanel
|
||||
id="admin-section"
|
||||
titleId="admin-section-title"
|
||||
title={navItem?.label ?? ""}
|
||||
description={navItem?.description ?? ""}
|
||||
>
|
||||
{tab === "stats" && isAdmin && <StatsPanel />}
|
||||
{tab === "learning" && <LearningPanel />}
|
||||
{tab === "users" && isAdmin && <UsersPanel />}
|
||||
@@ -318,9 +238,10 @@ export function AdminStudio() {
|
||||
{tab === "events" && <AdminEventsPanel />}
|
||||
{tab === "challenges" && isAdmin && <ChallengesPanel />}
|
||||
{tab === "announce" && isAdmin && <AnnouncePanel />}
|
||||
{tab === "bots" && isAdmin && <BotPanel />}
|
||||
{tab === "chat-export" && isAdmin && <ChatExportPanel />}
|
||||
</div>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
</SectionColumns>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -374,7 +295,7 @@ function UsersPanel() {
|
||||
const del = async (u: User) => {
|
||||
if (
|
||||
!(await confirm(
|
||||
"حذف کاربر",
|
||||
"حذف دائمی",
|
||||
`حساب «${u.name}» (${u.email}) برای همیشه حذف شود؟ این عمل بازگشتناپذیر است.`
|
||||
))
|
||||
) {
|
||||
@@ -497,8 +418,8 @@ function UsersPanel() {
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{canMutate && u.id !== currentUser?.id && (
|
||||
<Button size="sm" variant="ghost" className="h-8 px-2 text-destructive" disabled={busy === u.id} onClick={() => del(u)} title="حذف">
|
||||
{canMutate && u.id !== currentUser?.id && (u.isFrozen || u.isClosed) && (
|
||||
<Button size="sm" variant="ghost" className="h-8 px-2 text-destructive" disabled={busy === u.id} onClick={() => del(u)} title="حذف دائمی" aria-label="حذف دائمی">
|
||||
{busy === u.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
)}
|
||||
@@ -644,37 +565,24 @@ export function UserDialog({
|
||||
: "اطلاعات کاربر را بهروزرسانی کنید."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>نام</Label>
|
||||
<Input value={form.name ?? ""} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>شناسه</Label>
|
||||
<Input
|
||||
value={form.username ?? ""}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value.toLowerCase() })}
|
||||
dir="ltr"
|
||||
className="text-left"
|
||||
placeholder="مثلاً ahp"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>ایمیل</Label>
|
||||
<Input type="email" value={form.email ?? ""} onChange={(e) => setForm({ ...form, email: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>تلفن</Label>
|
||||
<Input
|
||||
value={form.phone ?? ""}
|
||||
onChange={(e) => setForm({ ...form, phone: clampPhoneInput(e.target.value) })}
|
||||
dir="ltr"
|
||||
className="text-left"
|
||||
placeholder="09xxxxxxxxx"
|
||||
inputMode="tel"
|
||||
maxLength={PHONE_INPUT_MAX_LEN}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
<AccountIdentityFields
|
||||
idPrefix="admin-user"
|
||||
name={form.name ?? ""}
|
||||
email={form.email ?? ""}
|
||||
phone={form.phone ?? ""}
|
||||
onName={(value) => setForm({ ...form, name: value })}
|
||||
onEmail={(value) => setForm({ ...form, email: value })}
|
||||
onPhone={(value) => setForm({ ...form, phone: clampPhoneInput(value) })}
|
||||
phoneMaxLength={PHONE_INPUT_MAX_LEN}
|
||||
/>
|
||||
<AccountUsernameSection
|
||||
mode="admin"
|
||||
idPrefix="admin-user"
|
||||
value={form.username ?? ""}
|
||||
onChange={(value) => setForm({ ...form, username: value })}
|
||||
/>
|
||||
<section className="space-y-4 border-t border-border pt-6">
|
||||
<div className="space-y-1.5">
|
||||
<Label>نقش</Label>
|
||||
<select
|
||||
@@ -745,6 +653,7 @@ export function UserDialog({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>انصراف</Button>
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
BookOpen,
|
||||
Calendar,
|
||||
Eye,
|
||||
Gamepad2,
|
||||
Loader2,
|
||||
Megaphone,
|
||||
Route,
|
||||
Settings2,
|
||||
Trash2,
|
||||
TrendingUp,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
@@ -28,14 +33,28 @@ import {
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "@/components/providers";
|
||||
import { SafeMarkdown } from "@/components/safe-markdown";
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: "event", label: "رویداد", icon: Calendar, hint: "جلسه زنده، کارگاه، ددلاین" },
|
||||
{ id: "progress", label: "پیشرفت", icon: TrendingUp, hint: "درس جدید، فصل بازشده" },
|
||||
{ id: "gamification", label: "بازیسازی", icon: Gamepad2, hint: "جایزه، استریک، چالش" },
|
||||
{ id: "mentor", label: "منتور", icon: Users, hint: "پیام مستقیم تیم منتور" },
|
||||
{ id: "urgent", label: "فوری", icon: AlertTriangle, hint: "نیاز به توجه همین حالا" },
|
||||
{ id: "curriculum", label: "درس", icon: BookOpen, hint: "محتوای آموزشی" },
|
||||
{ id: "community", label: "جامعه", icon: Users, hint: "فضای مشترک دوره" },
|
||||
{ id: "system", label: "سیستم", icon: Settings2, hint: "وضعیت خود پرگار" },
|
||||
] as const;
|
||||
|
||||
type Announcement = {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
category: string;
|
||||
route: string;
|
||||
pinned: boolean;
|
||||
};
|
||||
|
||||
const ROUTES = [
|
||||
{ path: "/unwrap", label: "نقشه مسیر" },
|
||||
{ path: "/notifications", label: "اعلانها" },
|
||||
@@ -43,6 +62,20 @@ const ROUTES = [
|
||||
{ path: "/cap", label: "داشبورد" },
|
||||
];
|
||||
|
||||
function announcementExcerpt(body: string) {
|
||||
const plain = body
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.replace(/`([^`]*)`/g, "$1")
|
||||
.replace(/!\[[^\]]*]\([^)]*\)/g, " ")
|
||||
.replace(/\[([^\]]*)]\([^)]*\)/g, "$1")
|
||||
.replace(/^#{1,6}\s+/gm, "")
|
||||
.replace(/[*_~>]+/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (plain.length <= 180) return plain;
|
||||
return `${plain.slice(0, 180).trimEnd()}…`;
|
||||
}
|
||||
|
||||
function PreviewCard({
|
||||
title,
|
||||
body,
|
||||
@@ -66,9 +99,10 @@ function PreviewCard({
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-2.5 text-sm font-black leading-snug">{title.trim() || "عنوان اطلاعیه"}</p>
|
||||
<p className="mt-1 min-h-[2.5rem] text-xs leading-relaxed text-muted-foreground">
|
||||
{body.trim() || "متن پیام اینجا دیده میشود…"}
|
||||
</p>
|
||||
<SafeMarkdown
|
||||
text={body.trim() || "متن پیام اینجا دیده میشود…"}
|
||||
className="mt-1 min-h-[2.5rem] space-y-1 text-xs leading-relaxed text-muted-foreground"
|
||||
/>
|
||||
<p className="mt-2.5 truncate text-[10px] font-mono text-primary" dir="ltr">
|
||||
→ {route || "/"}
|
||||
</p>
|
||||
@@ -80,8 +114,15 @@ export function AnnouncePanel() {
|
||||
const [title, setTitle] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [category, setCategory] = useState<string>("event");
|
||||
const [route, setRoute] = useState("/unwrap");
|
||||
const [route, setRoute] = useState("");
|
||||
const [pinned, setPinned] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const qc = useQueryClient();
|
||||
const listQ = useQuery({
|
||||
queryKey: ["admin-announcements"],
|
||||
queryFn: () => http.get<{ announcements: Announcement[] }>("/api/admin/announcements"),
|
||||
});
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [lastSent, setLastSent] = useState<{ title: string; recipients: number } | null>(null);
|
||||
|
||||
@@ -93,17 +134,21 @@ export function AnnouncePanel() {
|
||||
if (!valid) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await http.post<{ recipients: number }>("/api/admin/announce", {
|
||||
title: title.trim(),
|
||||
body: body.trim(),
|
||||
category,
|
||||
route,
|
||||
});
|
||||
toast.success(`اطلاعیه برای ${res.recipients} نفر ارسال شد`);
|
||||
setLastSent({ title: title.trim(), recipients: res.recipients });
|
||||
const payload = { title: title.trim(), body: body.trim(), category, route: route.trim(), pinned };
|
||||
if (editingId) {
|
||||
await http.put(`/api/admin/announcements/${editingId}`, payload);
|
||||
toast.success("اطلاعیه بهروز شد");
|
||||
} else {
|
||||
const res = await http.post<{ recipients: number }>("/api/admin/announce", payload);
|
||||
toast.success(`اطلاعیه برای ${res.recipients} نفر ارسال شد`);
|
||||
setLastSent({ title: title.trim(), recipients: res.recipients });
|
||||
}
|
||||
setTitle("");
|
||||
setBody("");
|
||||
setPinned(false);
|
||||
setEditingId(null);
|
||||
setConfirmOpen(false);
|
||||
void qc.invalidateQueries({ queryKey: ["admin-announcements"] });
|
||||
} catch (e) {
|
||||
toast.error(toUserError(e, "ارسال اطلاعیه ممکن نشد"));
|
||||
} finally {
|
||||
@@ -113,7 +158,8 @@ export function AnnouncePanel() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid items-start gap-4 lg:grid-cols-[minmax(0,1fr)_20rem]">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div data-announce-compose className="grid items-start gap-4 lg:grid-cols-[minmax(0,1fr)_20rem]">
|
||||
<Card>
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-base">نگارش اعلان</CardTitle>
|
||||
@@ -136,13 +182,13 @@ export function AnnouncePanel() {
|
||||
<div className="space-y-2">
|
||||
<div className="flex h-5 items-center justify-between gap-3">
|
||||
<Label className="text-sm font-bold">پیام</Label>
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground">{body.length}/۵۰۰</span>
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground">{body.length}/۴۰۰۰</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value.slice(0, 500))}
|
||||
onChange={(e) => setBody(e.target.value.slice(0, 4000))}
|
||||
rows={5}
|
||||
maxLength={500}
|
||||
maxLength={4000}
|
||||
className={cn(
|
||||
"w-full resize-y rounded-xl border bg-background px-4 py-3 text-sm leading-relaxed focus:outline-none focus:ring-2 focus:ring-primary",
|
||||
!bodyOk && body.length > 0 ? "border-destructive/50" : "border-input"
|
||||
@@ -190,8 +236,9 @@ export function AnnouncePanel() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="flex h-5 items-center gap-1.5 text-sm font-bold">
|
||||
<Route className="h-3.5 w-3.5" /> مقصد کلیک
|
||||
<Route className="h-3.5 w-3.5" /> با زدن اعلان این صفحه باز میشود
|
||||
</Label>
|
||||
<p className="text-[11px] text-muted-foreground">خالی بگذارید تا اعلان فقط خوانده شود و صفحهای باز نکند.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ROUTES.map((r) => (
|
||||
<button
|
||||
@@ -214,8 +261,12 @@ export function AnnouncePanel() {
|
||||
onChange={(e) => setRoute(e.target.value)}
|
||||
dir="ltr"
|
||||
className="h-10 rounded-2xl font-mono text-xs"
|
||||
placeholder="/unwrap"
|
||||
placeholder="خالی مجاز است"
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm font-bold">
|
||||
<input type="checkbox" checked={pinned} onChange={(e) => setPinned(e.target.checked)} />
|
||||
سنجاق روی درخت مهارت
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 border-t border-border pt-4">
|
||||
@@ -235,7 +286,7 @@ export function AnnouncePanel() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4 lg:sticky lg:top-24">
|
||||
<div className="space-y-4">
|
||||
<Card className="border bg-muted/20">
|
||||
<CardHeader className="space-y-1 pb-3">
|
||||
<CardTitle className="flex h-6 items-center gap-2 text-sm">
|
||||
@@ -260,6 +311,80 @@ export function AnnouncePanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card data-announce-list className="rounded-2xl border border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">اطلاعیههای ثبتشده</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(listQ.data?.announcements ?? []).length > 0 ? (
|
||||
<ul className="flex list-none flex-col gap-3 p-0">
|
||||
{(listQ.data?.announcements ?? []).map((a) => {
|
||||
const cat = CATEGORIES.find((c) => c.id === a.category);
|
||||
const excerpt = announcementExcerpt(a.body);
|
||||
return (
|
||||
<li key={a.id} className="rounded-2xl border border-border bg-background p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="min-w-0 text-sm font-bold leading-snug">{a.title}</p>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{cat?.label ?? a.category}
|
||||
</Badge>
|
||||
{a.pinned ? (
|
||||
<Badge variant="gold" className="text-[10px]">
|
||||
سنجاق
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{excerpt ? (
|
||||
<p className="line-clamp-2 text-xs leading-relaxed text-muted-foreground">{excerpt}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditingId(a.id);
|
||||
setTitle(a.title);
|
||||
setBody(a.body);
|
||||
setCategory(a.category);
|
||||
setRoute(a.route);
|
||||
setPinned(a.pinned);
|
||||
}}
|
||||
>
|
||||
ویرایش
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label="حذف"
|
||||
onClick={() => {
|
||||
void http
|
||||
.del(`/api/admin/announcements/${a.id}`)
|
||||
.then(() => qc.invalidateQueries({ queryKey: ["admin-announcements"] }))
|
||||
.catch((e) => toast.error(toUserError(e, "حذف ممکن نشد")));
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : !listQ.isPending ? (
|
||||
<p className="rounded-2xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
|
||||
هنوز اطلاعیهای ثبت نشده است.
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<DialogContent className="max-w-md gap-0 overflow-hidden p-0 sm:rounded-2xl">
|
||||
<DialogHeader className="space-y-1 border-b border-border px-6 py-4 text-start">
|
||||
@@ -275,7 +400,7 @@ export function AnnouncePanel() {
|
||||
</Button>
|
||||
<Button className="h-10 gap-2" disabled={busy || !valid} onClick={() => void send()}>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Bell className="h-4 w-4" />}
|
||||
تأیید و ارسال
|
||||
{editingId ? "تأیید و ذخیره" : "تأیید و ارسال"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { BookOpen } from "lucide-react";
|
||||
import { http, toUserError } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { toast } from "@/components/providers";
|
||||
import { formatFaNumber, formatJalaliStamp } from "@/lib/utils";
|
||||
|
||||
const PAGE_SIZE = 8;
|
||||
|
||||
type BotKey = {
|
||||
id: number;
|
||||
botId: number;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
active: boolean;
|
||||
requestCount: number;
|
||||
};
|
||||
|
||||
type BotList = {
|
||||
items: BotKey[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
export function BotPanel() {
|
||||
const qc = useQueryClient();
|
||||
const [name, setName] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [toggling, setToggling] = useState<number | null>(null);
|
||||
|
||||
const listQ = useQuery({
|
||||
queryKey: ["admin-bots", page],
|
||||
queryFn: () => http.get<BotList>(`/api/admin/bots?page=${page}&pageSize=${PAGE_SIZE}`),
|
||||
});
|
||||
|
||||
const items = listQ.data?.items ?? [];
|
||||
const total = listQ.data?.total ?? 0;
|
||||
const pageSize = listQ.data?.pageSize ?? PAGE_SIZE;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const pageSafe = Math.min(Math.max(page, 1), totalPages);
|
||||
|
||||
async function copyToken() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(token);
|
||||
toast.success("توکن کپی شد");
|
||||
} catch {
|
||||
toast.error("کپی ممکن نشد");
|
||||
}
|
||||
}
|
||||
|
||||
function setActive(row: BotKey, active: boolean) {
|
||||
setToggling(row.id);
|
||||
void http
|
||||
.put(`/api/admin/bots/${row.id}`, { active })
|
||||
.then(() => {
|
||||
toast.success(active ? "کلید فعال شد" : "کلید متوقف شد");
|
||||
return qc.invalidateQueries({ queryKey: ["admin-bots"] });
|
||||
})
|
||||
.catch((e) => toast.error(toUserError(e, "تغییر وضعیت ممکن نشد")))
|
||||
.finally(() => setToggling(null));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="rounded-2xl">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base">کلید API</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">توکن فقط یک بار نشان داده میشود.</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" asChild>
|
||||
<Link href="/admin/bot-docs">
|
||||
<BookOpen className="h-3.5 w-3.5" />
|
||||
راهنما
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="نام ربات" />
|
||||
<Button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setBusy(true);
|
||||
void http
|
||||
.post<{ token: string }>("/api/admin/bots", { name })
|
||||
.then((res) => {
|
||||
setToken(res.token);
|
||||
setName("");
|
||||
setPage(1);
|
||||
toast.success("ربات ساخته شد");
|
||||
return qc.invalidateQueries({ queryKey: ["admin-bots"] });
|
||||
})
|
||||
.catch((e) => toast.error(toUserError(e, "ساخت ربات ممکن نشد")))
|
||||
.finally(() => setBusy(false));
|
||||
}}
|
||||
>
|
||||
ساخت ربات
|
||||
</Button>
|
||||
{token && (
|
||||
<div className="space-y-2 rounded-2xl border border-border bg-muted/30 p-3">
|
||||
<p className="text-sm text-muted-foreground">توکن فقط یک بار نشان داده میشود.</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input readOnly value={token} dir="ltr" className="font-mono text-xs" aria-label="توکن ربات" />
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => void copyToken()}>
|
||||
کپی
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => setToken("")}>
|
||||
بستن
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">کلیدها</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{listQ.isPending ? (
|
||||
<p className="text-sm text-muted-foreground">در حال بارگذاری…</p>
|
||||
) : listQ.isError ? (
|
||||
<p className="text-sm font-bold text-destructive">{toUserError(listQ.error, "بارگذاری کلیدها ممکن نشد")}</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">هنوز کلیدی نیست.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<ul className="divide-y divide-border">
|
||||
{items.map((row) => (
|
||||
<li key={row.id} className="flex flex-col gap-3 py-4 first:pt-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="truncate text-sm font-bold">{row.name || "ربات"}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatJalaliStamp(row.createdAt)}</p>
|
||||
<p className="text-xs">
|
||||
<span className={row.active ? "font-bold text-success" : "font-bold text-muted-foreground"}>
|
||||
{row.active ? "فعال" : "متوقف"}
|
||||
</span>
|
||||
<span className="text-muted-foreground"> · {formatFaNumber(row.requestCount)} درخواست</span>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
disabled={toggling === row.id}
|
||||
onClick={() => setActive(row, !row.active)}
|
||||
>
|
||||
{row.active ? "توقف" : "فعالکردن"}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border pt-4">
|
||||
<Button type="button" variant="outline" size="sm" disabled={pageSafe <= 1} onClick={() => setPage(pageSafe - 1)}>
|
||||
قبلی
|
||||
</Button>
|
||||
<p className="text-sm font-bold tabular-nums text-muted-foreground">
|
||||
{formatFaNumber(pageSafe)} از {formatFaNumber(totalPages)}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pageSafe >= totalPages}
|
||||
onClick={() => setPage(pageSafe + 1)}
|
||||
>
|
||||
بعدی
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Activity,
|
||||
Bot,
|
||||
BookOpen,
|
||||
CalendarPlus,
|
||||
DatabaseBackup,
|
||||
@@ -33,6 +34,7 @@ export type AdminTabId =
|
||||
| "events"
|
||||
| "challenges"
|
||||
| "announce"
|
||||
| "bots"
|
||||
| "chat-export"
|
||||
| "settings";
|
||||
|
||||
@@ -166,6 +168,13 @@ export const ADMIN_NAV: AdminNavGroup[] = [
|
||||
icon: Megaphone,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "bots",
|
||||
label: "ربات",
|
||||
description: "کلیدهای API، وضعیت، شمار درخواست و راهنمای اتصال.",
|
||||
icon: Bot,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "chat-export",
|
||||
label: "آرشیو گفتگوها",
|
||||
@@ -181,7 +190,7 @@ export const ADMIN_NAV: AdminNavGroup[] = [
|
||||
{
|
||||
id: "settings",
|
||||
label: "تنظیمات سایت",
|
||||
description: "حمایت مالی، گواهینامه فیزیکی و اسپانسرهای لندینگ.",
|
||||
description: "شبکهها، قلبها، گواهینامه فیزیکی و اسپانسرهای لندینگ.",
|
||||
icon: Settings2,
|
||||
adminOnly: true,
|
||||
},
|
||||
|
||||
@@ -45,6 +45,7 @@ export function AdminProfileManage({ userId }: { userId: number }) {
|
||||
|
||||
const account = accountQ.data?.user;
|
||||
const isSelf = account?.id === me?.id;
|
||||
const canDelete = !!account && !isSelf && (account.isFrozen || account.isClosed);
|
||||
|
||||
const act = async (kind: "lock" | "unlock") => {
|
||||
setBusy("lock");
|
||||
@@ -89,7 +90,7 @@ export function AdminProfileManage({ userId }: { userId: number }) {
|
||||
}
|
||||
if (
|
||||
!(await confirm(
|
||||
"حذف کاربر",
|
||||
"حذف دائمی",
|
||||
`حساب «${account.name}» (${account.email}) برای همیشه حذف شود؟ این عمل بازگشتناپذیر است.`,
|
||||
))
|
||||
) {
|
||||
@@ -156,17 +157,17 @@ export function AdminProfileManage({ userId }: { userId: number }) {
|
||||
قفل
|
||||
</Button>
|
||||
))}
|
||||
<div className={cn("grid gap-2", isSelf ? "sm:grid-cols-2" : "sm:grid-cols-3")}>
|
||||
<div className={cn("grid gap-2", canDelete ? "sm:grid-cols-3" : "sm:grid-cols-2")}>
|
||||
<Button size="sm" variant="outline" className="w-full" onClick={() => setEditing(account)}>
|
||||
<Pencil className="h-3.5 w-3.5" /> ویرایش
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="w-full" disabled={busy !== null} onClick={() => setResetOpen(true)}>
|
||||
<KeyRound className="h-3.5 w-3.5" /> ریست رمز
|
||||
</Button>
|
||||
{!isSelf && (
|
||||
{canDelete && (
|
||||
<Button size="sm" variant="destructive" className="w-full" disabled={busy !== null} onClick={del}>
|
||||
{busy === "delete" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
|
||||
حذف
|
||||
حذف دائمی
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { HeartHandshake, Loader2, Package, Save, Settings2, Sparkles } from "lucide-react";
|
||||
import { Heart, Loader2, Package, Save, Settings2, Share2, Sparkles } from "lucide-react";
|
||||
import { http, toUserError } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -15,6 +15,7 @@ import type { AdminTabId } from "@/components/admin/nav";
|
||||
type SettingsMap = Record<string, string>;
|
||||
|
||||
type SponsorDraft = { name: string; url: string; blurb: string };
|
||||
type SocialDraft = { name: string; url: string };
|
||||
|
||||
function SectionHeading({
|
||||
icon: Icon,
|
||||
@@ -44,6 +45,7 @@ export function SiteSettingsPanel({
|
||||
const qc = useQueryClient();
|
||||
const [draft, setDraft] = useState<SettingsMap>({});
|
||||
const [sponsors, setSponsors] = useState<SponsorDraft[]>([]);
|
||||
const [socials, setSocials] = useState<SocialDraft[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
@@ -68,15 +70,17 @@ export function SiteSettingsPanel({
|
||||
} catch {
|
||||
setSponsors([]);
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(data.settings.social_links || "[]") as SocialDraft[];
|
||||
setSocials(Array.isArray(parsed) ? parsed.map((s) => ({ name: s.name ?? "", url: s.url ?? "" })) : []);
|
||||
} catch {
|
||||
setSocials([]);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const set = (key: string, value: string) => setDraft((d) => ({ ...d, [key]: value }));
|
||||
|
||||
const save = async () => {
|
||||
if ((draft.donation_note ?? "").trim().length > 2000) {
|
||||
toast.error("یادداشت دونیت خیلی طولانی است");
|
||||
return;
|
||||
}
|
||||
const price = Number(draft.physical_cert_price_irr);
|
||||
const days = Number(draft.physical_cert_window_days);
|
||||
if (!Number.isFinite(price) || price < 0) {
|
||||
@@ -110,6 +114,9 @@ export function SiteSettingsPanel({
|
||||
try {
|
||||
const settings = {
|
||||
...draft,
|
||||
social_links: JSON.stringify(
|
||||
socials.filter((s) => s.name.trim() && s.url.trim()).map((s) => ({ name: s.name.trim(), url: s.url.trim() })),
|
||||
),
|
||||
sponsors: JSON.stringify(
|
||||
sponsors
|
||||
.filter((s) => s.name.trim())
|
||||
@@ -173,24 +180,60 @@ export function SiteSettingsPanel({
|
||||
|
||||
<section className="space-y-3">
|
||||
<SectionHeading
|
||||
icon={HeartHandshake}
|
||||
title="حمایت / دونیت"
|
||||
hint="متن راهنما برای گفتگو با تیم دربارهٔ حمایت مالی"
|
||||
icon={Share2}
|
||||
title="شبکههای پرگار"
|
||||
hint="حداکثر ۸ لینک رسمی. در فوتر و زبانه شبکهها دیده میشود."
|
||||
/>
|
||||
{socials.map((s, i) => (
|
||||
<div key={i} className="grid gap-2 sm:grid-cols-2">
|
||||
<Input
|
||||
value={s.name}
|
||||
placeholder="نام"
|
||||
onChange={(e) =>
|
||||
setSocials((list) => list.map((row, idx) => (idx === i ? { ...row, name: e.target.value } : row)))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
dir="ltr"
|
||||
value={s.url}
|
||||
placeholder="https://"
|
||||
onChange={(e) =>
|
||||
setSocials((list) => list.map((row, idx) => (idx === i ? { ...row, url: e.target.value } : row)))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{socials.length < 8 && (
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => setSocials((list) => [...list, { name: "", url: "" }])}>
|
||||
افزودن شبکه
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<SectionHeading icon={Heart} title="قلبها" hint="سقف، فاصله بازگشت، و قفل در صفر." />
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={draft.hearts_max ?? "5"}
|
||||
onChange={(e) => set("hearts_max", e.target.value)}
|
||||
aria-label="سقف قلب"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={draft.hearts_regen_minutes ?? "240"}
|
||||
onChange={(e) => set("hearts_regen_minutes", e.target.value)}
|
||||
aria-label="فاصله بازگشت به دقیقه"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center justify-between gap-3 text-sm">
|
||||
<span>فعال باشد</span>
|
||||
<span>صفر قلب حساب را قفل کند</span>
|
||||
<Switch
|
||||
checked={(draft.donation_enabled ?? "true") === "true"}
|
||||
onCheckedChange={(v) => set("donation_enabled", v ? "true" : "false")}
|
||||
aria-label="حمایت مالی"
|
||||
checked={(draft.hearts_lock_on_empty ?? "false") === "true"}
|
||||
onCheckedChange={(v) => set("hearts_lock_on_empty", v ? "true" : "false")}
|
||||
aria-label="قفل در صفر"
|
||||
/>
|
||||
</label>
|
||||
<Textarea
|
||||
className="min-h-[96px]"
|
||||
value={draft.donation_note ?? ""}
|
||||
onChange={(e) => set("donation_note", e.target.value)}
|
||||
placeholder="متن راهنما برای دانشجو"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
|
||||
@@ -11,6 +11,15 @@ import { http } from "@/lib/api";
|
||||
import type { User } from "@/lib/types";
|
||||
import { hasInfiniteHearts } from "@/lib/hearts";
|
||||
|
||||
function SiteFooter() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col items-center justify-between gap-3 px-4 py-8 sm:flex-row sm:px-6">
|
||||
<BrandMark size="sm" logoOnly />
|
||||
<p className="text-xs text-muted-foreground">بوتکمپ ۱۲ هفتهای · توسعهیافته توسط امیرحسین پناهیفر</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isPublic(p: string) {
|
||||
return p === "/" || p === "/login";
|
||||
}
|
||||
@@ -25,6 +34,7 @@ const PROTECTED_PREFIXES = [
|
||||
"/notifications",
|
||||
"/lockout",
|
||||
"/profile",
|
||||
"/settings",
|
||||
];
|
||||
|
||||
function needsAuth(p: string) {
|
||||
@@ -141,12 +151,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
{allowChildren ? children : null}
|
||||
</main>
|
||||
<footer className="border-t border-border/70 bg-muted/20 backdrop-blur">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col items-center justify-between gap-3 px-4 py-8 sm:flex-row sm:px-6">
|
||||
<BrandMark size="sm" logoOnly />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
بوتکمپ ۱۲ هفتهای · توسعهیافته توسط امیرحسین پناهیفر
|
||||
</p>
|
||||
</div>
|
||||
<SiteFooter />
|
||||
</footer>
|
||||
{user && <ChatWidget />}
|
||||
</div>
|
||||
|
||||
@@ -20,12 +20,16 @@ function clamp(v: number, lo: number, hi: number) {
|
||||
export function AvatarCropDialog({
|
||||
file,
|
||||
busy,
|
||||
error,
|
||||
onCancel,
|
||||
onInvalid,
|
||||
onCrop,
|
||||
}: {
|
||||
file: File | null;
|
||||
busy?: boolean;
|
||||
error?: string | null;
|
||||
onCancel: () => void;
|
||||
onInvalid?: () => void;
|
||||
onCrop: (blob: Blob) => Promise<void> | void;
|
||||
}) {
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
@@ -33,25 +37,68 @@ export function AvatarCropDialog({
|
||||
const [C, setC] = useState(280);
|
||||
const [scale, setScale] = useState(1);
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const drag = useRef<{ sx: number; sy: number; px: number; py: number } | null>(null);
|
||||
const onInvalidRef = useRef(onInvalid);
|
||||
onInvalidRef.current = onInvalid;
|
||||
const fittedFor = useRef<HTMLImageElement | null>(null);
|
||||
const fittedSize = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!file) return;
|
||||
if (!file) {
|
||||
setImg(null);
|
||||
return;
|
||||
}
|
||||
let cancel = false;
|
||||
setLocalError(null);
|
||||
const url = URL.createObjectURL(file);
|
||||
const el = new Image();
|
||||
el.onload = () => setImg({ el, w: el.naturalWidth, h: el.naturalHeight });
|
||||
el.onload = () => {
|
||||
if (cancel) return;
|
||||
setImg({ el, w: el.naturalWidth, h: el.naturalHeight });
|
||||
};
|
||||
el.onerror = () => {
|
||||
if (cancel) return;
|
||||
setImg(null);
|
||||
onInvalidRef.current?.();
|
||||
};
|
||||
el.src = url;
|
||||
return () => URL.revokeObjectURL(url);
|
||||
return () => {
|
||||
cancel = true;
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [file]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!img) return;
|
||||
const box = boxRef.current;
|
||||
const size = Math.min(box?.clientWidth ?? 280, 320);
|
||||
setC(size);
|
||||
const minScale = size / Math.min(img.w, img.h);
|
||||
setScale(minScale);
|
||||
setPan({ x: (size - img.w * minScale) / 2, y: (size - img.h * minScale) / 2 });
|
||||
let ro: ResizeObserver | null = null;
|
||||
let frame = 0;
|
||||
let tries = 0;
|
||||
const fit = () => {
|
||||
const box = boxRef.current;
|
||||
if (!box) {
|
||||
if (tries++ < 40) frame = requestAnimationFrame(fit);
|
||||
return;
|
||||
}
|
||||
if (!ro) {
|
||||
ro = new ResizeObserver(fit);
|
||||
ro.observe(box);
|
||||
}
|
||||
const size = Math.round(Math.min(box.clientWidth, 320));
|
||||
if (size < 8) return;
|
||||
setC(size);
|
||||
if (fittedFor.current === img.el && Math.abs(fittedSize.current - size) < 2) return;
|
||||
fittedFor.current = img.el;
|
||||
fittedSize.current = size;
|
||||
const min = size / Math.min(img.w, img.h);
|
||||
setScale(min);
|
||||
setPan({ x: (size - img.w * min) / 2, y: (size - img.h * min) / 2 });
|
||||
};
|
||||
fit();
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
ro?.disconnect();
|
||||
};
|
||||
}, [img]);
|
||||
|
||||
const minScale = img ? C / Math.min(img.w, img.h) : 1;
|
||||
@@ -94,12 +141,13 @@ export function AvatarCropDialog({
|
||||
};
|
||||
|
||||
const confirm = async () => {
|
||||
if (!img) return;
|
||||
if (!img || !(scale > 0) || C < 8) return;
|
||||
const cropSize = C / scale;
|
||||
let cx = -pan.x / scale;
|
||||
let cy = -pan.y / scale;
|
||||
cx = clamp(cx, 0, img.w - cropSize);
|
||||
cy = clamp(cy, 0, img.h - cropSize);
|
||||
if (!(cropSize > 0) || !Number.isFinite(cx) || !Number.isFinite(cy)) return;
|
||||
cx = clamp(cx, 0, Math.max(0, img.w - cropSize));
|
||||
cy = clamp(cy, 0, Math.max(0, img.h - cropSize));
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = OUTPUT_SIZE;
|
||||
@@ -113,10 +161,14 @@ export function AvatarCropDialog({
|
||||
const blob = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob(resolve, "image/png")
|
||||
);
|
||||
if (blob) await onCrop(blob);
|
||||
if (!blob) {
|
||||
setLocalError("ساخت تصویر آواتار ممکن نشد");
|
||||
return;
|
||||
}
|
||||
await onCrop(blob);
|
||||
};
|
||||
|
||||
const zoomPct = Math.round((scale / maxScale) * 100);
|
||||
const zoomPct = maxScale > 0 ? Math.round((scale / maxScale) * 100) : 17;
|
||||
|
||||
return (
|
||||
<Dialog open={!!file && !!img} onOpenChange={(o) => !o && !busy && onCancel()}>
|
||||
@@ -145,10 +197,9 @@ export function AvatarCropDialog({
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
transform: `translate(${pan.x}px, ${pan.y}px) scale(${scale})`,
|
||||
transformOrigin: "0 0",
|
||||
width: img.w,
|
||||
height: img.h,
|
||||
transform: `translate(${pan.x}px, ${pan.y}px)`,
|
||||
width: img.w * scale,
|
||||
height: img.h * scale,
|
||||
maxWidth: "none",
|
||||
maxHeight: "none",
|
||||
}}
|
||||
@@ -184,6 +235,8 @@ export function AvatarCropDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(error || localError) && <p className="text-[11px] font-bold text-destructive">{error || localError}</p>}
|
||||
|
||||
<DialogFooter className="sm:space-x-0 sm:space-x-reverse sm:justify-between">
|
||||
<Button variant="outline" onClick={onCancel} disabled={busy}>
|
||||
<X /> انصراف
|
||||
@@ -192,7 +245,7 @@ export function AvatarCropDialog({
|
||||
<Button variant="ghost" size="sm" onClick={setZoomIn} disabled={busy} title="تنظیم خودکار">
|
||||
<Scan /> برازش
|
||||
</Button>
|
||||
<Button onClick={confirm} disabled={busy || !img}>
|
||||
<Button onClick={confirm} disabled={busy || !img || !(scale > 0) || C < 8}>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check />}
|
||||
{busy ? "در حال آپلود…" : "تأیید و ذخیره"}
|
||||
</Button>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useAuth } from "@/lib/auth-store";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import { apiForm, http, toUserError } from "@/lib/api";
|
||||
import type { User } from "@/lib/types";
|
||||
import { AVATAR_COLORS, AVATAR_VARIANTS, UserAvatar, avatarPaletteOf } from "@/components/ui/user-avatar";
|
||||
import { AVATAR_PALETTES, AVATAR_VARIANTS, UserAvatar, avatarPaletteOf } from "@/components/ui/user-avatar";
|
||||
import { AvatarCropDialog } from "@/components/avatar-crop";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -18,15 +18,6 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PALETTES: { label: string; colors: string[] }[] = [
|
||||
{ label: "پیشفرض", colors: AVATAR_COLORS },
|
||||
{ label: "رزین", colors: ["#0f172a", "#38bdf8", "#f0abfc", "#a5f3fc", "#7c3aed", "#f5d0fe"] },
|
||||
{ label: "صحرایی", colors: ["#1c1917", "#fbbf24", "#fb923c", "#fde68a", "#f97316", "#fed7aa"] },
|
||||
{ label: "اسکاندی", colors: ["#022c22", "#34d399", "#fef3c7", "#a7f3d0", "#166534", "#d1fae5"] },
|
||||
{ label: "نئون", colors: ["#18181b", "#a3e635", "#22d3ee", "#f97316", "#e879f9", "#fb7185"] },
|
||||
{ label: "سلطنتی", colors: ["#20123a", "#9370db", "#e6e6fa", "#6b21a8", "#c084fc", "#f3e8ff"] },
|
||||
];
|
||||
|
||||
export function AvatarStudio({ user, onClose }: { user: User; onClose: () => void }) {
|
||||
const setUser = useAuth((s) => s.setUser);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
@@ -54,13 +45,18 @@ export function AvatarStudio({ user, onClose }: { user: User; onClose: () => voi
|
||||
void queryClient.invalidateQueries({ queryKey: ["leaderboard"] });
|
||||
};
|
||||
|
||||
const publish = (fresh: User) => {
|
||||
queryClient.setQueryData<{ user: User }>(["me"], { user: fresh });
|
||||
setUser(fresh);
|
||||
invalidate();
|
||||
};
|
||||
|
||||
const doSave = async (body: { avatarVariant?: string; avatarPalette?: string; avatarPhoto?: string }) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { user: fresh } = await http.put<{ user: User }>("/api/me/avatar", body);
|
||||
setUser(fresh);
|
||||
invalidate();
|
||||
publish(fresh);
|
||||
} catch (e) {
|
||||
setError(toUserError(e, "خطا در ذخیره"));
|
||||
} finally {
|
||||
@@ -95,8 +91,7 @@ export function AvatarStudio({ user, onClose }: { user: User; onClose: () => voi
|
||||
form.append("file", new File([blob], "avatar.png", { type: "image/png" }), "avatar.png");
|
||||
const { url } = await apiForm<{ url: string }>("/api/me/avatar-photo", form);
|
||||
const { user: fresh } = await http.put<{ user: User }>("/api/me/avatar", { avatarPhoto: url });
|
||||
setUser(fresh);
|
||||
invalidate();
|
||||
publish(fresh);
|
||||
setPending(null);
|
||||
} catch (e) {
|
||||
setError(toUserError(e, "خطا در آپلود"));
|
||||
@@ -106,7 +101,8 @@ export function AvatarStudio({ user, onClose }: { user: User; onClose: () => voi
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pe-1">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative shrink-0">
|
||||
<UserAvatar
|
||||
@@ -162,7 +158,7 @@ export function AvatarStudio({ user, onClose }: { user: User; onClose: () => voi
|
||||
<div>
|
||||
<p className="mb-1.5 text-[11px] font-bold text-muted-foreground">رنگها</p>
|
||||
<div className="space-y-1">
|
||||
{PALETTES.map((p) => {
|
||||
{AVATAR_PALETTES.map((p) => {
|
||||
const active = JSON.stringify(p.colors) === draftP;
|
||||
return (
|
||||
<button
|
||||
@@ -224,8 +220,9 @@ export function AvatarStudio({ user, onClose }: { user: User; onClose: () => voi
|
||||
</div>
|
||||
|
||||
{error && <p className="text-[11px] font-bold text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="sticky -bottom-3 z-10 -mx-2.5 flex items-center justify-between gap-2 bg-popover px-2.5 pb-1 pt-2">
|
||||
<div className="mt-3 flex shrink-0 items-center justify-between gap-2 border-t border-border bg-popover pt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{dirty && (
|
||||
<Button variant="ghost" size="sm" onClick={reset} disabled={busy}>
|
||||
@@ -250,7 +247,16 @@ export function AvatarStudio({ user, onClose }: { user: User; onClose: () => voi
|
||||
<AvatarCropDialog
|
||||
file={pending}
|
||||
busy={busy}
|
||||
onCancel={() => !busy && setPending(null)}
|
||||
error={error}
|
||||
onCancel={() => {
|
||||
if (busy) return;
|
||||
setPending(null);
|
||||
setError(null);
|
||||
}}
|
||||
onInvalid={() => {
|
||||
setPending(null);
|
||||
setError("خواندن این عکس ممکن نشد. JPEG، PNG یا WebP را انتخاب کنید.");
|
||||
}}
|
||||
onCrop={(blob) => onUpload(blob)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check, Loader2 } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AVATAR_PALETTES } from "@/components/ui/user-avatar";
|
||||
import { BANNER_FILLS, BANNER_HEIGHT, BANNER_WIDTH, drawBannerFill, type BannerFill } from "@/lib/banner-pattern";
|
||||
import { toast } from "@/components/providers";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function FillThumb({
|
||||
colors,
|
||||
fill,
|
||||
seed,
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
colors: string[];
|
||||
fill: BannerFill;
|
||||
seed: string;
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLCanvasElement>(null);
|
||||
useEffect(() => {
|
||||
const canvas = ref.current;
|
||||
const ctx = canvas?.getContext("2d");
|
||||
if (!canvas || !ctx) return;
|
||||
drawBannerFill(ctx, canvas.width, canvas.height, colors, fill, seed);
|
||||
}, [colors, fill, seed]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"flex flex-col gap-1 rounded-lg border p-1 text-center transition-colors",
|
||||
active ? "border-primary bg-primary/10 ring-2 ring-primary" : "border-border hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<canvas ref={ref} width={240} height={80} className="h-10 w-full rounded-md" />
|
||||
<span className={cn("text-[10px] font-bold", active ? "text-primary" : "text-muted-foreground")}>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PatternCanvas({
|
||||
canvasRef,
|
||||
colors,
|
||||
seed,
|
||||
fill,
|
||||
onReady,
|
||||
}: {
|
||||
canvasRef: React.RefObject<HTMLCanvasElement | null>;
|
||||
colors: string[];
|
||||
seed: string;
|
||||
fill: BannerFill;
|
||||
onReady: (ready: boolean) => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext("2d");
|
||||
if (!canvas || !ctx) {
|
||||
onReady(false);
|
||||
return;
|
||||
}
|
||||
drawBannerFill(ctx, BANNER_WIDTH, BANNER_HEIGHT, colors, fill, seed);
|
||||
onReady(true);
|
||||
}, [canvasRef, colors, seed, fill, onReady]);
|
||||
|
||||
return <canvas ref={canvasRef} width={BANNER_WIDTH} height={BANNER_HEIGHT} className="absolute inset-0 h-full w-full" />;
|
||||
}
|
||||
|
||||
function HeaderPreview({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="relative aspect-[3/1] w-full overflow-hidden rounded-xl border-2 border-border bg-muted">
|
||||
{children}
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-card via-card/25 to-transparent" />
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute bottom-0 left-1/2 aspect-square w-[18%] -translate-x-1/2 translate-y-1/2 rounded-full bg-muted ring-4 ring-card"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BannerEditor({
|
||||
open,
|
||||
busy,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
onRemove,
|
||||
}: {
|
||||
open: boolean;
|
||||
busy?: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (blob: Blob) => Promise<void>;
|
||||
onRemove?: () => void;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [paletteIndex, setPaletteIndex] = useState(0);
|
||||
const [fill, setFill] = useState<BannerFill>("gradient");
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const palette = AVATAR_PALETTES[paletteIndex] ?? AVATAR_PALETTES[0];
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !ready) return;
|
||||
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
|
||||
if (!blob) {
|
||||
toast.error("ساخت تصویر بنر ممکن نشد");
|
||||
return;
|
||||
}
|
||||
await onSave(blob);
|
||||
} catch {
|
||||
toast.error("ذخیره بنر ممکن نشد");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !busy && onOpenChange(next)}>
|
||||
<DialogContent className="flex max-h-[min(92dvh,44rem)] w-[calc(100%-1.5rem)] max-w-xl flex-col gap-0 overflow-hidden p-0 sm:max-w-xl">
|
||||
<DialogHeader className="shrink-0 space-y-1 px-4 pe-12 pt-4 text-start sm:px-6">
|
||||
<DialogTitle>ویرایش بنر</DialogTitle>
|
||||
<DialogDescription>تصویر عریض پشت نام و آواتار در هدر پروفایل. نسبت حدود ۳ به ۱.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain px-4 py-3 sm:px-6">
|
||||
<div className="space-y-1.5">
|
||||
<HeaderPreview>
|
||||
<PatternCanvas
|
||||
canvasRef={canvasRef}
|
||||
colors={palette.colors}
|
||||
seed={palette.label}
|
||||
fill={fill}
|
||||
onReady={setReady}
|
||||
/>
|
||||
</HeaderPreview>
|
||||
<p className="text-[11px] text-muted-foreground">پیشنمایش هدر — سایه و جای آواتار فقط برای دیدن نتیجهاند و داخل تصویر ذخیره نمیشوند.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1.5 text-[11px] font-bold text-muted-foreground">رنگها</p>
|
||||
<div className="space-y-1">
|
||||
{AVATAR_PALETTES.map((p, i) => {
|
||||
const active = i === paletteIndex;
|
||||
return (
|
||||
<button
|
||||
key={p.label}
|
||||
type="button"
|
||||
onClick={() => setPaletteIndex(i)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-lg border px-2.5 py-1.5 transition-colors",
|
||||
active ? "border-primary bg-primary/10" : "border-border hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className="text-xs font-bold">{p.label}</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
{p.colors.slice(0, 5).map((c) => (
|
||||
<span
|
||||
key={c}
|
||||
className="h-3.5 w-3.5 rounded-full ring-1 ring-black/10 dark:ring-white/10"
|
||||
style={{ background: c }}
|
||||
/>
|
||||
))}
|
||||
{active && <Check className="ms-1 h-3.5 w-3.5 text-primary" />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1.5 text-[11px] font-bold text-muted-foreground">پسزمینه</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{BANNER_FILLS.map((item) => (
|
||||
<FillThumb
|
||||
key={item.id}
|
||||
colors={palette.colors}
|
||||
fill={item.id}
|
||||
seed={palette.label}
|
||||
label={item.label}
|
||||
active={fill === item.id}
|
||||
onClick={() => setFill(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0 gap-2 border-t border-border bg-popover px-4 py-3 sm:flex-row sm:justify-between sm:space-x-0 sm:px-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
|
||||
انصراف
|
||||
</Button>
|
||||
{onRemove && (
|
||||
<Button type="button" variant="outline" onClick={onRemove} disabled={busy}>
|
||||
برداشتن بنر
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button type="button" onClick={() => void save()} disabled={!ready || busy}>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check />}
|
||||
{busy ? "در حال ذخیره…" : "ذخیره بنر"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { HeartHandshake, LogIn, MessageSquareHeart } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ExternalLink, Share2 } from "lucide-react";
|
||||
import { http, toUserError } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth-store";
|
||||
import { Panel } from "@/components/panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export type SocialLink = { name: string; url: string };
|
||||
|
||||
export type CommunityPayload = {
|
||||
donation: {
|
||||
enabled: boolean;
|
||||
note: string;
|
||||
mode: "chat";
|
||||
contactId?: number;
|
||||
draft: string;
|
||||
};
|
||||
socials: SocialLink[];
|
||||
};
|
||||
|
||||
function openDonationChat(contactId: number | undefined, draft: string) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("pargar:chat", {
|
||||
detail: { open: true, with: contactId, draft },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function CommunityPanel() {
|
||||
const user = useAuth((s) => s.user);
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: ["community", user?.id ?? "guest"],
|
||||
queryKey: ["community"],
|
||||
queryFn: () => http.get<CommunityPayload>("/api/community"),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-72 animate-pulse rounded-2xl border-2 border-border bg-muted/40" />
|
||||
);
|
||||
return <div className="h-72 animate-pulse rounded-2xl border-2 border-border bg-muted/40" />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
@@ -50,40 +33,34 @@ export function CommunityPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
const donation = data?.donation;
|
||||
const socials = data?.socials ?? [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<Panel
|
||||
title="حمایت مالی"
|
||||
description="بدون کارت عمومی — اگر لاگین باشید، گفتگو با تیم باز میشود."
|
||||
icon={<HeartHandshake className="h-5 w-5" />}
|
||||
tint="bg-accent/15 text-accent"
|
||||
title="شبکههای پرگار"
|
||||
description="راههای رسمی ارتباط با برنامه. لینک شخصی هر کاربر روی پروفایل خودش است."
|
||||
icon={<Share2 className="h-5 w-5" />}
|
||||
tint="bg-primary/15 text-primary"
|
||||
>
|
||||
{donation?.enabled ? (
|
||||
<div className="space-y-4 pt-1">
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{donation.note}</p>
|
||||
<div className="rounded-2xl border border-border bg-muted/20 p-5 text-sm leading-relaxed">
|
||||
جزئیات واریز فقط در پیام خصوصی رد و بدل میشود؛ اینجا چیزی عمومی نیست.
|
||||
</div>
|
||||
{user ? (
|
||||
<Button
|
||||
className="w-full gap-2"
|
||||
size="lg"
|
||||
onClick={() => openDonationChat(donation.contactId, donation.draft)}
|
||||
>
|
||||
<MessageSquareHeart className="h-4 w-4" /> پیام دربارهٔ دونیت
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild className="w-full gap-2" size="lg">
|
||||
<Link href="/login">
|
||||
<LogIn className="h-4 w-4" /> ورود برای هماهنگی دونیت
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{socials.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">هنوز شبکهای ثبت نشده است.</p>
|
||||
) : (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">حمایت فعلاً غیرفعال است.</p>
|
||||
<ul className="space-y-2 pt-1">
|
||||
{socials.map((s) => (
|
||||
<li key={s.url}>
|
||||
<a
|
||||
href={s.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center justify-between gap-3 rounded-xl border-2 border-border/70 px-3 py-2.5 text-sm font-bold transition-colors hover:border-primary/50 hover:bg-primary/5"
|
||||
>
|
||||
<span>{s.name}</span>
|
||||
<ExternalLink className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Snowflake } from "lucide-react";
|
||||
|
||||
export function isAccountMonochrome(account?: { isFrozen?: boolean; isClosed?: boolean } | null) {
|
||||
return Boolean(account?.isFrozen || account?.isClosed);
|
||||
}
|
||||
|
||||
export function FreezeMark({ closed }: { closed?: boolean }) {
|
||||
const label = closed ? "حساب بسته شده" : "حساب فریز شده";
|
||||
return (
|
||||
<Snowflake
|
||||
aria-hidden={false}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
className="size-[0.85em] shrink-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Search } from "lucide-react";
|
||||
import { http } from "@/lib/api";
|
||||
|
||||
type Hit = { kind: string; title: string; href: string };
|
||||
|
||||
export function GlobalSearch() {
|
||||
const router = useRouter();
|
||||
const [q, setQ] = useState("");
|
||||
const [hits, setHits] = useState<Hit[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const query = q.trim();
|
||||
if (query.length < 2) {
|
||||
setHits([]);
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => {
|
||||
void http
|
||||
.get<{ results: Hit[] }>(`/api/search?q=${encodeURIComponent(query)}`)
|
||||
.then((res) => setHits(res.results ?? []))
|
||||
.catch(() => setHits([]));
|
||||
}, 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [q]);
|
||||
|
||||
return (
|
||||
<div className="relative min-w-0 w-28 sm:w-56">
|
||||
<Search className="pointer-events-none absolute start-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
placeholder="جستجو"
|
||||
className="h-9 w-full rounded-full border border-border bg-background/70 ps-8 pe-3 text-sm outline-none focus:border-primary"
|
||||
/>
|
||||
{open && hits.length > 0 && (
|
||||
<div className="absolute z-50 mt-1 w-full overflow-hidden rounded-xl border bg-popover shadow-lg">
|
||||
{hits.map((h) => (
|
||||
<button
|
||||
key={h.kind + h.href + h.title}
|
||||
type="button"
|
||||
className="block w-full px-3 py-2 text-start text-sm hover:bg-muted"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setQ("");
|
||||
router.push(h.href);
|
||||
}}
|
||||
>
|
||||
<span className="text-[10px] text-muted-foreground">{h.kind}</span>
|
||||
<span className="block truncate font-bold">{h.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+2103
-316
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,8 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn, formatFaNumber } from "@/lib/utils";
|
||||
import { GlobalSearch } from "@/components/global-search";
|
||||
import { isVerifiedRole, VerifiedBadge } from "@/components/verified-badge";
|
||||
|
||||
const navLinks = [
|
||||
{ href: "/cap", label: "درخت مهارت" },
|
||||
@@ -63,6 +65,7 @@ export function Navbar() {
|
||||
<BrandMark />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<GlobalSearch />
|
||||
<ThemeToggle />
|
||||
{pathname !== "/login" && (
|
||||
<Button asChild size="sm" className="font-bold">
|
||||
@@ -173,7 +176,10 @@ export function Navbar() {
|
||||
className="h-11 w-11"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-extrabold">{liveUser.name}</p>
|
||||
<p className="flex min-w-0 items-center gap-1 text-sm font-extrabold">
|
||||
<span className="truncate">{liveUser.name}</span>
|
||||
{isVerifiedRole(liveUser.role) && <VerifiedBadge role={liveUser.role} />}
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground">{liveUser.email}</p>
|
||||
<p className="text-[11px] text-primary">{roleLabel(liveUser.role)}</p>
|
||||
</div>
|
||||
@@ -181,6 +187,9 @@ export function Navbar() {
|
||||
<DropdownMenuItem onClick={() => router.push("/profile")}>
|
||||
<User /> پروفایل من
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push("/settings")}>
|
||||
<User /> تنظیمات
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setStudioOpen(true)}>
|
||||
<User /> ویرایش آواتار
|
||||
</DropdownMenuItem>
|
||||
@@ -200,8 +209,8 @@ export function Navbar() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Dialog open={studioOpen} onOpenChange={setStudioOpen}>
|
||||
<DialogContent className="max-h-[85vh] max-w-sm gap-3 overflow-y-auto chat-scroll rounded-2xl">
|
||||
<DialogHeader>
|
||||
<DialogContent className="flex max-h-[min(85vh,40rem)] max-w-sm flex-col gap-3 overflow-hidden rounded-2xl">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle className="text-center">ویرایش آواتار</DialogTitle>
|
||||
</DialogHeader>
|
||||
<AvatarStudio user={liveUser} onClose={() => setStudioOpen(false)} />
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import Link from "next/link";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { SearchX } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const PERSIAN_LETTER = /[\u0600-\u06FF]/;
|
||||
|
||||
/** Keep a short Persian sentence. Drop blank, English, trace-like text, and a copy of the title. */
|
||||
export function notFoundSentence(
|
||||
candidate: string | null | undefined,
|
||||
fallback: string,
|
||||
title?: string,
|
||||
): string {
|
||||
const msg = candidate?.replace(/\s+/g, " ").trim() ?? "";
|
||||
if (!msg || msg.length > 180 || !PERSIAN_LETTER.test(msg)) return fallback;
|
||||
if (title && msg === title.trim()) return fallback;
|
||||
if (/stack trace|uncaught|typeerror|referenceerror|at \S+\(/i.test(msg)) return fallback;
|
||||
return msg;
|
||||
}
|
||||
|
||||
export function NotFoundState({
|
||||
title,
|
||||
description,
|
||||
href,
|
||||
actionLabel,
|
||||
icon: Icon = SearchX,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
actionLabel: string;
|
||||
icon?: LucideIcon;
|
||||
}) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-lg py-6 sm:py-10">
|
||||
<div className="rounded-2xl border border-border bg-card px-6 py-12 text-center sm:px-10 sm:py-16">
|
||||
<div className="mx-auto grid h-14 w-14 place-items-center rounded-2xl bg-muted text-muted-foreground">
|
||||
<Icon className="h-6 w-6" strokeWidth={1.75} aria-hidden />
|
||||
</div>
|
||||
<h1 className="mt-6 text-xl font-black tracking-tight sm:text-2xl">{title}</h1>
|
||||
<p className="mx-auto mt-3 max-w-sm text-sm leading-relaxed text-muted-foreground">{description}</p>
|
||||
<Button asChild className="mt-8">
|
||||
<Link href={href}>{actionLabel}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,11 +11,11 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { http } from "@/lib/api";
|
||||
import type { NotificationItem } from "@/lib/types";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SafeMarkdown } from "@/components/safe-markdown";
|
||||
|
||||
export function NotificationBell() {
|
||||
const router = useRouter();
|
||||
@@ -32,11 +32,15 @@ export function NotificationBell() {
|
||||
qc.invalidateQueries({ queryKey: ["me"] });
|
||||
};
|
||||
|
||||
const categoryDot: Record<string, string> = {
|
||||
progress: "bg-primary",
|
||||
gamification: "bg-gold",
|
||||
mentor: "bg-accent",
|
||||
event: "bg-secondary",
|
||||
const categoryMeta: Record<string, { label: string; dot: string }> = {
|
||||
progress: { label: "پیشرفت", dot: "bg-primary" },
|
||||
gamification: { label: "بازیسازی", dot: "bg-gold" },
|
||||
mentor: { label: "منتور", dot: "bg-accent" },
|
||||
event: { label: "رویداد", dot: "bg-secondary" },
|
||||
urgent: { label: "فوری", dot: "bg-destructive" },
|
||||
curriculum: { label: "درس", dot: "bg-primary" },
|
||||
community: { label: "جامعه", dot: "bg-accent" },
|
||||
system: { label: "سیستم", dot: "bg-muted-foreground" },
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -51,67 +55,89 @@ export function NotificationBell() {
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80 p-0">
|
||||
<div className="flex items-center justify-between rounded-t-md border-b border-border bg-muted/40 px-3 py-2.5">
|
||||
<span className="text-sm font-semibold">اعلانها</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{unread > 0 && <Badge variant="destructive" className="px-2 py-0">{unread} جدید</Badge>}
|
||||
<Button variant="ghost" size="sm" onClick={markAll} className="h-7 gap-1 px-2 text-xs">
|
||||
<CheckCheck className="h-3.5 w-3.5" /> خواندن همه
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
side="bottom"
|
||||
collisionPadding={12}
|
||||
className="w-[min(22rem,calc(100vw-1.25rem))] overflow-hidden p-0"
|
||||
>
|
||||
<div dir="rtl" className="text-start">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold">اعلانها</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{unread > 0 ? `${unread} خواندهنشده` : "همه خوانده شده"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={markAll}
|
||||
disabled={unread === 0}
|
||||
className="h-8 w-8 shrink-0"
|
||||
aria-label="خواندن همه"
|
||||
title="خواندن همه"
|
||||
>
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[360px] overflow-y-auto">
|
||||
{isLoading && (
|
||||
<div className="space-y-3 px-3 py-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-start gap-3">
|
||||
<Skeleton className="mt-1.5 h-2 w-2 rounded-full" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<div className="max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto">
|
||||
{isLoading && (
|
||||
<div className="space-y-3 px-3 py-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-1/3" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && (!data?.notifications || data.notifications.length === 0) && (
|
||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
اعلانی ندارید.
|
||||
<br />
|
||||
<span className="text-xs">با قبولی در یک آزمون یا ثبتنام در رویداد، اعلان میگیرید.</span>
|
||||
</div>
|
||||
)}
|
||||
{data?.notifications.map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => {
|
||||
if (n.route) router.push(n.route);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-3 border-b border-border px-3 py-3 text-right transition-colors hover:bg-muted/60",
|
||||
!n.readAt && "bg-primary/5"
|
||||
)}
|
||||
>
|
||||
<span className={cn("mt-1.5 h-2 w-2 shrink-0 rounded-full", categoryDot[n.category] ?? "bg-primary")} />
|
||||
<span className="flex-1 space-y-0.5">
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">{n.title}</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(n.createdAt), { addSuffix: true, locale: faIR })}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && (!data?.notifications || data.notifications.length === 0) && (
|
||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
اعلانی ندارید.
|
||||
<br />
|
||||
<span className="text-xs">با قبولی در یک آزمون یا ثبتنام در رویداد، اعلان میگیرید.</span>
|
||||
</div>
|
||||
)}
|
||||
{data?.notifications.map((n) => {
|
||||
const cat = categoryMeta[n.category] ?? { label: "اعلان", dot: "bg-primary" };
|
||||
return (
|
||||
<button
|
||||
key={n.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (n.route) router.push(n.route);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-2.5 border-b border-border px-3 py-2.5 text-start transition-colors last:border-b-0 hover:bg-muted/60",
|
||||
!n.readAt && "bg-primary/[0.06]",
|
||||
)}
|
||||
>
|
||||
<span className={cn("mt-1.5 h-2 w-2 shrink-0 rounded-full", n.readAt ? "bg-border" : cat.dot)} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span dir="auto" className="block truncate text-sm font-medium [unicode-bidi:plaintext]">
|
||||
{n.title}
|
||||
</span>
|
||||
{n.body && <SafeMarkdown text={n.body} lines={2} className="mt-0.5 text-xs text-muted-foreground" />}
|
||||
<span className="mt-1 flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span>{cat.label}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{formatDistanceToNow(new Date(n.createdAt), { addSuffix: true, locale: faIR })}</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
{n.body && <span className="block text-xs text-muted-foreground">{n.body}</span>}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/notifications")}
|
||||
className="w-full border-t border-border bg-muted/40 py-2.5 text-xs font-semibold text-primary hover:bg-muted"
|
||||
>
|
||||
مشاهده همه اعلانها
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push("/notifications")}
|
||||
className="w-full rounded-b-md bg-muted/40 py-2.5 text-xs font-semibold text-primary hover:bg-muted"
|
||||
>
|
||||
مشاهده همه اعلانها
|
||||
</button>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@@ -8,10 +8,10 @@ import { faIR } from "date-fns/locale";
|
||||
import { http } from "@/lib/api";
|
||||
import type { NotificationItem } from "@/lib/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SafeMarkdown } from "@/components/safe-markdown";
|
||||
|
||||
const categoryMap: Record<
|
||||
string,
|
||||
@@ -21,6 +21,10 @@ const categoryMap: Record<
|
||||
gamification: { label: "بازیسازی", dot: "bg-gold", classes: "bg-gold/15 text-gold" },
|
||||
mentor: { label: "منتور", dot: "bg-accent", classes: "bg-accent/15 text-accent" },
|
||||
event: { label: "رویداد", dot: "bg-secondary", classes: "bg-secondary/15 text-secondary" },
|
||||
urgent: { label: "فوری", dot: "bg-destructive", classes: "bg-destructive/10 text-destructive" },
|
||||
curriculum: { label: "درس", dot: "bg-primary", classes: "bg-primary/10 text-primary" },
|
||||
community: { label: "جامعه", dot: "bg-accent", classes: "bg-accent/15 text-accent" },
|
||||
system: { label: "سیستم", dot: "bg-muted-foreground", classes: "bg-muted text-foreground" },
|
||||
};
|
||||
|
||||
export function NotificationsPanel() {
|
||||
@@ -82,17 +86,15 @@ export function NotificationsPanel() {
|
||||
!n.readAt ? "border-primary/30 bg-primary/5" : "border-border bg-card"
|
||||
)}
|
||||
>
|
||||
<span className={cn("mt-2 h-2.5 w-2.5 shrink-0 rounded-full", cat.dot)} />
|
||||
<span className="flex-1 space-y-1">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{n.title}</span>
|
||||
<Badge variant="outline" className={cn("px-1.5 py-0 text-[10px] rounded-lg", cat.classes)}>
|
||||
{cat.label}
|
||||
</Badge>
|
||||
<span className={cn("mt-2 h-2 w-2 shrink-0 rounded-full", n.readAt ? "bg-border" : cat.dot)} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span dir="auto" className="block truncate font-medium [unicode-bidi:plaintext]">
|
||||
{n.title}
|
||||
</span>
|
||||
{n.body && <span className="block text-sm text-muted-foreground">{n.body}</span>}
|
||||
<span className="block text-[11px] text-muted-foreground/70">
|
||||
{formatDistanceToNow(new Date(n.createdAt), { addSuffix: true, locale: faIR })}
|
||||
{n.body && <SafeMarkdown text={n.body} lines={2} className="mt-0.5 text-sm text-muted-foreground" />}
|
||||
<span className="mt-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span className={cn("rounded-md px-1.5 py-0.5", cat.classes)}>{cat.label}</span>
|
||||
<span>{formatDistanceToNow(new Date(n.createdAt), { addSuffix: true, locale: faIR })}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { BannerEditor } from "@/components/banner-editor";
|
||||
import { FreezeMark, isAccountMonochrome } from "@/components/freeze-mark";
|
||||
import { toast } from "@/components/providers";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UserAvatar, avatarPropsOf, type AvatarVariantName } from "@/components/ui/user-avatar";
|
||||
import { isVerifiedRole, VerifiedBadge } from "@/components/verified-badge";
|
||||
import { apiForm, http, toUserError } from "@/lib/api";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import type { User } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ProfileHeaderModel = {
|
||||
name: string;
|
||||
role?: string | null;
|
||||
banner?: string;
|
||||
createdAt?: string;
|
||||
avatarVariant?: AvatarVariantName;
|
||||
avatarPalette?: string;
|
||||
avatarPhoto?: string;
|
||||
isFrozen?: boolean;
|
||||
isClosed?: boolean;
|
||||
};
|
||||
|
||||
export function membershipLine(createdAt?: string) {
|
||||
if (!createdAt) return "";
|
||||
const date = new Date(createdAt);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return `عضویت از ${date.toLocaleDateString("fa-IR", { year: "numeric", month: "long", day: "numeric" })}`;
|
||||
}
|
||||
|
||||
function useHoverBanner() {
|
||||
const [on, setOn] = useState(false);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(hover: hover) and (min-width: 640px)");
|
||||
const sync = () => setOn(mq.matches);
|
||||
sync();
|
||||
mq.addEventListener("change", sync);
|
||||
return () => mq.removeEventListener("change", sync);
|
||||
}, []);
|
||||
return on;
|
||||
}
|
||||
|
||||
/** Same header card on the owner profile, a public profile, and anyone opened from the league. */
|
||||
export function ProfileHeader({
|
||||
person,
|
||||
secondary,
|
||||
editable = false,
|
||||
bannerBusy = false,
|
||||
onEditBanner,
|
||||
onRemoveBanner,
|
||||
onEditAvatar,
|
||||
children,
|
||||
}: {
|
||||
person: ProfileHeaderModel;
|
||||
/** When set, replaces the membership line. Owner and public profiles leave this empty. */
|
||||
secondary?: string;
|
||||
editable?: boolean;
|
||||
bannerBusy?: boolean;
|
||||
onEditBanner?: () => void;
|
||||
onRemoveBanner?: () => void;
|
||||
onEditAvatar?: () => void;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const [bannerHot, setBannerHot] = useState(false);
|
||||
const hoverBanner = useHoverBanner();
|
||||
const hideBanner = () => setBannerHot(false);
|
||||
const banner = person.banner ?? "";
|
||||
const bannerLabel = banner ? "جایگزینی بنر" : "گذاشتن بنر";
|
||||
const line = secondary ?? membershipLine(person.createdAt);
|
||||
const canEditBanner = editable && !!onEditBanner;
|
||||
|
||||
return (
|
||||
<header className={cn("relative overflow-hidden rounded-2xl border border-border bg-card", isAccountMonochrome(person) && "[filter:grayscale(1)]")}>
|
||||
<div className="pointer-events-none absolute inset-0" aria-hidden>
|
||||
{banner ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={banner} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-accent/20 to-muted" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-card from-20% via-card/50 via-45% to-transparent" />
|
||||
</div>
|
||||
{canEditBanner && hoverBanner ? (
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 z-10 h-44"
|
||||
onPointerEnter={() => setBannerHot(true)}
|
||||
onPointerLeave={hideBanner}
|
||||
>
|
||||
<div
|
||||
className={`absolute inset-0 bg-background/45 backdrop-blur-[2px] transition-opacity ${
|
||||
bannerHot ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 px-3">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-card px-3 py-1.5 text-xs font-bold text-foreground shadow-sm"
|
||||
disabled={bannerBusy}
|
||||
onClick={onEditBanner}
|
||||
>
|
||||
{bannerLabel}
|
||||
</button>
|
||||
{banner && onRemoveBanner && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-card px-3 py-1.5 text-xs font-bold text-foreground shadow-sm disabled:opacity-50"
|
||||
disabled={bannerBusy}
|
||||
onClick={onRemoveBanner}
|
||||
>
|
||||
برداشتن بنر
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : canEditBanner ? (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 z-10"
|
||||
aria-label={bannerLabel}
|
||||
onClick={onEditBanner}
|
||||
/>
|
||||
) : null}
|
||||
{editable && (
|
||||
<div className="absolute end-3 top-3 z-30">
|
||||
<Button type="button" variant="outline" size="sm" className="h-7 border-border/70 bg-card/80 px-2.5 text-[11px] shadow-none backdrop-blur" asChild>
|
||||
<Link href="/settings">تنظیمات</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="pointer-events-none relative z-20 h-44" />
|
||||
<div className="pointer-events-none relative z-20 -mt-12 flex items-end justify-start gap-4 px-5 pb-5">
|
||||
{editable && onEditAvatar ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditAvatar}
|
||||
onPointerEnter={hideBanner}
|
||||
aria-label="ویرایش آواتار"
|
||||
className="group/avatar pointer-events-auto relative z-20 h-24 w-24 shrink-0 rounded-full"
|
||||
>
|
||||
<UserAvatar name={person.name} className="h-24 w-24 ring-4 ring-card" {...avatarPropsOf(person)} />
|
||||
<span
|
||||
aria-hidden
|
||||
className={`pointer-events-none absolute inset-0 grid place-items-center rounded-full bg-background/60 px-1 text-center text-[10px] font-bold leading-tight ring-4 ring-card transition-opacity ${
|
||||
hoverBanner
|
||||
? "opacity-0 group-hover/avatar:opacity-100 group-focus-visible/avatar:opacity-100"
|
||||
: "opacity-0"
|
||||
}`}
|
||||
>
|
||||
ویرایش آواتار
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<UserAvatar name={person.name} className="pointer-events-auto h-24 w-24 shrink-0 ring-4 ring-card" {...avatarPropsOf(person)} />
|
||||
)}
|
||||
<div
|
||||
className="pointer-events-auto flex w-fit max-w-full min-w-0 flex-col items-start gap-1"
|
||||
onPointerEnter={(event) => {
|
||||
event.stopPropagation();
|
||||
hideBanner();
|
||||
}}
|
||||
>
|
||||
<h1 className="flex max-w-full items-center gap-1 text-2xl font-black leading-tight tracking-tight">
|
||||
<span className="min-w-0 truncate">{person.name}</span>
|
||||
{isVerifiedRole(person.role) && <VerifiedBadge role={person.role} />}
|
||||
{isAccountMonochrome(person) && <FreezeMark closed={person.isClosed} />}
|
||||
</h1>
|
||||
{line ? <p className="w-fit max-w-full text-sm text-muted-foreground">{line}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function OwnerProfileHeader({ user, onEditAvatar }: { user: User; onEditAvatar: () => void }) {
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const profileQ = useQuery({
|
||||
queryKey: ["me-profile"],
|
||||
queryFn: () => http.get<{ profile: { banner: string } }>("/api/me/profile"),
|
||||
});
|
||||
const banner = profileQ.data?.profile.banner ?? "";
|
||||
|
||||
const upload = async (blob: Blob) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", new File([blob], "banner.png", { type: "image/png" }), "banner.png");
|
||||
const { url } = await apiForm<{ url: string }>("/api/me/avatar-photo", form);
|
||||
await http.put("/api/me/profile", { banner: url });
|
||||
await queryClient.invalidateQueries({ queryKey: ["me-profile"] });
|
||||
toast.success("بنر ذخیره شد");
|
||||
return true;
|
||||
} catch (e) {
|
||||
toast.error(toUserError(e, "ذخیره بنر ممکن نشد"));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeBanner = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await http.put("/api/me/profile", { banner: "" });
|
||||
await queryClient.invalidateQueries({ queryKey: ["me-profile"] });
|
||||
setEditorOpen(false);
|
||||
} catch (e) {
|
||||
toast.error(toUserError(e, "برداشتن بنر ممکن نشد"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ProfileHeader
|
||||
person={{
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
banner,
|
||||
createdAt: user.createdAt,
|
||||
avatarVariant: user.avatarVariant,
|
||||
avatarPalette: user.avatarPalette,
|
||||
avatarPhoto: user.avatarPhoto,
|
||||
isFrozen: user.isFrozen,
|
||||
isClosed: user.isClosed,
|
||||
}}
|
||||
editable
|
||||
bannerBusy={busy}
|
||||
onEditBanner={() => setEditorOpen(true)}
|
||||
onRemoveBanner={banner ? () => void removeBanner() : undefined}
|
||||
onEditAvatar={onEditAvatar}
|
||||
>
|
||||
<BannerEditor
|
||||
open={editorOpen}
|
||||
busy={busy}
|
||||
onOpenChange={setEditorOpen}
|
||||
onRemove={banner ? () => void removeBanner() : undefined}
|
||||
onSave={async (blob) => {
|
||||
const ok = await upload(blob);
|
||||
if (ok) setEditorOpen(false);
|
||||
}}
|
||||
/>
|
||||
</ProfileHeader>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,14 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
<MotionConfig reducedMotion="user" transition={softTween}>
|
||||
<ErrorBoundary area="app">{children}</ErrorBoundary>
|
||||
</MotionConfig>
|
||||
<Toaster position="top-center" richColors closeButton />
|
||||
<Toaster
|
||||
position="top-center"
|
||||
richColors
|
||||
offset={76}
|
||||
mobileOffset={{ top: "4.25rem", left: "0.75rem", right: "0.75rem" }}
|
||||
closeButton
|
||||
toastOptions={{ duration: 4000 }}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
Sparkles,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { api, http, toUserError } from "@/lib/api";
|
||||
import { NotFoundState, notFoundSentence } from "@/components/not-found-state";
|
||||
import { ApiError, api, http, toUserError } from "@/lib/api";
|
||||
import type { QuizData, QuizResult } from "@/lib/types";
|
||||
import { HeartsMeter } from "@/components/hearts-meter";
|
||||
import { useLiveUser } from "@/hooks/use-live-user";
|
||||
@@ -36,9 +37,11 @@ export function QuizExperience({ lessonId }: { lessonId: number }) {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const idOk = Number.isFinite(lessonId) && lessonId > 0;
|
||||
const { data, isLoading, isError, error: loadError } = useQuery({
|
||||
queryKey: ["quiz", lessonId],
|
||||
queryFn: () => http.get<QuizData>(`/api/lessons/${lessonId}/quiz`),
|
||||
enabled: idOk,
|
||||
retry: false,
|
||||
meta: { silentError: true },
|
||||
});
|
||||
@@ -73,6 +76,21 @@ export function QuizExperience({ lessonId }: { lessonId: number }) {
|
||||
}
|
||||
};
|
||||
|
||||
if (!idOk || (loadError instanceof ApiError && loadError.status === 404)) {
|
||||
return (
|
||||
<NotFoundState
|
||||
title="آزمون پیدا نشد"
|
||||
description={notFoundSentence(
|
||||
loadError ? toUserError(loadError, "") : "",
|
||||
"این آزمون در سامانه نیست.",
|
||||
"آزمون پیدا نشد",
|
||||
)}
|
||||
href="/cap"
|
||||
actionLabel="بازگشت به درخت مهارت"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center">
|
||||
|
||||
@@ -5,7 +5,7 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft, ArrowRight, Loader2, UserPlus } from "lucide-react";
|
||||
import { useAuth } from "@/lib/auth-store";
|
||||
import { http, toUserError } from "@/lib/api";
|
||||
import { ApiError, http, toUserError } from "@/lib/api";
|
||||
import type { User } from "@/lib/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -72,9 +72,11 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
|
||||
const [captchaKey, setCaptchaKey] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [captchaError, setCaptchaError] = useState<string | null>(null);
|
||||
|
||||
const goNext = () => {
|
||||
setError(null);
|
||||
setCaptchaError(null);
|
||||
if (step === 1) {
|
||||
const nErr = validateName(name);
|
||||
if (nErr) {
|
||||
@@ -111,6 +113,7 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
|
||||
|
||||
const goBack = () => {
|
||||
setError(null);
|
||||
setCaptchaError(null);
|
||||
setStep((s) => Math.max(1, s - 1));
|
||||
};
|
||||
|
||||
@@ -127,7 +130,7 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
|
||||
return;
|
||||
}
|
||||
if (!challenge) {
|
||||
setError("ابتدا کد امنیتی را بارگذاری کنید");
|
||||
setCaptchaError("ابتدا کد امنیتی را بارگذاری کنید");
|
||||
setCaptchaKey((k) => k + 1);
|
||||
return;
|
||||
}
|
||||
@@ -152,7 +155,9 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
|
||||
router.push("/cap");
|
||||
} catch (err) {
|
||||
setError(toUserError(err, "ثبتنام ناموفق بود"));
|
||||
setCaptchaKey((k) => k + 1);
|
||||
if (!(err instanceof ApiError) || err.status !== 429) {
|
||||
setCaptchaKey((k) => k + 1);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -304,12 +309,16 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
|
||||
value={captchaAnswer}
|
||||
onChange={setCaptchaAnswer}
|
||||
onChallenge={setChallenge}
|
||||
onError={setError}
|
||||
onError={setCaptchaError}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
{(error || captchaError) && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{error || captchaError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-stretch gap-2.5 pt-1">
|
||||
{step > 1 && (
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Renders a small markdown subset. Raw HTML is escaped by React text nodes. */
|
||||
export function SafeMarkdown({
|
||||
text,
|
||||
className,
|
||||
lines,
|
||||
}: {
|
||||
text: string;
|
||||
className?: string;
|
||||
/** When set, only the first block is shown, clamped to this many lines. */
|
||||
lines?: 2 | 3;
|
||||
}) {
|
||||
const blocks = text.replace(/\r\n/g, "\n").split(/\n{2,}/);
|
||||
const shown = lines ? blocks.slice(0, 1) : blocks;
|
||||
return (
|
||||
<div className={className}>
|
||||
{shown.map((block, i) => (
|
||||
<p
|
||||
key={i}
|
||||
dir="auto"
|
||||
className={cn(
|
||||
"break-words [unicode-bidi:plaintext]",
|
||||
lines === 2 && "line-clamp-2",
|
||||
lines === 3 && "line-clamp-3",
|
||||
!lines && "whitespace-pre-wrap",
|
||||
)}
|
||||
>
|
||||
{inline(block)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function inline(src: string): ReactNode[] {
|
||||
const re = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|\*\*([^*]+)\*\*|`([^`]+)`/g;
|
||||
const nodes: ReactNode[] = [];
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
let k = 0;
|
||||
while ((m = re.exec(src))) {
|
||||
if (m.index > last) nodes.push(src.slice(last, m.index));
|
||||
if (m[1] && m[2]) {
|
||||
nodes.push(
|
||||
<a key={k++} href={m[2]} target="_blank" rel="noreferrer" className="underline">
|
||||
{m[1]}
|
||||
</a>,
|
||||
);
|
||||
} else if (m[3]) {
|
||||
nodes.push(<strong key={k++}>{m[3]}</strong>);
|
||||
} else if (m[4]) {
|
||||
nodes.push(
|
||||
<code key={k++} className="rounded bg-muted px-1">
|
||||
{m[4]}
|
||||
</code>,
|
||||
);
|
||||
}
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last < src.length) nodes.push(src.slice(last));
|
||||
return nodes;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type SectionNavItem<T extends string = string> = {
|
||||
id: T;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type SectionNavGroup<T extends string = string> = {
|
||||
title?: string;
|
||||
items: SectionNavItem<T>[];
|
||||
};
|
||||
|
||||
const DESKTOP_STICKY_TOP = 80;
|
||||
|
||||
export function SectionColumns({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="sm:grid sm:grid-cols-[15rem_minmax(0,1fr)] sm:items-start sm:gap-8">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionNav<T extends string>({
|
||||
label,
|
||||
groups,
|
||||
activeId,
|
||||
onSelect,
|
||||
}: {
|
||||
label: string;
|
||||
groups: SectionNavGroup<T>[];
|
||||
activeId: T;
|
||||
onSelect: (id: T) => void;
|
||||
}) {
|
||||
const navRef = useRef<HTMLElement>(null);
|
||||
const [stick, setStick] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = navRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
const desktop = window.matchMedia("(min-width: 640px)").matches;
|
||||
setStick(desktop && el.offsetHeight <= window.innerHeight - DESKTOP_STICKY_TOP);
|
||||
};
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(el);
|
||||
window.addEventListener("resize", measure);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener("resize", measure);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<nav
|
||||
ref={navRef}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"z-20 -mx-1 mb-4 flex gap-2 bg-background/95 px-1 py-2 backdrop-blur max-sm:sticky max-sm:top-16 max-sm:overflow-x-auto",
|
||||
"sm:z-10 sm:mx-0 sm:mb-0 sm:flex-col sm:gap-3 sm:self-start sm:overflow-visible sm:rounded-2xl sm:border-2 sm:border-border sm:bg-card sm:p-2 sm:backdrop-blur-none",
|
||||
stick && "sm:sticky sm:top-20",
|
||||
)}
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<div
|
||||
key={group.title ?? group.items[0]?.id ?? "group"}
|
||||
className="contents sm:flex sm:flex-col sm:gap-1"
|
||||
>
|
||||
{group.title ? (
|
||||
<p className="hidden px-3 pt-2 text-sm font-bold text-muted-foreground sm:block">{group.title}</p>
|
||||
) : null}
|
||||
{group.items.map((item) => {
|
||||
const selected = activeId === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
aria-current={selected ? "true" : undefined}
|
||||
onClick={() => onSelect(item.id)}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full border px-3.5 py-1.5 text-sm font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
"sm:w-full sm:rounded-xl sm:px-3 sm:py-2 sm:text-start",
|
||||
selected
|
||||
? "border-primary bg-primary text-primary-foreground sm:border-transparent sm:bg-primary/10 sm:text-primary"
|
||||
: "border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground sm:border-transparent sm:bg-transparent",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionPanel({
|
||||
id,
|
||||
titleId,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
id?: string;
|
||||
titleId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section id={id} aria-labelledby={titleId} className="min-w-0 rounded-2xl border-2 border-border bg-card">
|
||||
<header className="border-b border-border/70 px-5 py-4 sm:px-6">
|
||||
<h2 id={titleId} className="text-lg font-black">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
</header>
|
||||
<div className="p-5 sm:p-6">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -40,4 +40,31 @@ const DropdownMenuItem = React.forwardRef<
|
||||
));
|
||||
DropdownMenuItem.displayName = "DropdownMenuItem";
|
||||
|
||||
export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem };
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2.5 py-1.5 text-[11px] font-bold text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = "DropdownMenuLabel";
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />
|
||||
));
|
||||
DropdownMenuSeparator.displayName = "DropdownMenuSeparator";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
};
|
||||
@@ -19,6 +19,16 @@ export const AVATAR_VARIANTS: { name: AvatarVariantName; label: string }[] = [
|
||||
{ name: "bauhaus", label: "باهاوس" },
|
||||
];
|
||||
|
||||
/** Same palettes the avatar studio offers, reused for the wide profile banner. */
|
||||
export const AVATAR_PALETTES: { label: string; colors: string[] }[] = [
|
||||
{ label: "پیشفرض", colors: AVATAR_COLORS },
|
||||
{ label: "رزین", colors: ["#0f172a", "#38bdf8", "#f0abfc", "#a5f3fc", "#7c3aed", "#f5d0fe"] },
|
||||
{ label: "صحرایی", colors: ["#1c1917", "#fbbf24", "#fb923c", "#fde68a", "#f97316", "#fed7aa"] },
|
||||
{ label: "اسکاندی", colors: ["#022c22", "#34d399", "#fef3c7", "#a7f3d0", "#166534", "#d1fae5"] },
|
||||
{ label: "نئون", colors: ["#18181b", "#a3e635", "#22d3ee", "#f97316", "#e879f9", "#fb7185"] },
|
||||
{ label: "سلطنتی", colors: ["#20123a", "#9370db", "#e6e6fa", "#6b21a8", "#c084fc", "#f3e8ff"] },
|
||||
];
|
||||
|
||||
export function avatarPaletteOf(raw?: string): string[] {
|
||||
if (!raw || !raw.startsWith("[")) return AVATAR_COLORS;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { BadgeCheck } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function isVerifiedRole(role?: string | null): boolean {
|
||||
return role === "admin" || role === "mentor";
|
||||
}
|
||||
|
||||
function roleLabel(role?: string | null): string | null {
|
||||
if (role === "admin") return "ادمین";
|
||||
if (role === "mentor") return "منتور";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Seal sized just under the surrounding name's cap height. Hover and focus show the role. */
|
||||
export function VerifiedBadge({ role, className }: { role?: string | null; className?: string }) {
|
||||
const label = roleLabel(role);
|
||||
const tipId = useId();
|
||||
const anchor = useRef<HTMLSpanElement>(null);
|
||||
const hideTimer = useRef<number | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [focusable, setFocusable] = useState(false);
|
||||
const [box, setBox] = useState<{ left: number; top: number; below: boolean } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = anchor.current;
|
||||
if (!el) return;
|
||||
setFocusable(!el.closest("button, a, [role='button']"));
|
||||
}, []);
|
||||
|
||||
const place = () => {
|
||||
const el = anchor.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const below = rect.top < 36;
|
||||
setBox({
|
||||
left: rect.left + rect.width / 2,
|
||||
top: below ? rect.bottom : rect.top,
|
||||
below,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onMove = () => place();
|
||||
window.addEventListener("scroll", onMove, true);
|
||||
window.addEventListener("resize", onMove);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onMove, true);
|
||||
window.removeEventListener("resize", onMove);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hideTimer.current != null) window.clearTimeout(hideTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const show = (event?: { stopPropagation: () => void }) => {
|
||||
event?.stopPropagation();
|
||||
if (hideTimer.current != null) window.clearTimeout(hideTimer.current);
|
||||
place();
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const scheduleHide = () => {
|
||||
if (hideTimer.current != null) window.clearTimeout(hideTimer.current);
|
||||
hideTimer.current = window.setTimeout(() => setOpen(false), 60);
|
||||
};
|
||||
|
||||
if (!label) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={anchor}
|
||||
tabIndex={focusable ? 0 : undefined}
|
||||
className="pointer-events-auto inline-flex shrink-0 cursor-default items-center rounded-sm leading-none outline-none focus-visible:ring-2 focus-visible:ring-sky-500/70"
|
||||
onPointerEnter={show}
|
||||
onPointerLeave={scheduleHide}
|
||||
onFocus={() => show()}
|
||||
onBlur={() => setOpen(false)}
|
||||
aria-describedby={open ? tipId : undefined}
|
||||
>
|
||||
<BadgeCheck
|
||||
aria-hidden={false}
|
||||
role="img"
|
||||
aria-label="تأییدشده"
|
||||
className={cn("size-[0.85em] text-sky-500", className)}
|
||||
/>
|
||||
</span>
|
||||
{open && box
|
||||
? createPortal(
|
||||
<span
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: box.left,
|
||||
top: box.below ? box.top - 4 : box.top + 4,
|
||||
transform: box.below ? "translate(-50%, 0)" : "translate(-50%, -100%)",
|
||||
paddingTop: box.below ? 8 : 0,
|
||||
paddingBottom: box.below ? 0 : 8,
|
||||
}}
|
||||
className="pointer-events-auto z-[200]"
|
||||
onPointerEnter={show}
|
||||
onPointerLeave={scheduleHide}
|
||||
>
|
||||
<span
|
||||
id={tipId}
|
||||
role="tooltip"
|
||||
className="block whitespace-nowrap rounded-md border border-border bg-popover px-2 py-1 text-xs font-bold text-popover-foreground shadow-md"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</span>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,29 @@ import { useAuth } from "@/lib/auth-store";
|
||||
import { http } from "@/lib/api";
|
||||
import type { User } from "@/lib/types";
|
||||
|
||||
function sameLiveUser(a: User, b: User): boolean {
|
||||
return (
|
||||
a.id === b.id &&
|
||||
a.name === b.name &&
|
||||
a.email === b.email &&
|
||||
a.role === b.role &&
|
||||
a.xp === b.xp &&
|
||||
a.hearts === b.hearts &&
|
||||
a.streakCurrent === b.streakCurrent &&
|
||||
a.streakLongest === b.streakLongest &&
|
||||
a.isLocked === b.isLocked &&
|
||||
a.isFrozen === b.isFrozen &&
|
||||
a.isClosed === b.isClosed &&
|
||||
a.frozenAt === b.frozenAt &&
|
||||
a.closedAt === b.closedAt &&
|
||||
a.closesAt === b.closesAt &&
|
||||
a.isActive === b.isActive &&
|
||||
a.avatarVariant === b.avatarVariant &&
|
||||
a.avatarPalette === b.avatarPalette &&
|
||||
a.avatarPhoto === b.avatarPhoto
|
||||
);
|
||||
}
|
||||
|
||||
export function useLiveUser(): User | null {
|
||||
const user = useAuth((s) => s.user);
|
||||
const setUser = useAuth((s) => s.setUser);
|
||||
@@ -19,26 +42,13 @@ export function useLiveUser(): User | null {
|
||||
|
||||
useEffect(() => {
|
||||
const fresh = data?.user;
|
||||
if (!fresh || !user) return;
|
||||
if (
|
||||
fresh.id === user.id &&
|
||||
fresh.name === user.name &&
|
||||
fresh.email === user.email &&
|
||||
fresh.role === user.role &&
|
||||
fresh.xp === user.xp &&
|
||||
fresh.hearts === user.hearts &&
|
||||
fresh.streakCurrent === user.streakCurrent &&
|
||||
fresh.streakLongest === user.streakLongest &&
|
||||
fresh.isLocked === user.isLocked &&
|
||||
fresh.isActive === user.isActive &&
|
||||
fresh.avatarVariant === user.avatarVariant &&
|
||||
fresh.avatarPalette === user.avatarPalette &&
|
||||
fresh.avatarPhoto === user.avatarPhoto
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!fresh) return;
|
||||
const current = useAuth.getState().user;
|
||||
if (current && sameLiveUser(fresh, current)) return;
|
||||
setUser(fresh);
|
||||
}, [data, user, setUser]);
|
||||
}, [data, setUser]);
|
||||
|
||||
return data?.user ?? user;
|
||||
// Prefer the auth store. A just-saved avatar must show before the ["me"]
|
||||
// cache catches up; applying that stale cache used to put the old photo back.
|
||||
return user ?? data?.user ?? null;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/lib/auth-store";
|
||||
import { getWsUrl } from "@/lib/api";
|
||||
@@ -24,6 +25,7 @@ export function useRealtimeGateway() {
|
||||
const accessToken = useAuth((s) => s.accessToken);
|
||||
const user = useAuth((s) => s.user);
|
||||
const client = useQueryClient();
|
||||
const router = useRouter();
|
||||
const lastEvent = useRef<Record<string, number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -55,7 +57,31 @@ export function useRealtimeGateway() {
|
||||
if (stamp - (lastEvent.current[dedupeKey] ?? 0) < 2000) return;
|
||||
lastEvent.current[dedupeKey] = stamp;
|
||||
|
||||
toast.info(`${n.title}${n.body ? ` — ${n.body}` : ""}`, { duration: 5000 });
|
||||
const title = n.title?.trim() || "اعلان";
|
||||
const body = n.body?.trim();
|
||||
toast.custom(
|
||||
(id) => (
|
||||
<button
|
||||
type="button"
|
||||
dir="rtl"
|
||||
onClick={() => {
|
||||
toast.dismiss(id);
|
||||
if (n.route) router.push(n.route);
|
||||
}}
|
||||
className="w-[min(22rem,calc(100vw-1.5rem))] rounded-xl border border-primary/40 bg-gradient-to-br from-primary/25 via-primary/12 to-accent/20 px-3 py-2.5 text-start shadow-[0_16px_40px_-16px_color-mix(in_srgb,var(--color-primary)_75%,transparent)] backdrop-blur-md"
|
||||
>
|
||||
<span dir="auto" className="block truncate text-sm font-semibold text-foreground [unicode-bidi:plaintext]">
|
||||
{title}
|
||||
</span>
|
||||
{body ? (
|
||||
<span dir="auto" className="mt-0.5 block line-clamp-2 text-xs text-foreground/75 [unicode-bidi:plaintext]">
|
||||
{body}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
),
|
||||
{ duration: 5000 },
|
||||
);
|
||||
client.invalidateQueries({ queryKey: ["notifications"] });
|
||||
client.invalidateQueries({ queryKey: ["me"] });
|
||||
}
|
||||
@@ -76,7 +102,7 @@ export function useRealtimeGateway() {
|
||||
closed = true;
|
||||
ws?.close();
|
||||
};
|
||||
}, [accessToken, user, client]);
|
||||
}, [accessToken, user, client, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/** Wide profile-header artwork. Palettes come from the avatar studio; these fills stretch them across a 3:1 banner. */
|
||||
|
||||
export const BANNER_WIDTH = 1200;
|
||||
export const BANNER_HEIGHT = 400;
|
||||
|
||||
export type BannerFill = "gradient" | "stripes" | "dots" | "diamonds" | "pixel" | "waves";
|
||||
|
||||
export const BANNER_FILLS: { id: BannerFill; label: string }[] = [
|
||||
{ id: "gradient", label: "گرادیان" },
|
||||
{ id: "stripes", label: "راهراه" },
|
||||
{ id: "dots", label: "نقطهای" },
|
||||
{ id: "diamonds", label: "لوزی" },
|
||||
{ id: "pixel", label: "پیکسلی" },
|
||||
{ id: "waves", label: "موج" },
|
||||
];
|
||||
|
||||
function hash(seed: string): number {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
h ^= seed.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
function mulberry32(seed: number) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function palette(colors: string[]): string[] {
|
||||
return colors.length >= 2 ? colors : ["#7d6df2", "#0f9b8e"];
|
||||
}
|
||||
|
||||
export function drawBannerFill(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
w: number,
|
||||
h: number,
|
||||
colors: string[],
|
||||
fill: BannerFill,
|
||||
seedKey: string,
|
||||
) {
|
||||
const c = palette(colors);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
if (fill === "gradient") {
|
||||
const g = ctx.createLinearGradient(0, h, w, 0);
|
||||
c.forEach((color, i) => g.addColorStop(i / (c.length - 1), color));
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
const glow = ctx.createRadialGradient(w * 0.5, h * 0.42, h * 0.04, w * 0.5, h * 0.42, w * 0.46);
|
||||
glow.addColorStop(0, "rgba(255,255,255,0.22)");
|
||||
glow.addColorStop(1, "rgba(255,255,255,0)");
|
||||
ctx.fillStyle = glow;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.fillStyle = c[0];
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
if (fill === "stripes") {
|
||||
const stripe = Math.max(14, w / 26);
|
||||
ctx.save();
|
||||
ctx.translate(w / 2, h / 2);
|
||||
ctx.rotate(-Math.PI / 7);
|
||||
const span = w + h;
|
||||
let i = 0;
|
||||
for (let x = -span; x < span; x += stripe) {
|
||||
ctx.globalAlpha = 0.88;
|
||||
ctx.fillStyle = c[i % c.length];
|
||||
ctx.fillRect(x, -span, stripe * 0.58, span * 2);
|
||||
i++;
|
||||
}
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
if (fill === "dots") {
|
||||
const gap = Math.max(22, w / 28);
|
||||
const radius = gap * 0.22;
|
||||
let row = 0;
|
||||
for (let y = gap * 0.55; y < h + gap; y += gap * 0.86) {
|
||||
const offset = row % 2 ? gap / 2 : 0;
|
||||
let col = 0;
|
||||
for (let x = offset; x < w + gap; x += gap) {
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = c[(row + col) % c.length];
|
||||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
col++;
|
||||
}
|
||||
row++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (fill === "diamonds") {
|
||||
const gap = Math.max(28, w / 18);
|
||||
const s = gap * 0.42;
|
||||
let row = 0;
|
||||
for (let y = gap * 0.2; y < h + gap; y += gap * 0.72) {
|
||||
const offset = row % 2 ? gap / 2 : 0;
|
||||
let col = 0;
|
||||
for (let x = offset; x < w + gap; x += gap) {
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.rotate(Math.PI / 4);
|
||||
ctx.fillStyle = c[(row + col) % c.length];
|
||||
ctx.fillRect(-s / 2, -s / 2, s, s);
|
||||
ctx.restore();
|
||||
col++;
|
||||
}
|
||||
row++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (fill === "pixel") {
|
||||
const cols = 18;
|
||||
const rows = 6;
|
||||
const cw = w / cols;
|
||||
const ch = h / rows;
|
||||
const rand = mulberry32(hash(`${seedKey}:pixel`));
|
||||
for (let y = 0; y < rows; y++) {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
ctx.fillStyle = c[Math.floor(rand() * c.length)];
|
||||
ctx.fillRect(Math.floor(x * cw), Math.floor(y * ch), Math.ceil(cw) + 1, Math.ceil(ch) + 1);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const bands = c.length;
|
||||
for (let b = 0; b < bands; b++) {
|
||||
const amp = h * (0.055 + (b % 3) * 0.012);
|
||||
const freq = 1.15 + (b % 4) * 0.35;
|
||||
const base = h * (0.16 + (b / bands) * 0.72);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, h);
|
||||
ctx.lineTo(0, base);
|
||||
for (let x = 0; x <= w; x += 8) {
|
||||
const y = base + Math.sin((x / w) * Math.PI * 2 * freq + b * 0.85) * amp;
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.lineTo(w, h);
|
||||
ctx.closePath();
|
||||
ctx.globalAlpha = b === 0 ? 0.45 : 0.94;
|
||||
ctx.fillStyle = c[b];
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import type { Role, User } from "./types";
|
||||
|
||||
/** Hard cap for students — no timed refill. */
|
||||
export const MAX_HEARTS = 3;
|
||||
/** Absolute student cap. A heart returns on the server every few hours. */
|
||||
export const MAX_HEARTS = 5;
|
||||
|
||||
/** Infinity glyph for staff heart display. */
|
||||
export const INFINITE_HEARTS_LABEL = "∞";
|
||||
|
||||
@@ -169,7 +169,111 @@ export async function saveRemoteFile(
|
||||
triggerDownload(blob, filename);
|
||||
}
|
||||
|
||||
function crc32(data: Uint8Array): number {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
c ^= data[i];
|
||||
for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
|
||||
}
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function uniqueZipName(name: string, used: Map<string, number>): string {
|
||||
const base = (name || "file").split(/[/\\]/).pop() || "file";
|
||||
const clean = base.replace(/[^\w.\-\u0600-\u06FF ]+/g, "_").slice(0, 80) || "file";
|
||||
const n = used.get(clean) ?? 0;
|
||||
used.set(clean, n + 1);
|
||||
if (n === 0) return clean;
|
||||
const dot = clean.lastIndexOf(".");
|
||||
if (dot > 0) return `${clean.slice(0, dot)}-${n + 1}${clean.slice(dot)}`;
|
||||
return `${clean}-${n + 1}`;
|
||||
}
|
||||
|
||||
/** Uncompressed zip so several chat files save as one download. */
|
||||
function buildStoredZip(files: { name: string; data: Uint8Array }[]): Blob {
|
||||
const enc = new TextEncoder();
|
||||
const now = new Date();
|
||||
const dosTime = (now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1);
|
||||
const dosDate = ((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate();
|
||||
const locals: Uint8Array[] = [];
|
||||
const central: Uint8Array[] = [];
|
||||
let offset = 0;
|
||||
for (const file of files) {
|
||||
const name = enc.encode(file.name);
|
||||
const crc = crc32(file.data);
|
||||
const local = new Uint8Array(30 + name.length);
|
||||
const view = new DataView(local.buffer);
|
||||
view.setUint32(0, 0x04034b50, true);
|
||||
view.setUint16(4, 20, true);
|
||||
view.setUint16(6, 0x800, true);
|
||||
view.setUint16(8, 0, true);
|
||||
view.setUint16(10, dosTime, true);
|
||||
view.setUint16(12, dosDate, true);
|
||||
view.setUint32(14, crc, true);
|
||||
view.setUint32(18, file.data.length, true);
|
||||
view.setUint32(22, file.data.length, true);
|
||||
view.setUint16(26, name.length, true);
|
||||
local.set(name, 30);
|
||||
locals.push(local, file.data);
|
||||
const cen = new Uint8Array(46 + name.length);
|
||||
const cenView = new DataView(cen.buffer);
|
||||
cenView.setUint32(0, 0x02014b50, true);
|
||||
cenView.setUint16(4, 20, true);
|
||||
cenView.setUint16(6, 20, true);
|
||||
cenView.setUint16(8, 0x800, true);
|
||||
cenView.setUint16(10, 0, true);
|
||||
cenView.setUint16(12, dosTime, true);
|
||||
cenView.setUint16(14, dosDate, true);
|
||||
cenView.setUint32(16, crc, true);
|
||||
cenView.setUint32(20, file.data.length, true);
|
||||
cenView.setUint32(24, file.data.length, true);
|
||||
cenView.setUint16(28, name.length, true);
|
||||
cenView.setUint32(42, offset, true);
|
||||
cen.set(name, 46);
|
||||
central.push(cen);
|
||||
offset += local.length + file.data.length;
|
||||
}
|
||||
const centralSize = central.reduce((sum, part) => sum + part.length, 0);
|
||||
const end = new Uint8Array(22);
|
||||
const endView = new DataView(end.buffer);
|
||||
endView.setUint32(0, 0x06054b50, true);
|
||||
endView.setUint16(8, files.length, true);
|
||||
endView.setUint16(10, files.length, true);
|
||||
endView.setUint32(12, centralSize, true);
|
||||
endView.setUint32(16, offset, true);
|
||||
const parts = [...locals, ...central, end].map((part) => {
|
||||
const copy = new ArrayBuffer(part.byteLength);
|
||||
new Uint8Array(copy).set(part);
|
||||
return copy;
|
||||
});
|
||||
return new Blob(parts, { type: "application/zip" });
|
||||
}
|
||||
|
||||
/** Save every file in one zip. */
|
||||
export async function downloadAllAsZip(
|
||||
items: { url: string; name: string }[],
|
||||
archiveName: string,
|
||||
onProgress?: (ratio: number) => void,
|
||||
): Promise<void> {
|
||||
const used = new Map<string, number>();
|
||||
const files: { name: string; data: Uint8Array }[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
onProgress?.(i / Math.max(items.length, 1));
|
||||
const hit = await readCachedMedia(items[i].url);
|
||||
const blob = hit ?? (await fetchBlob(items[i].url));
|
||||
if (!hit) await writeCachedMedia(items[i].url, blob);
|
||||
files.push({ name: uniqueZipName(items[i].name, used), data: new Uint8Array(await blob.arrayBuffer()) });
|
||||
}
|
||||
onProgress?.(1);
|
||||
triggerDownload(buildStoredZip(files), archiveName || "files.zip");
|
||||
}
|
||||
|
||||
/** One network fetch per object. Later views reuse the saved bytes until the object is replaced. */
|
||||
export async function clearMediaCache(): Promise<void> {
|
||||
if (typeof caches === "undefined") return;
|
||||
await caches.delete(mediaCacheName);
|
||||
}
|
||||
|
||||
export async function loadCachedMedia(url: string): Promise<Blob> {
|
||||
const hit = await readCachedMedia(url);
|
||||
if (hit) return hit;
|
||||
|
||||
@@ -21,6 +21,13 @@ export interface User {
|
||||
securityQuestion?: string;
|
||||
hasSecurityAnswer?: boolean;
|
||||
createdAt: string;
|
||||
usernameChangesRemaining?: number;
|
||||
usernameCooldownUntil?: string;
|
||||
frozenAt?: string;
|
||||
closedAt?: string;
|
||||
closesAt?: string;
|
||||
isFrozen?: boolean;
|
||||
isClosed?: boolean;
|
||||
}
|
||||
|
||||
export interface Certificate {
|
||||
@@ -195,7 +202,9 @@ export interface ChatMessage {
|
||||
pinned?: boolean;
|
||||
editedAt?: string;
|
||||
attachment?: ChatAttachment;
|
||||
attachments?: ChatAttachment[];
|
||||
reactions?: ChatReaction[];
|
||||
buttons?: { text: string }[];
|
||||
}
|
||||
|
||||
export interface ChatReaction {
|
||||
@@ -210,6 +219,7 @@ export interface Mentor {
|
||||
email: string;
|
||||
role: Role;
|
||||
online?: boolean;
|
||||
lastSeen?: string;
|
||||
avatarVariant?: "marble" | "beam" | "pixel" | "sunset" | "ring" | "bauhaus";
|
||||
avatarPalette?: string;
|
||||
avatarPhoto?: string;
|
||||
@@ -222,12 +232,18 @@ export interface Conversation {
|
||||
email: string;
|
||||
role: Role;
|
||||
online?: boolean;
|
||||
lastSeen?: string;
|
||||
saved?: boolean;
|
||||
avatarVariant?: "marble" | "beam" | "pixel" | "sunset" | "ring" | "bauhaus";
|
||||
avatarPalette?: string;
|
||||
avatarPhoto?: string;
|
||||
};
|
||||
lastMessage?: ChatMessage;
|
||||
unreadCount: number;
|
||||
messageCount?: number;
|
||||
pinnedRank: number | null;
|
||||
muted: boolean;
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
export interface LeaderboardEntry {
|
||||
@@ -268,16 +284,23 @@ export interface MCQQuestion {
|
||||
export interface PublicProfile {
|
||||
id: number;
|
||||
name: string;
|
||||
username: string;
|
||||
username?: string;
|
||||
role: Role;
|
||||
xp: number;
|
||||
hearts: number;
|
||||
streakCurrent: number;
|
||||
streakLongest: number;
|
||||
xp?: number;
|
||||
hearts?: number;
|
||||
streakCurrent?: number;
|
||||
streakLongest?: number;
|
||||
avatarVariant?: User["avatarVariant"];
|
||||
avatarPalette?: string;
|
||||
avatarPhoto?: string;
|
||||
isLocked: boolean;
|
||||
isLocked?: boolean;
|
||||
isFrozen?: boolean;
|
||||
isClosed?: boolean;
|
||||
private?: boolean;
|
||||
public?: boolean;
|
||||
banner?: string;
|
||||
createdAt?: string;
|
||||
socialLinks?: { name: string; url: string }[];
|
||||
}
|
||||
|
||||
export interface AdminStats {
|
||||
|
||||
@@ -22,6 +22,17 @@ export function formatStreakLabel(days: number) {
|
||||
/** Quiz option letters: الف، ب، ج، د، … */
|
||||
const FA_OPTION_LETTERS = ["الف", "ب", "ج", "د", "ه", "و"] as const;
|
||||
|
||||
/** Jalali day plus clock, for conversation rows. */
|
||||
export function formatJalaliStamp(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return "";
|
||||
}
|
||||
const day = new Intl.DateTimeFormat("fa-IR", { day: "numeric", month: "short" }).format(d);
|
||||
const time = new Intl.DateTimeFormat("fa-IR", { hour: "2-digit", minute: "2-digit" }).format(d);
|
||||
return `${day} · ${time}`;
|
||||
}
|
||||
|
||||
export function formatOptionLetter(index: number): string {
|
||||
if (index >= 0 && index < FA_OPTION_LETTERS.length) {
|
||||
return FA_OPTION_LETTERS[index];
|
||||
|
||||
Reference in New Issue
Block a user