diff --git a/src/components/generic/AddBlockMenu.jsx b/src/components/generic/AddBlockMenu.jsx index 8fce213..26bd37e 100644 --- a/src/components/generic/AddBlockMenu.jsx +++ b/src/components/generic/AddBlockMenu.jsx @@ -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: , }, + { + type: "document", + label: "Document", + description: "Upload a PDF/PPTX only, auto-convert to Markdown", + icon: , + }, ]; export function AddBlockMenu({ onAdd }) { diff --git a/src/components/generic/AssetPickerSheet.jsx b/src/components/generic/AssetPickerSheet.jsx index 5d51545..e3908f6 100644 --- a/src/components/generic/AssetPickerSheet.jsx +++ b/src/components/generic/AssetPickerSheet.jsx @@ -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 }) { )} - +

File type

{hasActiveFilters && ( diff --git a/src/components/generic/BlockList.jsx b/src/components/generic/BlockList.jsx index 82b39c8..95e7a52 100644 --- a/src/components/generic/BlockList.jsx +++ b/src/components/generic/BlockList.jsx @@ -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 ; case "markdown": return ; + case "document": + return ; default: return

Unknown block type.

; } @@ -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 ───────────────────────────────────────────────────────────────────── diff --git a/src/components/generic/Blocks/Admin/DocumentBlock.jsx b/src/components/generic/Blocks/Admin/DocumentBlock.jsx new file mode 100644 index 0000000..829a0f1 --- /dev/null +++ b/src/components/generic/Blocks/Admin/DocumentBlock.jsx @@ -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 ( +
+ {STAGES.map((s, i) => { + const state = activeIndex > i ? "done" : activeIndex === i ? "active" : "pending"; + return ( +
+ {state === "done" ? ( + + ) : state === "active" ? ( + + ) : ( + + )} + {s.label}… +
+ ); + })} +
+ ); +} + +// ─── 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 ( +
+ {!readOnly && ( +
+ + {content.source_filename && ( +
+ + {content.source_filename} + +
+ )} +
+ )} + + {!readOnly && ( + + )} +
+ ); + } + + // ── Converting ────────────────────────────────────────────────────────── + if (phase) { + return ( +
+ +
+

{pendingAsset?.display_name}

+ +
+
+ ); + } + + // ── Draft review ──────────────────────────────────────────────────────── + if (draft) { + return ( +
+ +
+
+ + Converted from {draft.asset.display_name} — review before inserting + + +
+ + {draft.warnings.length > 0 && ( +
+ {draft.warnings.map((w, i) => ( + + {w} + + ))} +
+ )} + +
+ {preview ? ( +
+ {draft.markdown} +
+ ) : ( +
{draft.markdown}
+ )} +
+ +
+ + +
+
+
+ ); + } + + // ── Empty: pick a file ───────────────────────────────────────────────── + return ( +
+ {!readOnly && } + + + {!readOnly && ( + + )} +
+ ); +} diff --git a/src/components/generic/Blocks/Client/DocumentBlock.jsx b/src/components/generic/Blocks/Client/DocumentBlock.jsx new file mode 100644 index 0000000..f1e593b --- /dev/null +++ b/src/components/generic/Blocks/Client/DocumentBlock.jsx @@ -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 ; +} diff --git a/src/contexts/AdminAssetsContext.jsx b/src/contexts/AdminAssetsContext.jsx index 2d4ef48..d083c95 100644 --- a/src/contexts/AdminAssetsContext.jsx +++ b/src/contexts/AdminAssetsContext.jsx @@ -4,19 +4,20 @@ import api from "@/utils/api.util"; import { useAuth } from "@/contexts/AuthContext"; 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 -// with, so GET /admin/assets/upload-progress/:uploadId is consumed via a -// manually-parsed, authenticated fetch() stream instead of EventSource. -// Returns a stop() function. Failures here are swallowed on purpose — this is -// a best-effort visual on top of the real upload, never load-bearing for it. -function streamUploadProgress(uploadId, token, onProgress) { +// with, so SSE endpoints are consumed via a manually-parsed, authenticated +// fetch() stream instead of EventSource. Returns a stop() function. Failures +// here are swallowed on purpose — this is a best-effort progress signal on +// top of a request that already carries its own real result, never +// load-bearing on its own. +function streamSSE(url, token, onEvent) { const controller = new AbortController(); (async () => { 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, signal: controller.signal, }); @@ -35,18 +36,28 @@ function streamUploadProgress(uploadId, token, onProgress) { const line = chunk.split("\n").find((l) => l.startsWith("data: ")); if (!line) continue; const data = JSON.parse(line.slice(6)); - onProgress(data); + onEvent(data); if (data.done) return; } } } 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(); } +// 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); export function useAssets() { @@ -279,6 +290,34 @@ export function AssetsProvider({ children }) { [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 ──────────────────────────────────── const updateAsset = useCallback( (assetId, fields, file = null) => @@ -421,6 +460,7 @@ export function AssetsProvider({ children }) { fetchAsset, fetchArchivedAssets, uploadAsset, + convertAssetToMarkdown, updateAsset, archiveAsset, archiveAssets, diff --git a/src/modules/admin/components/courses/LessonsPreview.jsx b/src/modules/admin/components/courses/LessonsPreview.jsx index 587e22b..203f1f9 100644 --- a/src/modules/admin/components/courses/LessonsPreview.jsx +++ b/src/modules/admin/components/courses/LessonsPreview.jsx @@ -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 ; case "markdown": return ; + case "document": + return ; default: return null; } diff --git a/src/modules/admin/config/library/lessons/toolbar.config.jsx b/src/modules/admin/config/library/lessons/toolbar.config.jsx index b07d641..7dfc066 100644 --- a/src/modules/admin/config/library/lessons/toolbar.config.jsx +++ b/src/modules/admin/config/library/lessons/toolbar.config.jsx @@ -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: , + variant: "outline", + onClick: () => navigate("/admin/lessons/import"), + }, { key: "archived-lessons", type: "button", diff --git a/src/modules/admin/pages/library/lessons/ImportLibraryLesson.jsx b/src/modules/admin/pages/library/lessons/ImportLibraryLesson.jsx new file mode 100644 index 0000000..bcff293 --- /dev/null +++ b/src/modules/admin/pages/library/lessons/ImportLibraryLesson.jsx @@ -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

{message}

; +} + +function StageProgress({ phase }) { + const activeIndex = STAGES.findIndex((s) => s.phase === phase); + return ( +
+ {STAGES.map((s, i) => { + const state = activeIndex > i ? "done" : activeIndex === i ? "active" : "pending"; + return ( +
+ {state === "done" ? ( + + ) : state === "active" ? ( + + ) : ( + + )} + {s.label}… +
+ ); + })} +
+ ); +} + +// ─── Step 1 — Import ──────────────────────────────────────────────────────────── +function StepImport({ register, errors, selectedAsset, onPick, fileMissing }) { + const [pickerOpen, setPickerOpen] = useState(false); + + return ( +
+
+ + + +
+ +
+ +