mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
document to markdown
This commit is contained in:
@@ -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 ?? "" }} />;
|
||||
}
|
||||
Reference in New Issue
Block a user