From 40fa0b8581a6d618cf01c02e1ad682697ceaab79 Mon Sep 17 00:00:00 2001 From: Amir Husayn Panahifar Date: Sun, 18 Oct 2026 10:29:58 +0330 Subject: [PATCH] =?UTF-8?q?fix:=20quality=20audit=20=E2=80=94=20memory=20l?= =?UTF-8?q?eaks,=20dead=20prose=20classes,=20stale=20state=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - safe-markdown: add `cancelled` flag to async useEffect; no more setState after unmount when component is destroyed mid-import - rich-editor: same cleanup for preview tab effect; also remove dead `prose prose-sm max-w-none` classes (Tailwind Typography not installed) and replace with explicit arbitrary-selector markdown styling that actually renders using existing design tokens - profile-certificate-slot: drop dead `ORDER_LABEL`, `order` variable, and `physicalOrder` from `MineCertificateResponse`; physical orders are disabled — these were leftover references causing lint noise - learning-panel: change `issuingCert: boolean` → `issuingCertFor: number | null` so only the targeted user's button shows a spinner; a boolean shared across all users caused the wrong row to animate when the admin switched selected users mid-request Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/admin/learning-panel.tsx | 14 ++++++---- .../components/profile-certificate-slot.tsx | 11 +------- frontend/src/components/safe-markdown.tsx | 10 +++++-- frontend/src/components/ui/rich-editor.tsx | 26 ++++++++++++++----- 4 files changed, 37 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/admin/learning-panel.tsx b/frontend/src/components/admin/learning-panel.tsx index 590d3c2..bddcbbc 100644 --- a/frontend/src/components/admin/learning-panel.tsx +++ b/frontend/src/components/admin/learning-panel.tsx @@ -65,7 +65,7 @@ export function LearningPanel() { const [sort, setSort] = useState("xp"); const [selectedId, setSelectedId] = useState(null); const [unlocking, setUnlocking] = useState(false); - const [issuingCert, setIssuingCert] = useState(false); + const [issuingCertFor, setIssuingCertFor] = useState(null); const [page, setPage] = useState(1); useEffect(() => { @@ -117,7 +117,7 @@ export function LearningPanel() { }; const issueCertificate = async (id: number) => { - setIssuingCert(true); + setIssuingCertFor(id); try { await http.post(`/api/admin/users/${id}/certificate/issue`, {}); toast.success("گواهینامه با موفقیت صادر شد"); @@ -125,7 +125,7 @@ export function LearningPanel() { } catch (e) { toast.error(toUserError(e, "صدور گواهینامه ممکن نشد")); } finally { - setIssuingCert(false); + setIssuingCertFor(null); } }; @@ -393,11 +393,15 @@ export function LearningPanel() { diff --git a/frontend/src/components/profile-certificate-slot.tsx b/frontend/src/components/profile-certificate-slot.tsx index 7a140d6..bf83430 100644 --- a/frontend/src/components/profile-certificate-slot.tsx +++ b/frontend/src/components/profile-certificate-slot.tsx @@ -5,7 +5,7 @@ import { useRouter } from "next/navigation"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Award, Clock, Copy, ExternalLink, Loader2, Lock } from "lucide-react"; import { http, toUserError } from "@/lib/api"; -import type { Certificate, CertificatePhysicalOrder } from "@/lib/types"; +import type { Certificate } from "@/lib/types"; import { Button } from "@/components/ui/button"; import { toast } from "@/components/providers"; import { cn } from "@/lib/utils"; @@ -13,14 +13,6 @@ import { cn } from "@/lib/utils"; type MineCertificateResponse = { certificate: Certificate | null; eligible: boolean; - physicalOrder: CertificatePhysicalOrder | null; -}; - -const ORDER_LABEL: Record = { - requested: "درخواست فیزیکی ثبت شد", - paid: "پرداخت تأیید شد", - shipped: "ارسال شد", - cancelled: "درخواست فیزیکی لغو شده", }; export function ProfileCertificateSlot() { @@ -36,7 +28,6 @@ export function ProfileCertificateSlot() { const cert = data?.certificate ?? null; const eligible = !!data?.eligible; - const order = data?.physicalOrder; // Auto-issue once when the student becomes eligible (same path as /cap). useEffect(() => { diff --git a/frontend/src/components/safe-markdown.tsx b/frontend/src/components/safe-markdown.tsx index 2395a5b..512aedd 100644 --- a/frontend/src/components/safe-markdown.tsx +++ b/frontend/src/components/safe-markdown.tsx @@ -16,14 +16,19 @@ export function SafeMarkdown({ useEffect(() => { if (!text) { setHtml(""); return; } + let cancelled = false; void (async () => { const { marked } = await import("marked"); const DOMPurify = (await import("dompurify")).default; const raw = await marked(text); - setHtml(DOMPurify.sanitize(raw)); + if (!cancelled) setHtml(DOMPurify.sanitize(raw)); })(); + return () => { cancelled = true; }; }, [text]); + const mdClasses = + "[&_strong]:font-bold [&_em]:italic [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:text-[0.85em] [&_h2]:text-base [&_h2]:font-bold [&_h2]:mt-3 [&_h3]:text-sm [&_h3]:font-bold [&_h3]:mt-2 [&_ul]:list-disc [&_ul]:pr-5 [&_ol]:list-decimal [&_ol]:pr-5 [&_li]:mt-0.5 [&_p]:leading-relaxed [&_p+p]:mt-2 [&_a]:underline [&_a]:text-primary"; + if (!html) { return (
("edit"); const [preview, setPreview] = useState(""); @@ -34,7 +37,6 @@ export function RichEditor({ value, onChange, placeholder, className }: Props) { }, }); - // Sync external value changes (e.g. when a new lesson is loaded). useEffect(() => { if (!editor) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -46,11 +48,14 @@ export function RichEditor({ value, onChange, placeholder, className }: Props) { useEffect(() => { if (tab !== "preview") return; - import("marked").then(async ({ marked }) => { + let cancelled = false; + void (async () => { + const { marked } = await import("marked"); const DOMPurify = (await import("dompurify")).default; const html = await marked(value); - setPreview(DOMPurify.sanitize(html)); - }); + if (!cancelled) setPreview(DOMPurify.sanitize(html)); + })(); + return () => { cancelled = true; }; }, [tab, value]); const ToolBtn = ({ @@ -170,13 +175,20 @@ export function RichEditor({ value, onChange, placeholder, className }: Props) { ) : (
پیش‌نمایشی نیست.

" }} + className={cn("px-3 py-2 text-sm", mdClasses)} + dangerouslySetInnerHTML={{ + __html: preview || "

پیش‌نمایشی نیست.

", + }} /> )}