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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {selectedAsset ? (
+
+
+
+ {selectedAsset.display_name}
+
+
+
+ ) : (
+
+ )}
+ {fileMissing &&
}
+
+
+
+
+ );
+}
+
+// ─── Step 2 — Processing ────────────────────────────────────────────────────────
+function StepProcessing({ selectedAsset, phase, result, convertError, onRetry }) {
+ return (
+
+
+
{selectedAsset?.display_name}
+ {phase &&
}
+
+ {!phase && result && (
+
+
+
+ Conversion complete
+
+
+ {result.stats?.extractedLength ?? result.markdown.length} characters extracted.
+
+ {result.warnings?.length > 0 && (
+
+ {result.warnings.map((w, i) => - {w}
)}
+
+ )}
+
+ )}
+
+ {!phase && convertError && (
+
+
+
+ Conversion failed.
+
+
+
+ )}
+
+
+ {!phase && result && (
+
+
+
+
+ {result.markdown}
+
+
+
+ )}
+
+ );
+}
+
+// ─── Step 3 — Review ─────────────────────────────────────────────────────────────
+function SummaryRow({ label, value }) {
+ if (!value) return null;
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+function StepReview({ data, selectedAsset, result }) {
+ return (
+
+
+
+
+
+
+
+ {result?.markdown ?? ""}
+
+
+
+
+ );
+}
+
+// ─── 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 (
+
+
+
+
+
+
+
+
+
Import Lesson
+
+ Convert a PDF or PPTX into a ready-to-edit Lesson — text only, no images/OCR.
+
+
+
+
+ {/* Stepper */}
+
+ {STEPS.map((s, i) => {
+ const Icon = s.icon;
+ const isActive = step === i;
+ const isDone = step > i;
+
+ return (
+
+
+
+ {isDone ? : }
+
+
+ {s.label}
+
+
+ {i < STEPS.length - 1 && (
+
i ? "bg-emerald-600" : "bg-border"
+ )} />
+ )}
+
+ );
+ })}
+
+
+ {/* Step content */}
+
+
{STEPS[step].label}
+
+ {step === 0 && (
+
+ )}
+ {step === 1 && (
+ runConversion(selectedAsset)}
+ />
+ )}
+ {step === 2 && (
+
+ )}
+
+
+ {/* Navigation */}
+
+
+
+ {step < STEPS.length - 1 ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {unsavedChangesDialog}
+
+ );
+}
diff --git a/src/modules/admin/routes/AdminRoutes.jsx b/src/modules/admin/routes/AdminRoutes.jsx
index 25d5591..a69ddb2 100644
--- a/src/modules/admin/routes/AdminRoutes.jsx
+++ b/src/modules/admin/routes/AdminRoutes.jsx
@@ -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:
},
{ path: 'add', element:
},
+ { path: 'import', element:
},
{ path: 'archived', element:
},
{ path: ':lessonId/view', element:
},
{ path: ':lessonId/edit', element:
},