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
@@ -11,6 +11,7 @@ import { TextBlock } from "@/components/generic/Blocks/Client/TextBlock";
import { AudioBlock } from "@/components/generic/Blocks/Client/AudioBlock";
import { CodeBlock } from "@/components/generic/Blocks/Client/CodeBlock";
import { MarkdownBlock } from "@/components/generic/Blocks/Client/MarkdownBlock";
import { DocumentBlock } from "@/components/generic/Blocks/Client/DocumentBlock";
export function LessonHeader({ lesson }) {
if (!lesson) return null;
@@ -159,6 +160,8 @@ export function PreviewBlock({ block, onWatchProgress, resumeMap, antiSkipEnable
return <CodeBlock content={content} />;
case "markdown":
return <MarkdownBlock content={content} />;
case "document":
return <DocumentBlock content={content} />;
default:
return null;
}
@@ -1,7 +1,7 @@
// config/library/lessons/toolbar.config.jsx
// Toolbar actions for the Lesson Library (active + archived variants).
import { Plus, RefreshCw, Download, Archive, ArrowLeft } from "lucide-react";
import { Plus, RefreshCw, Download, Archive, ArrowLeft, Upload } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
export function buildToolbarActions({
@@ -46,6 +46,14 @@ export function buildToolbarActions({
variant: "default",
onClick: () => navigate("/admin/lessons/add"),
},
{
key: "import",
type: "button",
label: "Import",
icon: <Upload className="h-3.5 w-3.5" />,
variant: "outline",
onClick: () => navigate("/admin/lessons/import"),
},
{
key: "archived-lessons",
type: "button",
@@ -0,0 +1,443 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { nanoid } from "nanoid";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
ArrowLeft, ChevronLeft, ChevronRight, Check, X,
FileUp, FileText, Cog, ClipboardCheck, RotateCcw,
} from "lucide-react";
import { useLibrary } from "@/contexts/AdminLibraryContext";
import { useAssets } from "@/contexts/AdminAssetsContext";
import { useAuth } from "@/contexts/AuthContext";
import { PageMeta } from "@/contexts/MetadataContext";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
// Only these two — same scope as the Document Import block (documentConversion.service.js on the backend).
const ALLOWED_EXTENSIONS = ["pdf", "pptx"];
const schema = z.object({
title: z.string().min(1, "Title is required."),
description: z.string().optional(),
});
const DEFAULT_VALUES = { title: "", description: "" };
const STEPS = [
{ id: 0, label: "Import", icon: FileUp },
{ id: 1, label: "Processing", icon: Cog },
{ id: 2, label: "Review", icon: ClipboardCheck },
];
const STEP_FIELDS = [["title", "description"], [], []];
const STAGES = [
{ phase: "compiling", label: "Compilation" },
{ phase: "validating", label: "Validation" },
{ phase: "generating", label: "Automation" },
];
function FieldError({ message }) {
if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>;
}
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>
);
}
// ─── Step 1 — Import ────────────────────────────────────────────────────────────
function StepImport({ register, errors, selectedAsset, onPick, fileMissing }) {
const [pickerOpen, setPickerOpen] = useState(false);
return (
<div className="space-y-5">
<div className="space-y-1.5">
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
<Input id="title" placeholder="Lesson title" {...register("title")} />
<FieldError message={errors.title?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
</div>
<div className="space-y-1.5">
<Label>Import File <span className="text-destructive">*</span></Label>
{selectedAsset ? (
<div className="flex items-center justify-between gap-2 rounded-lg border p-3">
<div className="flex items-center gap-2 min-w-0">
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm truncate">{selectedAsset.display_name}</span>
</div>
<button
type="button"
onClick={() => setPickerOpen(true)}
className="text-xs text-primary hover:underline shrink-0"
>
Change
</button>
</div>
) : (
<button
type="button"
onClick={() => setPickerOpen(true)}
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>
</button>
)}
{fileMissing && <FieldError message="Select a file to import before continuing." />}
</div>
<AssetPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="document"
allowedExtensions={ALLOWED_EXTENSIONS}
onSelect={onPick}
/>
</div>
);
}
// ─── Step 2 — Processing ────────────────────────────────────────────────────────
function StepProcessing({ selectedAsset, phase, result, convertError, onRetry }) {
return (
<div className="space-y-4">
<div className="rounded-lg border p-4 space-y-1">
<p className="text-sm text-muted-foreground truncate">{selectedAsset?.display_name}</p>
{phase && <StageProgress phase={phase} />}
{!phase && result && (
<div className="space-y-2 pt-1">
<div className="flex items-center gap-2 text-sm text-primary">
<Check className="h-3.5 w-3.5" />
Conversion complete
</div>
<p className="text-xs text-muted-foreground">
{result.stats?.extractedLength ?? result.markdown.length} characters extracted.
</p>
{result.warnings?.length > 0 && (
<ul className="text-xs text-muted-foreground list-disc list-inside space-y-0.5">
{result.warnings.map((w, i) => <li key={i}>{w}</li>)}
</ul>
)}
</div>
)}
{!phase && convertError && (
<div className="space-y-2 pt-1">
<div className="flex items-center gap-2 text-sm text-destructive">
<X className="h-3.5 w-3.5" />
Conversion failed.
</div>
<Button type="button" variant="outline" size="sm" onClick={onRetry} className="gap-1.5">
<RotateCcw className="h-3.5 w-3.5" />
Retry
</Button>
</div>
)}
</div>
{!phase && result && (
<div className="space-y-1.5">
<Label>Result</Label>
<div className="border rounded-md min-h-[120px] max-h-[280px] overflow-y-auto px-3 py-3">
<div className="typeset text-sm">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{result.markdown}</ReactMarkdown>
</div>
</div>
</div>
)}
</div>
);
}
// ─── Step 3 — Review ─────────────────────────────────────────────────────────────
function SummaryRow({ label, value }) {
if (!value) return null;
return (
<div className="flex justify-between py-1.5 text-sm">
<span className="text-muted-foreground min-w-[140px]">{label}</span>
<span className="text-foreground text-right">{value}</span>
</div>
);
}
function StepReview({ data, selectedAsset, result }) {
return (
<div className="space-y-4">
<div className="border border-border rounded-lg p-4 space-y-1">
<div className="flex items-center gap-2 mb-3">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Lesson</span>
</div>
<SummaryRow label="Title" value={data.title} />
<SummaryRow label="Description" value={data.description} />
<SummaryRow label="Source file" value={selectedAsset?.display_name} />
</div>
<div className="space-y-1.5">
<Label>Converted content</Label>
<div className="border rounded-md min-h-[180px] max-h-[360px] overflow-y-auto px-3 py-3">
<div className="typeset text-sm">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{result?.markdown ?? ""}</ReactMarkdown>
</div>
</div>
</div>
</div>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────────
export default function ImportLibraryLesson() {
const navigate = useNavigate();
const { createLesson, saveLessonPage, loading } = useLibrary();
const { convertAssetToMarkdown } = useAssets();
const { user } = useAuth();
const [step, setStep] = useState(0);
const [fileMissing, setFileMissing] = useState(false);
const [selectedAsset, setSelectedAsset] = useState(null);
const [phase, setPhase] = useState(null);
const [result, setResult] = useState(null);
const [convertError, setConvertError] = useState(false);
// Guards the auto-fire effect below: stores the asset_id already converted
// (or in flight) for, so re-rendering / re-entering this step with the SAME
// file never re-triggers a second API call — the one concrete mechanism
// keeping this wizard from hammering the conversion endpoint.
const firedForAssetRef = useRef(null);
const {
register, trigger, getValues,
formState: { errors, isDirty },
} = useForm({
resolver: zodResolver(schema),
defaultValues: DEFAULT_VALUES,
mode: "onTouched",
});
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty || !!selectedAsset);
const runConversion = async (asset) => {
setConvertError(false);
setResult(null);
setPhase("compiling");
const res = await convertAssetToMarkdown(asset.asset_id, {
onProgress: (data) => {
if (data.phase && data.phase !== "done" && data.phase !== "error") setPhase(data.phase);
},
});
setPhase(null);
if (res) setResult(res);
else setConvertError(true);
};
// Auto-fire exactly once per (step === 1, selectedAsset) pair.
useEffect(() => {
if (step !== 1 || !selectedAsset) return;
if (firedForAssetRef.current === selectedAsset.asset_id) return;
firedForAssetRef.current = selectedAsset.asset_id;
runConversion(selectedAsset);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step, selectedAsset]);
const handlePick = (asset) => {
setSelectedAsset({ asset_id: asset.asset_id, display_name: asset.display_name, extension: asset.extension });
setFileMissing(false);
setResult(null);
setConvertError(false);
};
const handleNext = async () => {
if (step === 0) {
const valid = await trigger(STEP_FIELDS[0]);
if (!selectedAsset) { setFileMissing(true); return; }
if (!valid) return;
}
setStep((s) => Math.min(s + 1, STEPS.length - 1));
};
const handleBack = () => {
if (step === 0) navigate("/admin/lessons");
else setStep((s) => s - 1);
};
const handleCreate = async () => {
const valid = await trigger();
if (!valid || !result || !selectedAsset) return;
const data = getValues();
const created = await createLesson({
title: data.title,
description: data.description || null,
createdBy: user?.user_id,
});
if (!created) return;
const lessonId = created?.data?.data?.lesson_id;
if (!lessonId) return;
await saveLessonPage(lessonId, {
blocks: [{
id: nanoid(),
type: "document",
content: {
source_asset_id: selectedAsset.asset_id,
source_filename: selectedAsset.display_name,
source_ext: selectedAsset.extension,
body: result.markdown,
},
}],
updatedBy: user?.user_id,
});
bypassOnce();
navigate("/admin/lessons");
};
const nextDisabled = (step === 1 && (!!phase || !result)) || loading;
return (
<section className="bg-muted min-h-full">
<PageMeta title="Import Lesson - STARR" />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
<div className="w-full max-w-3xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/lessons")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Import Lesson</h1>
<p className="text-sm text-muted-foreground">
Convert a PDF or PPTX into a ready-to-edit Lesson — text only, no images/OCR.
</p>
</div>
</div>
{/* Stepper */}
<div className="flex items-center gap-0">
{STEPS.map((s, i) => {
const Icon = s.icon;
const isActive = step === i;
const isDone = step > i;
return (
<div key={s.id} className="flex items-center flex-1 last:flex-none">
<div className="flex flex-col items-center gap-1">
<div className={cn(
"h-8 w-8 rounded-full flex items-center justify-center border transition-colors",
isDone && "bg-emerald-600 border-emerald-600 text-white",
isActive && "border-primary bg-primary text-primary-foreground",
!isActive && !isDone && "border-border bg-background text-muted-foreground"
)}>
{isDone ? <Check className="h-4 w-4" /> : <Icon className="h-4 w-4" />}
</div>
<span className={cn(
"text-[11px] font-medium whitespace-nowrap hidden sm:block",
isActive ? "text-foreground" : "text-muted-foreground",
isDone ? "text-emerald-600" : ""
)}>
{s.label}
</span>
</div>
{i < STEPS.length - 1 && (
<div className={cn(
"flex-1 h-px mx-2 mb-4 transition-colors",
step > i ? "bg-emerald-600" : "bg-border"
)} />
)}
</div>
);
})}
</div>
{/* Step content */}
<div className="rounded-lg border bg-card p-6 min-h-[320px]">
<h2 className="text-base font-medium mb-4">{STEPS[step].label}</h2>
{step === 0 && (
<StepImport
register={register}
errors={errors}
selectedAsset={selectedAsset}
onPick={handlePick}
fileMissing={fileMissing}
/>
)}
{step === 1 && (
<StepProcessing
selectedAsset={selectedAsset}
phase={phase}
result={result}
convertError={convertError}
onRetry={() => runConversion(selectedAsset)}
/>
)}
{step === 2 && (
<StepReview data={getValues()} selectedAsset={selectedAsset} result={result} />
)}
</div>
{/* Navigation */}
<div className="flex items-center justify-between gap-3">
<Button type="button" variant="outline" onClick={handleBack} disabled={loading || !!phase}>
<ChevronLeft className="h-4 w-4 mr-1" />
{step === 0 ? "Cancel" : "Back"}
</Button>
{step < STEPS.length - 1 ? (
<Button type="button" onClick={handleNext} disabled={nextDisabled}>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
) : (
<Button type="button" onClick={handleCreate} disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create Lesson
</Button>
)}
</div>
</div>
</div>
{unsavedChangesDialog}
</section>
);
}
+2
View File
@@ -68,6 +68,7 @@ import ViewLibraryUnit from '../pages/library/units/ViewLibraryUnit'
import ArchivedUnitLibraryList from '../pages/library/units/ArchivedUnitLibraryList'
import LessonLibraryList from '../pages/library/lessons/LessonLibraryList'
import AddLibraryLesson from '../pages/library/lessons/AddLibraryLesson'
import ImportLibraryLesson from '../pages/library/lessons/ImportLibraryLesson'
import EditLibraryLesson from '../pages/library/lessons/EditLibraryLesson'
import ViewLibraryLesson from '../pages/library/lessons/ViewLibraryLesson'
import ArchivedLessonLibraryList from '../pages/library/lessons/ArchivedLessonLibraryList'
@@ -264,6 +265,7 @@ export const AdminRoutes = {
children: [
{ index: true, element: <LessonLibraryList /> },
{ path: 'add', element: <AddLibraryLesson /> },
{ path: 'import', element: <ImportLibraryLesson /> },
{ path: 'archived', element: <ArchivedLessonLibraryList /> },
{ path: ':lessonId/view', element: <ViewLibraryLesson /> },
{ path: ':lessonId/edit', element: <EditLibraryLesson /> },