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:
@@ -1,6 +1,6 @@
|
|||||||
// components/generic/CMS/AddBlockMenu.jsx
|
// 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 { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -60,6 +60,12 @@ const BLOCK_TYPES = [
|
|||||||
description: "Rich text written in Markdown",
|
description: "Rich text written in Markdown",
|
||||||
icon: <FileText className="h-4 w-4" />,
|
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 }) {
|
export function AddBlockMenu({ onAdd }) {
|
||||||
|
|||||||
@@ -71,7 +71,11 @@ function EmptyState({ fileType }) {
|
|||||||
|
|
||||||
// ─── Main Sheet ───────────────────────────────────────────────────────────────
|
// ─── 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
|
// NOTE: previously this returned null before any hooks ran when `open` was
|
||||||
// false. Since the parent renders this component unconditionally (only the
|
// false. Since the parent renders this component unconditionally (only the
|
||||||
// `open` prop toggles), that meant React remounted every hook from scratch
|
// `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 isFirstSearchRun = useRef(true);
|
||||||
const LIMIT = 12;
|
const LIMIT = 12;
|
||||||
|
|
||||||
const extOptions = EXT_OPTIONS[fileType] ?? [];
|
const extOptions = allowedExtensions ?? EXT_OPTIONS[fileType] ?? [];
|
||||||
|
|
||||||
const resolveStreamSrc = useCallback((assetId) => {
|
const resolveStreamSrc = useCallback((assetId) => {
|
||||||
const entry = mediaTokens[String(assetId)];
|
const entry = mediaTokens[String(assetId)];
|
||||||
@@ -102,13 +106,17 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
|
|||||||
|
|
||||||
// ── Build and fire fetch ──────────────────────────────────────────────────
|
// ── Build and fire fetch ──────────────────────────────────────────────────
|
||||||
const doFetch = useCallback((searchVal, extSet, pg) => {
|
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 = [
|
const filters = [
|
||||||
...(fileType ? [{ id: "file_type", value: [fileType] }] : []),
|
...(fileType ? [{ id: "file_type", value: [fileType] }] : []),
|
||||||
...(searchVal.trim() ? [{ id: "display_name", value: [searchVal.trim()] }] : []),
|
...(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 });
|
fetchAssets({ page: pg, limit: LIMIT, filters });
|
||||||
}, [fileType, fetchAssets]);
|
}, [fileType, fetchAssets, allowedExtensions]);
|
||||||
|
|
||||||
// ── Immediate fetch: on open, or when filters/page change while open ──────
|
// ── Immediate fetch: on open, or when filters/page change while open ──────
|
||||||
// (fetchAssets itself is TTL-cached in AdminAssetsContext, so reopening
|
// (fetchAssets itself is TTL-cached in AdminAssetsContext, so reopening
|
||||||
@@ -236,7 +244,7 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</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">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">File type</p>
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">File type</p>
|
||||||
{hasActiveFilters && (
|
{hasActiveFilters && (
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { TextVideoBlock } from "./Blocks/Admin/TextVideoBlock";
|
|||||||
import { AudioBlock } from "./Blocks/Admin/AudioBlock";
|
import { AudioBlock } from "./Blocks/Admin/AudioBlock";
|
||||||
import { CodeBlock } from "./Blocks/Admin/CodeBlock";
|
import { CodeBlock } from "./Blocks/Admin/CodeBlock";
|
||||||
import { MarkdownBlock } from "./Blocks/Admin/MarkdownBlock";
|
import { MarkdownBlock } from "./Blocks/Admin/MarkdownBlock";
|
||||||
|
import { DocumentBlock } from "./Blocks/Admin/DocumentBlock";
|
||||||
|
|
||||||
// ─── Block renderer ───────────────────────────────────────────────────────────
|
// ─── Block renderer ───────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
@@ -41,6 +42,8 @@ function BlockContent({ block, onUpdate }) {
|
|||||||
return <CodeBlock content={content} onUpdate={onUpdate} />;
|
return <CodeBlock content={content} onUpdate={onUpdate} />;
|
||||||
case "markdown":
|
case "markdown":
|
||||||
return <MarkdownBlock content={content} onUpdate={onUpdate} />;
|
return <MarkdownBlock content={content} onUpdate={onUpdate} />;
|
||||||
|
case "document":
|
||||||
|
return <DocumentBlock content={content} onUpdate={onUpdate} />;
|
||||||
default:
|
default:
|
||||||
return <p className="text-sm text-muted-foreground">Unknown block type.</p>;
|
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 },
|
"audio": { asset_id: null, url: "", title: "", artist: "", tag: "", thumbnail: "", duration_seconds: 0 },
|
||||||
"code": { language: "javascript", code: "" },
|
"code": { language: "javascript", code: "" },
|
||||||
"markdown": { body: "" },
|
"markdown": { body: "" },
|
||||||
|
"document": { source_asset_id: null, source_filename: null, source_ext: null, body: "" },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── List ─────────────────────────────────────────────────────────────────────
|
// ─── 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 ?? "" }} />;
|
||||||
|
}
|
||||||
@@ -4,19 +4,20 @@ import api from "@/utils/api.util";
|
|||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
// ─── Upload progress stream (Express -> Garage, real bytes) ───────────────────
|
// ─── Generic authenticated SSE reader ──────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Native EventSource can't set the Authorization header this app authenticates
|
// Native EventSource can't set the Authorization header this app authenticates
|
||||||
// with, so GET /admin/assets/upload-progress/:uploadId is consumed via a
|
// with, so SSE endpoints are consumed via a manually-parsed, authenticated
|
||||||
// manually-parsed, authenticated fetch() stream instead of EventSource.
|
// fetch() stream instead of EventSource. Returns a stop() function. Failures
|
||||||
// Returns a stop() function. Failures here are swallowed on purpose — this is
|
// here are swallowed on purpose — this is a best-effort progress signal on
|
||||||
// a best-effort visual on top of the real upload, never load-bearing for it.
|
// top of a request that already carries its own real result, never
|
||||||
function streamUploadProgress(uploadId, token, onProgress) {
|
// load-bearing on its own.
|
||||||
|
function streamSSE(url, token, onEvent) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${import.meta.env.VITE_API_URL}/admin/assets/upload-progress/${uploadId}`, {
|
const res = await fetch(url, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
@@ -35,18 +36,28 @@ function streamUploadProgress(uploadId, token, onProgress) {
|
|||||||
const line = chunk.split("\n").find((l) => l.startsWith("data: "));
|
const line = chunk.split("\n").find((l) => l.startsWith("data: "));
|
||||||
if (!line) continue;
|
if (!line) continue;
|
||||||
const data = JSON.parse(line.slice(6));
|
const data = JSON.parse(line.slice(6));
|
||||||
onProgress(data);
|
onEvent(data);
|
||||||
if (data.done) return;
|
if (data.done) return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.name !== "AbortError") console.warn("[ASSET][UPLOAD PROGRESS STREAM]", err.message);
|
if (err.name !== "AbortError") console.warn("[SSE STREAM]", url, err.message);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Upload progress (Express -> Garage, real bytes) — see uploadAsset() below.
|
||||||
|
const streamUploadProgress = (uploadId, token, onProgress) =>
|
||||||
|
streamSSE(`${import.meta.env.VITE_API_URL}/admin/assets/upload-progress/${uploadId}`, token, onProgress);
|
||||||
|
|
||||||
|
// Document-conversion stage progress (compiling/validating/generating) — see
|
||||||
|
// convertAssetToMarkdown() below. Same broadcaster/channel shape on the
|
||||||
|
// backend (services/uploadProgress.service.js), just a different job id.
|
||||||
|
const streamConvertProgress = (jobId, token, onProgress) =>
|
||||||
|
streamSSE(`${import.meta.env.VITE_API_URL}/admin/assets/convert-progress/${jobId}`, token, onProgress);
|
||||||
|
|
||||||
const AssetsContext = createContext(null);
|
const AssetsContext = createContext(null);
|
||||||
|
|
||||||
export function useAssets() {
|
export function useAssets() {
|
||||||
@@ -279,6 +290,34 @@ export function AssetsProvider({ children }) {
|
|||||||
[request, accessTokenRef]
|
[request, accessTokenRef]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── POST /api/admin/assets/:assetId/convert-to-markdown ─────────────────
|
||||||
|
//
|
||||||
|
// PDF/PPTX -> Markdown, text only (see documentConversion.service.js on
|
||||||
|
// the backend for why OCR/images are out of scope). Nothing is persisted
|
||||||
|
// by this call — the result is a draft the caller (Document Import block)
|
||||||
|
// only keeps if the admin explicitly inserts it. On failure this resolves
|
||||||
|
// to null (the shared `request()` wrapper already toasts the backend's
|
||||||
|
// specific error message, e.g. "No readable text found...").
|
||||||
|
//
|
||||||
|
// onProgress?: ({ phase: 'compiling'|'validating'|'generating'|'done'|'error' }) => void
|
||||||
|
const convertAssetToMarkdown = useCallback(
|
||||||
|
(assetId, { onProgress } = {}) =>
|
||||||
|
request(async () => {
|
||||||
|
const jobId = nanoid();
|
||||||
|
const stopStream = onProgress
|
||||||
|
? streamConvertProgress(jobId, accessTokenRef.current, onProgress)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.post(`/admin/assets/${assetId}/convert-to-markdown`, { jobId });
|
||||||
|
return res.data?.data ?? null;
|
||||||
|
} finally {
|
||||||
|
stopStream?.();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[request, accessTokenRef]
|
||||||
|
);
|
||||||
|
|
||||||
// ─── PATCH /api/admin/assets/:assetId ────────────────────────────────────
|
// ─── PATCH /api/admin/assets/:assetId ────────────────────────────────────
|
||||||
const updateAsset = useCallback(
|
const updateAsset = useCallback(
|
||||||
(assetId, fields, file = null) =>
|
(assetId, fields, file = null) =>
|
||||||
@@ -421,6 +460,7 @@ export function AssetsProvider({ children }) {
|
|||||||
fetchAsset,
|
fetchAsset,
|
||||||
fetchArchivedAssets,
|
fetchArchivedAssets,
|
||||||
uploadAsset,
|
uploadAsset,
|
||||||
|
convertAssetToMarkdown,
|
||||||
updateAsset,
|
updateAsset,
|
||||||
archiveAsset,
|
archiveAsset,
|
||||||
archiveAssets,
|
archiveAssets,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { TextBlock } from "@/components/generic/Blocks/Client/TextBlock";
|
|||||||
import { AudioBlock } from "@/components/generic/Blocks/Client/AudioBlock";
|
import { AudioBlock } from "@/components/generic/Blocks/Client/AudioBlock";
|
||||||
import { CodeBlock } from "@/components/generic/Blocks/Client/CodeBlock";
|
import { CodeBlock } from "@/components/generic/Blocks/Client/CodeBlock";
|
||||||
import { MarkdownBlock } from "@/components/generic/Blocks/Client/MarkdownBlock";
|
import { MarkdownBlock } from "@/components/generic/Blocks/Client/MarkdownBlock";
|
||||||
|
import { DocumentBlock } from "@/components/generic/Blocks/Client/DocumentBlock";
|
||||||
|
|
||||||
export function LessonHeader({ lesson }) {
|
export function LessonHeader({ lesson }) {
|
||||||
if (!lesson) return null;
|
if (!lesson) return null;
|
||||||
@@ -159,6 +160,8 @@ export function PreviewBlock({ block, onWatchProgress, resumeMap, antiSkipEnable
|
|||||||
return <CodeBlock content={content} />;
|
return <CodeBlock content={content} />;
|
||||||
case "markdown":
|
case "markdown":
|
||||||
return <MarkdownBlock content={content} />;
|
return <MarkdownBlock content={content} />;
|
||||||
|
case "document":
|
||||||
|
return <DocumentBlock content={content} />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// config/library/lessons/toolbar.config.jsx
|
// config/library/lessons/toolbar.config.jsx
|
||||||
// Toolbar actions for the Lesson Library (active + archived variants).
|
// 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";
|
import { exportTableToExcel } from "@/utils/excel.util";
|
||||||
|
|
||||||
export function buildToolbarActions({
|
export function buildToolbarActions({
|
||||||
@@ -46,6 +46,14 @@ export function buildToolbarActions({
|
|||||||
variant: "default",
|
variant: "default",
|
||||||
onClick: () => navigate("/admin/lessons/add"),
|
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",
|
key: "archived-lessons",
|
||||||
type: "button",
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -68,6 +68,7 @@ import ViewLibraryUnit from '../pages/library/units/ViewLibraryUnit'
|
|||||||
import ArchivedUnitLibraryList from '../pages/library/units/ArchivedUnitLibraryList'
|
import ArchivedUnitLibraryList from '../pages/library/units/ArchivedUnitLibraryList'
|
||||||
import LessonLibraryList from '../pages/library/lessons/LessonLibraryList'
|
import LessonLibraryList from '../pages/library/lessons/LessonLibraryList'
|
||||||
import AddLibraryLesson from '../pages/library/lessons/AddLibraryLesson'
|
import AddLibraryLesson from '../pages/library/lessons/AddLibraryLesson'
|
||||||
|
import ImportLibraryLesson from '../pages/library/lessons/ImportLibraryLesson'
|
||||||
import EditLibraryLesson from '../pages/library/lessons/EditLibraryLesson'
|
import EditLibraryLesson from '../pages/library/lessons/EditLibraryLesson'
|
||||||
import ViewLibraryLesson from '../pages/library/lessons/ViewLibraryLesson'
|
import ViewLibraryLesson from '../pages/library/lessons/ViewLibraryLesson'
|
||||||
import ArchivedLessonLibraryList from '../pages/library/lessons/ArchivedLessonLibraryList'
|
import ArchivedLessonLibraryList from '../pages/library/lessons/ArchivedLessonLibraryList'
|
||||||
@@ -264,6 +265,7 @@ export const AdminRoutes = {
|
|||||||
children: [
|
children: [
|
||||||
{ index: true, element: <LessonLibraryList /> },
|
{ index: true, element: <LessonLibraryList /> },
|
||||||
{ path: 'add', element: <AddLibraryLesson /> },
|
{ path: 'add', element: <AddLibraryLesson /> },
|
||||||
|
{ path: 'import', element: <ImportLibraryLesson /> },
|
||||||
{ path: 'archived', element: <ArchivedLessonLibraryList /> },
|
{ path: 'archived', element: <ArchivedLessonLibraryList /> },
|
||||||
{ path: ':lessonId/view', element: <ViewLibraryLesson /> },
|
{ path: ':lessonId/view', element: <ViewLibraryLesson /> },
|
||||||
{ path: ':lessonId/edit', element: <EditLibraryLesson /> },
|
{ path: ':lessonId/edit', element: <EditLibraryLesson /> },
|
||||||
|
|||||||
Reference in New Issue
Block a user