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:
ahp
2026-10-18 10:29:58 +03:30
co-authored by Claude Sonnet 4.6
parent b4a7e7e1d3
commit 40fa0b8581
4 changed files with 37 additions and 24 deletions
+19 -7
View File
@@ -15,6 +15,9 @@ type Props = {
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) {
const [tab, setTab] = useState<"edit" | "preview">("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) {
<EditorContent
editor={editor}
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
dir="auto"
className="prose prose-sm max-w-none px-3 py-2 text-sm"
dangerouslySetInnerHTML={{ __html: preview || "<p class='text-muted-foreground text-sm'>پیش‌نمایشی نیست.</p>" }}
className={cn("px-3 py-2 text-sm", mdClasses)}
dangerouslySetInnerHTML={{
__html: preview || "<p class='text-muted-foreground text-sm'>پیش‌نمایشی نیست.</p>",
}}
/>
)}
</div>