document to markdown

This commit is contained in:
rgrgogu
2026-07-18 11:41:01 +08:00
parent 5eefbe0dc9
commit 22c0731cc1
10 changed files with 783 additions and 16 deletions
+7 -1
View File
@@ -1,6 +1,6 @@
// components/generic/CMS/AddBlockMenu.jsx
import { Plus, Type, Image, ImagePlay, Video, VideoIcon, Music2, Code2, FileText } from "lucide-react";
import { Plus, Type, Image, ImagePlay, Video, VideoIcon, Music2, Code2, FileText, FileUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -60,6 +60,12 @@ const BLOCK_TYPES = [
description: "Rich text written in Markdown",
icon: <FileText className="h-4 w-4" />,
},
{
type: "document",
label: "Document",
description: "Upload a PDF/PPTX only, auto-convert to Markdown",
icon: <FileUp className="h-4 w-4" />,
},
];
export function AddBlockMenu({ onAdd }) {
+13 -5
View File
@@ -71,7 +71,11 @@ function EmptyState({ fileType }) {
// ─── Main Sheet ───────────────────────────────────────────────────────────────
export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
// allowedExtensions optionally narrows a fileType's picker to a subset of
// EXT_OPTIONS (e.g. the Document Import block only wants pdf/pptx out of the
// full document set). Omit it and behavior is unchanged from every existing
// caller — the extension filter stays purely an opt-in user-facing toggle.
export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allowedExtensions }) {
// NOTE: previously this returned null before any hooks ran when `open` was
// false. Since the parent renders this component unconditionally (only the
// `open` prop toggles), that meant React remounted every hook from scratch
@@ -92,7 +96,7 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
const isFirstSearchRun = useRef(true);
const LIMIT = 12;
const extOptions = EXT_OPTIONS[fileType] ?? [];
const extOptions = allowedExtensions ?? EXT_OPTIONS[fileType] ?? [];
const resolveStreamSrc = useCallback((assetId) => {
const entry = mediaTokens[String(assetId)];
@@ -102,13 +106,17 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
// ── Build and fire fetch ──────────────────────────────────────────────────
const doFetch = useCallback((searchVal, extSet, pg) => {
// No chips picked: fall back to allowedExtensions (if the caller
// passed one) as a mandatory whitelist, otherwise no extension
// filter at all — matches every pre-existing caller's behavior.
const extFilterValue = extSet.size > 0 ? [...extSet] : (allowedExtensions ?? null);
const filters = [
...(fileType ? [{ id: "file_type", value: [fileType] }] : []),
...(searchVal.trim() ? [{ id: "display_name", value: [searchVal.trim()] }] : []),
...(extSet.size > 0 ? [{ id: "extension", value: [...extSet] }] : []),
...(extFilterValue?.length ? [{ id: "extension", value: extFilterValue }] : []),
];
fetchAssets({ page: pg, limit: LIMIT, filters });
}, [fileType, fetchAssets]);
}, [fileType, fetchAssets, allowedExtensions]);
// ── Immediate fetch: on open, or when filters/page change while open ──────
// (fetchAssets itself is TTL-cached in AdminAssetsContext, so reopening
@@ -236,7 +244,7 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-56 p-3 space-y-3">
<PopoverContent align="end" className="w-56 p-3 space-y-1">
<div className="flex items-center justify-between">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">File type</p>
{hasActiveFilters && (
+4
View File
@@ -9,6 +9,7 @@ import { TextVideoBlock } from "./Blocks/Admin/TextVideoBlock";
import { AudioBlock } from "./Blocks/Admin/AudioBlock";
import { CodeBlock } from "./Blocks/Admin/CodeBlock";
import { MarkdownBlock } from "./Blocks/Admin/MarkdownBlock";
import { DocumentBlock } from "./Blocks/Admin/DocumentBlock";
// ─── Block renderer ───────────────────────────────────────────────────────────
//
@@ -41,6 +42,8 @@ function BlockContent({ block, onUpdate }) {
return <CodeBlock content={content} onUpdate={onUpdate} />;
case "markdown":
return <MarkdownBlock content={content} onUpdate={onUpdate} />;
case "document":
return <DocumentBlock content={content} onUpdate={onUpdate} />;
default:
return <p className="text-sm text-muted-foreground">Unknown block type.</p>;
}
@@ -57,6 +60,7 @@ export const DEFAULT_CONTENT = {
"audio": { asset_id: null, url: "", title: "", artist: "", tag: "", thumbnail: "", duration_seconds: 0 },
"code": { language: "javascript", code: "" },
"markdown": { body: "" },
"document": { source_asset_id: null, source_filename: null, source_ext: null, body: "" },
};
// ─── List ─────────────────────────────────────────────────────────────────────
@@ -0,0 +1,246 @@
import { useState } from "react";
import { FileUp, FileText, RotateCcw, Check, X, Eye, Pencil } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { cn } from "@/lib/utils";
import { useAssets } from "@/contexts/AdminAssetsContext";
import { AssetPickerSheet } from "../../AssetPickerSheet";
import { MarkdownBlock } from "./MarkdownBlock";
// Only these two — docx/xlsx are visible in the generic document library but
// out of scope for this block (see documentConversion.service.js on the backend).
const ALLOWED_EXTENSIONS = ["pdf", "pptx"];
const STAGES = [
{ phase: "compiling", label: "Compiling document" },
{ phase: "validating", label: "Validating content" },
{ phase: "generating", label: "Generating Markdown" },
];
// ─── Stage progress ───────────────────────────────────────────────────────────
function StageProgress({ phase }) {
const activeIndex = STAGES.findIndex((s) => s.phase === phase);
return (
<div className="flex flex-col gap-2 py-2">
{STAGES.map((s, i) => {
const state = activeIndex > i ? "done" : activeIndex === i ? "active" : "pending";
return (
<div key={s.phase} className="flex items-center gap-2 text-sm">
{state === "done" ? (
<Check className="h-3.5 w-3.5 text-primary shrink-0" />
) : state === "active" ? (
<Spinner className="h-3.5 w-3.5 shrink-0" />
) : (
<span className="h-3.5 w-3.5 rounded-full border shrink-0" />
)}
<span className={cn(state === "pending" && "text-muted-foreground")}>{s.label}…</span>
</div>
);
})}
</div>
);
}
// ─── DocumentBlock (Admin) ─────────────────────────────────────────────────────
//
// Pick (pdf/pptx only) -> Convert (compile/validate/automate, live stage
// progress over SSE) -> Draft review (rendered Markdown + warnings, explicit
// Insert/Discard). Nothing lands in `content` until "Insert into Lesson" is
// clicked — the conversion result is a draft, never written automatically.
// Once inserted, this hands off to MarkdownBlock's own editor for `body` so
// admins can hand-edit the generated Markdown before saving the lesson.
//
export function DocumentBlock({ content, onUpdate, readOnly = false }) {
const { convertAssetToMarkdown } = useAssets();
const [pickerOpen, setPickerOpen] = useState(false);
const [phase, setPhase] = useState(null); // null | 'compiling' | 'validating' | 'generating'
const [pendingAsset, setPendingAsset] = useState(null); // { asset_id, display_name, extension } — while converting
const [draft, setDraft] = useState(null); // { markdown, warnings, stats } — awaiting Insert/Discard
const [preview, setPreview] = useState(true);
const hasContent = !!content?.body?.trim();
const runConversion = async (asset) => {
setPendingAsset(asset);
setDraft(null);
setPhase("compiling");
const result = await convertAssetToMarkdown(asset.asset_id, {
onProgress: (data) => { if (data.phase && data.phase !== "done" && data.phase !== "error") setPhase(data.phase); },
});
setPhase(null);
if (result) {
setDraft({
markdown: result.markdown ?? "",
warnings: result.warnings ?? [],
stats: result.stats ?? null,
asset,
});
} else {
// convertAssetToMarkdown already toasted the backend's specific
// error (e.g. "No readable text found…") — just reset to picking.
setPendingAsset(null);
}
};
const handlePick = (asset) => {
runConversion({ asset_id: asset.asset_id, display_name: asset.display_name, extension: asset.extension });
};
const insertDraft = () => {
if (!draft) return;
onUpdate({
source_asset_id: draft.asset.asset_id,
source_filename: draft.asset.display_name,
source_ext: draft.asset.extension,
body: draft.markdown,
});
setDraft(null);
setPendingAsset(null);
};
const discardDraft = () => {
setDraft(null);
setPendingAsset(null);
setPickerOpen(true);
};
// ── Already inserted: behave like a normal editable Markdown block ────────
if (hasContent && !draft) {
return (
<div className="space-y-2">
{!readOnly && (
<div className="flex items-center justify-between">
<Label>Document Import</Label>
{content.source_filename && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<FileText className="h-3 w-3" />
<span className="truncate max-w-[180px]">{content.source_filename}</span>
<button
type="button"
onClick={() => setPickerOpen(true)}
className="flex items-center gap-1 text-primary hover:underline"
>
<RotateCcw className="h-3 w-3" />
Re-convert
</button>
</div>
)}
</div>
)}
<MarkdownBlock content={content} onUpdate={onUpdate} readOnly={readOnly} />
{!readOnly && (
<AssetPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="document"
allowedExtensions={ALLOWED_EXTENSIONS}
onSelect={handlePick}
/>
)}
</div>
);
}
// ── Converting ──────────────────────────────────────────────────────────
if (phase) {
return (
<div className="space-y-1.5">
<Label>Document Import</Label>
<div className="rounded-lg border p-4 space-y-1">
<p className="text-sm text-muted-foreground truncate">{pendingAsset?.display_name}</p>
<StageProgress phase={phase} />
</div>
</div>
);
}
// ── Draft review ────────────────────────────────────────────────────────
if (draft) {
return (
<div className="space-y-1.5">
<Label>Document Import — Draft</Label>
<div className="border rounded-md overflow-hidden">
<div className="flex items-center justify-between gap-2 px-3 py-1.5 border-b bg-muted/40">
<span className="text-xs text-muted-foreground truncate">
Converted from <span className="font-medium">{draft.asset.display_name}</span> — review before inserting
</span>
<button
type="button"
title={preview ? "View raw Markdown" : "Preview"}
onClick={() => setPreview((p) => !p)}
className="h-6 w-6 flex items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-accent-foreground shrink-0"
>
{preview ? <Pencil className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
</div>
{draft.warnings.length > 0 && (
<div className="px-3 pt-2 space-y-1">
{draft.warnings.map((w, i) => (
<Alert key={i} className="py-1.5">
<AlertDescription className="text-xs">{w}</AlertDescription>
</Alert>
))}
</div>
)}
<div className="min-h-[180px] max-h-[400px] overflow-y-auto px-3 py-3">
{preview ? (
<div className="typeset text-sm">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{draft.markdown}</ReactMarkdown>
</div>
) : (
<pre className="text-xs font-mono whitespace-pre-wrap">{draft.markdown}</pre>
)}
</div>
<div className="flex items-center justify-end gap-2 px-3 py-2 border-t bg-muted/40">
<Button type="button" variant="outline" size="sm" onClick={discardDraft} className="gap-1.5">
<X className="h-3.5 w-3.5" />
Discard / Pick another
</Button>
<Button type="button" size="sm" onClick={insertDraft} className="gap-1.5">
<Check className="h-3.5 w-3.5" />
Insert into Lesson
</Button>
</div>
</div>
</div>
);
}
// ── Empty: pick a file ─────────────────────────────────────────────────
return (
<div className="space-y-1.5">
{!readOnly && <Label>Document Import</Label>}
<button
type="button"
onClick={() => setPickerOpen(true)}
disabled={readOnly}
className="w-full py-8 rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-primary/50 hover:bg-muted/20 transition-colors flex flex-col items-center justify-center gap-2"
>
<FileUp className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">Click to select a PDF or PPTX</p>
<p className="text-xs text-muted-foreground/70">Text is extracted and converted to Markdown automatically — no images/OCR.</p>
</button>
{!readOnly && (
<AssetPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="document"
allowedExtensions={ALLOWED_EXTENSIONS}
onSelect={handlePick}
/>
)}
</div>
);
}
@@ -0,0 +1,7 @@
import { MarkdownBlock } from "./MarkdownBlock";
// The converted output is just Markdown by the time it reaches the client —
// no document-specific rendering needed, just delegate straight through.
export function DocumentBlock({ content }) {
return <MarkdownBlock content={{ body: content?.body ?? "" }} />;
}