fix: quality audit — memory leaks, dead prose classes, stale state scope
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -65,7 +65,7 @@ export function LearningPanel() {
|
|||||||
const [sort, setSort] = useState<SortKey>("xp");
|
const [sort, setSort] = useState<SortKey>("xp");
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
const [unlocking, setUnlocking] = useState(false);
|
const [unlocking, setUnlocking] = useState(false);
|
||||||
const [issuingCert, setIssuingCert] = useState(false);
|
const [issuingCertFor, setIssuingCertFor] = useState<number | null>(null);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -117,7 +117,7 @@ export function LearningPanel() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const issueCertificate = async (id: number) => {
|
const issueCertificate = async (id: number) => {
|
||||||
setIssuingCert(true);
|
setIssuingCertFor(id);
|
||||||
try {
|
try {
|
||||||
await http.post(`/api/admin/users/${id}/certificate/issue`, {});
|
await http.post(`/api/admin/users/${id}/certificate/issue`, {});
|
||||||
toast.success("گواهینامه با موفقیت صادر شد");
|
toast.success("گواهینامه با موفقیت صادر شد");
|
||||||
@@ -125,7 +125,7 @@ export function LearningPanel() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(toUserError(e, "صدور گواهینامه ممکن نشد"));
|
toast.error(toUserError(e, "صدور گواهینامه ممکن نشد"));
|
||||||
} finally {
|
} finally {
|
||||||
setIssuingCert(false);
|
setIssuingCertFor(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -393,11 +393,15 @@ export function LearningPanel() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="default"
|
variant="default"
|
||||||
disabled={issuingCert}
|
disabled={issuingCertFor === detailQ.data.learning.user.id}
|
||||||
onClick={() => issueCertificate(detailQ.data!.learning.user.id)}
|
onClick={() => issueCertificate(detailQ.data!.learning.user.id)}
|
||||||
className="gap-1.5"
|
className="gap-1.5"
|
||||||
>
|
>
|
||||||
{issuingCert ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Award className="h-3.5 w-3.5" />}
|
{issuingCertFor === detailQ.data.learning.user.id ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Award className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
صدور گواهینامه
|
صدور گواهینامه
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Award, Clock, Copy, ExternalLink, Loader2, Lock } from "lucide-react";
|
import { Award, Clock, Copy, ExternalLink, Loader2, Lock } from "lucide-react";
|
||||||
import { http, toUserError } from "@/lib/api";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { toast } from "@/components/providers";
|
import { toast } from "@/components/providers";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -13,14 +13,6 @@ import { cn } from "@/lib/utils";
|
|||||||
type MineCertificateResponse = {
|
type MineCertificateResponse = {
|
||||||
certificate: Certificate | null;
|
certificate: Certificate | null;
|
||||||
eligible: boolean;
|
eligible: boolean;
|
||||||
physicalOrder: CertificatePhysicalOrder | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ORDER_LABEL: Record<string, string> = {
|
|
||||||
requested: "درخواست فیزیکی ثبت شد",
|
|
||||||
paid: "پرداخت تأیید شد",
|
|
||||||
shipped: "ارسال شد",
|
|
||||||
cancelled: "درخواست فیزیکی لغو شده",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ProfileCertificateSlot() {
|
export function ProfileCertificateSlot() {
|
||||||
@@ -36,7 +28,6 @@ export function ProfileCertificateSlot() {
|
|||||||
|
|
||||||
const cert = data?.certificate ?? null;
|
const cert = data?.certificate ?? null;
|
||||||
const eligible = !!data?.eligible;
|
const eligible = !!data?.eligible;
|
||||||
const order = data?.physicalOrder;
|
|
||||||
|
|
||||||
// Auto-issue once when the student becomes eligible (same path as /cap).
|
// Auto-issue once when the student becomes eligible (same path as /cap).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -16,14 +16,19 @@ export function SafeMarkdown({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!text) { setHtml(""); return; }
|
if (!text) { setHtml(""); return; }
|
||||||
|
let cancelled = false;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const { marked } = await import("marked");
|
const { marked } = await import("marked");
|
||||||
const DOMPurify = (await import("dompurify")).default;
|
const DOMPurify = (await import("dompurify")).default;
|
||||||
const raw = await marked(text);
|
const raw = await marked(text);
|
||||||
setHtml(DOMPurify.sanitize(raw));
|
if (!cancelled) setHtml(DOMPurify.sanitize(raw));
|
||||||
})();
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
}, [text]);
|
}, [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) {
|
if (!html) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -45,7 +50,8 @@ export function SafeMarkdown({
|
|||||||
<div
|
<div
|
||||||
dir="auto"
|
dir="auto"
|
||||||
className={cn(
|
className={cn(
|
||||||
"prose prose-sm max-w-none break-words [unicode-bidi:plaintext]",
|
"break-words text-sm [unicode-bidi:plaintext]",
|
||||||
|
mdClasses,
|
||||||
lines === 2 && "line-clamp-2",
|
lines === 2 && "line-clamp-2",
|
||||||
lines === 3 && "line-clamp-3",
|
lines === 3 && "line-clamp-3",
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ type Props = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
export function RichEditor({ value, onChange, placeholder, className }: Props) {
|
export function RichEditor({ value, onChange, placeholder, className }: Props) {
|
||||||
const [tab, setTab] = useState<"edit" | "preview">("edit");
|
const [tab, setTab] = useState<"edit" | "preview">("edit");
|
||||||
const [preview, setPreview] = useState("");
|
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(() => {
|
useEffect(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -46,11 +48,14 @@ export function RichEditor({ value, onChange, placeholder, className }: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tab !== "preview") return;
|
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 DOMPurify = (await import("dompurify")).default;
|
||||||
const html = await marked(value);
|
const html = await marked(value);
|
||||||
setPreview(DOMPurify.sanitize(html));
|
if (!cancelled) setPreview(DOMPurify.sanitize(html));
|
||||||
});
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
}, [tab, value]);
|
}, [tab, value]);
|
||||||
|
|
||||||
const ToolBtn = ({
|
const ToolBtn = ({
|
||||||
@@ -170,13 +175,20 @@ export function RichEditor({ value, onChange, placeholder, className }: Props) {
|
|||||||
<EditorContent
|
<EditorContent
|
||||||
editor={editor}
|
editor={editor}
|
||||||
dir="auto"
|
dir="auto"
|
||||||
className="prose prose-sm max-w-none px-3 py-2 text-sm focus-within:outline-none [&_.ProseMirror]:min-h-[80px] [&_.ProseMirror]:outline-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-muted-foreground/50 [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-right [&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none"
|
className={cn(
|
||||||
|
"px-3 py-2 text-sm focus-within:outline-none",
|
||||||
|
mdClasses,
|
||||||
|
"[&_.ProseMirror]:min-h-[80px] [&_.ProseMirror]:outline-none",
|
||||||
|
"[&_.ProseMirror_p.is-editor-empty:first-child::before]:text-muted-foreground/50 [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-right [&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
dir="auto"
|
dir="auto"
|
||||||
className="prose prose-sm max-w-none px-3 py-2 text-sm"
|
className={cn("px-3 py-2 text-sm", mdClasses)}
|
||||||
dangerouslySetInnerHTML={{ __html: preview || "<p class='text-muted-foreground text-sm'>پیشنمایشی نیست.</p>" }}
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: preview || "<p class='text-muted-foreground text-sm'>پیشنمایشی نیست.</p>",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user