-
-
- {topicLabel(viewing.topic)}
-
-
- {statusLabel(viewing.status)}
-
+
{statusLabel(message.status)}
+
+ openMessage(message)} aria-label={t("admin.contact.actions.view")}>
+ {message.status !== "resolved" ? (
+ void setStatus(message, "resolved")} aria-label={t("admin.contact.actions.markResolved")}>
+ ) : (
+ void setStatus(message, "read")} aria-label={t("admin.contact.actions.reopen")}>
+ )}
+ setDeleting(message)} aria-label={t("admin.contact.actions.delete")}>
-
-
- {t("admin.contact.dialog.messageFrom").replace("{name}", viewing.name)}
-
-
-
-
-
- {(viewing.name || "U")[0]?.toUpperCase()}
-
-
-
-
-
-
- {viewing.name}
- {viewing.user_id && (
-
- {t("admin.contact.dialog.registeredBadge")}
-
- )}
-
-
-
-
-
- {new Date(viewing.created_at).toLocaleString(
- locale === "ro" ? "ro-RO" : "en-US"
- )}
-
+
+ ))}
+
+ )}
+
+
+
{ if (!open) { setViewing(null); setReplyDraft(null); } }}>
+
+ {viewing && replyDraft && (
+ <>
+
+ {copy.messageDetails}
+ {copy.messageDescription}
+
+
+
+
+ {topicLabel(viewing.topic)}
+ {statusLabel(viewing.status)}
+ {viewing.user_id && {t("admin.contact.dialog.registeredBadge")} }
+
+
+
{viewing.name.charAt(0).toUpperCase()}
+
-
-
-
-
-
- {t("admin.contact.dialog.messageLabel")}
-
-
-
- {viewing.description}
-
-
-
-
-
-
{
- setDeleting(viewing);
- setViewing(null);
- }}
- >
-
- {t("admin.contact.actions.delete")}
-
-
-
-
-
- {t("admin.contact.actions.reply")}
-
-
+
+
{copy.sender} {viewing.email}
+
{copy.received} {formatDate(viewing.created_at, locale)}
+
+
+
{t("admin.contact.dialog.messageLabel")}
+
+
+
+ {viewing.status !== "resolved" ? (
+ void setStatus(viewing, "resolved")}> {t("admin.contact.actions.markResolved")}
+ ) : (
+ void setStatus(viewing, "read")}> {t("admin.contact.actions.reopen")}
+ )}
+ { setDeleting(viewing); setViewing(null); }}> {t("admin.contact.actions.delete")}
+
+
- {viewing.status !== "resolved" ? (
-
{
- await setStatus(viewing.id, "resolved");
- setViewing({ ...viewing, status: "resolved" });
- }}
- >
-
- {t("admin.contact.actions.markResolved")}
-
- ) : (
-
{
- await setStatus(viewing.id, "read");
- setViewing({ ...viewing, status: "read" });
- }}
- >
- {t("admin.contact.actions.reopen")}
+
+
+
{copy.response}
+
{copy.responseDescription}
+
+
+ {copy.fromName} setReplyDraft({ ...replyDraft, senderName: event.target.value })} />
+
+ {copy.emailFormat}
+ setReplyDraft({ ...replyDraft, mode: value as ReplyMode })}>
+
+ {copy.html} {copy.plain}
+
+
+
+
+ {copy.fromAddress}
+
+ setReplyDraft({ ...replyDraft, senderLocalPart: event.target.value })} className="min-w-0 flex-1 bg-transparent px-3 text-sm outline-none" aria-label={copy.fromAddress} />
+ @scripticx.org
+
+
+ {copy.subject} setReplyDraft({ ...replyDraft, subject: event.target.value })} />
+ {copy.body}
+
+
{copy.sentFrom} {replyDraft.senderLocalPart || "support"}@scripticx.org .
+
{viewing.user_id ? copy.registeredHint : copy.guestHint}
+
+ void sendReply()} disabled={sendingReply}>
+ {sendingReply ? : }{sendingReply ? copy.sending : copy.send}
- )}
+
-
-
+ >
)}
-
!o && setDeleting(null)}
- >
+ { if (!open) setDeleting(null); }}>
{t("admin.contact.deleteDialog.title")}
-
- {t("admin.contact.deleteDialog.description").replace("{name}", deleting?.name ?? "")}
-
+ {t("admin.contact.deleteDialog.description").replace("{name}", deleting?.name || "")}
{t("admin.contact.deleteDialog.cancel")}
-
- {t("admin.contact.deleteDialog.confirm")}
-
+ void confirmDelete()} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">{t("admin.contact.deleteDialog.confirm")}
-
-
+
);
}
export default function AdminContactPage() {
- return (
-
-
-
- );
+ return
;
}
diff --git a/app/admin/design-system/page.tsx b/app/admin/design-system/page.tsx
new file mode 100644
index 0000000..55a70c0
--- /dev/null
+++ b/app/admin/design-system/page.tsx
@@ -0,0 +1,20 @@
+import type { Metadata } from "next";
+
+import RouteGuard from "@/components/RouteGuard";
+import { DesignSystemShowcase } from "@/components/admin/design-system/DesignSystemShowcase";
+import { createPageMetadata } from "@/lib/metadata";
+
+export const metadata: Metadata = createPageMetadata({
+ title: "Design system",
+ description: "The internal ScripticX interface foundations and component reference.",
+ path: "/admin/design-system",
+ noIndex: true,
+});
+
+export default function DesignSystemPage() {
+ return (
+
+
+
+ );
+}
diff --git a/app/admin/email/page.tsx b/app/admin/email/page.tsx
new file mode 100644
index 0000000..ecbac7d
--- /dev/null
+++ b/app/admin/email/page.tsx
@@ -0,0 +1,12 @@
+"use client";
+
+import RouteGuard from "@/components/RouteGuard";
+import { EmailCenter } from "@/components/admin/email/EmailCenter";
+
+export default function AdminEmailPage() {
+ return (
+
+
+
+ );
+}
diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx
index b040410..31c8d72 100644
--- a/app/admin/layout.tsx
+++ b/app/admin/layout.tsx
@@ -1,3 +1,4 @@
+import RouteGuard from "@/components/RouteGuard";
import type { Metadata } from "next";
import { createPageMetadata } from "@/lib/metadata";
@@ -12,5 +13,5 @@ export const metadata: Metadata = createPageMetadata({
export default function AdminLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
- return children;
+ return
{children} ;
}
diff --git a/app/admin/lessons/page.tsx b/app/admin/lessons/page.tsx
new file mode 100644
index 0000000..932f707
--- /dev/null
+++ b/app/admin/lessons/page.tsx
@@ -0,0 +1,4424 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import Link from "next/link";
+import {
+ ArrowLeft,
+ ArrowLeftRight,
+ BookOpen,
+ Check,
+ ClipboardList,
+ Code2,
+ Copy,
+ Eye,
+ EyeOff,
+ Grip,
+ Link2,
+ Maximize2,
+ Minus,
+ MousePointer2,
+ Plus,
+ Save,
+ Scaling,
+ Search,
+ Settings2,
+ Shuffle,
+ Sparkles,
+ Timer,
+ Trash2,
+ Trophy,
+ Play,
+ Video,
+ Columns2,
+ FileText,
+ PencilLine,
+ X,
+} from "lucide-react";
+
+import RouteGuard from "@/components/RouteGuard";
+import { Markdown } from "@/components/Markdown";
+import { useLanguage } from "@/components/LanguageProvider";
+import { MiniScriptMonacoEditor } from "@/components/editor/MiniScriptMonacoEditor";
+import { useUndoHistory } from "@/hooks/useUndoHistory";
+import { Badge } from "@/components/ui/badge";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuLabel,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from "@/components/ui/context-menu";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import {
+ Popover,
+ PopoverContent,
+ PopoverHeader,
+ PopoverTitle,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Separator } from "@/components/ui/separator";
+import { Slider } from "@/components/ui/slider";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import {
+ getLessonCompletionRequirement,
+ getLessonKind,
+ getLessonRule,
+ learnLessons,
+ learnSections,
+ text,
+ type LearnLesson,
+ type LearnSection,
+ type LessonCompletionRequirement,
+ type LessonRuleKind,
+ type LessonLocale,
+} from "@/lib/learn-lessons";
+import { getLessonMarkdown } from "@/lib/lesson-markdown";
+import {
+ buildDefaultRoadmapConnections,
+ clearRoadmapConfig,
+ defaultRoadmapCategories,
+ getRoadmapConfigData,
+ readRoadmapConfig,
+ readRemoteRoadmapConfig,
+ writeRoadmapConfig,
+ writeRemoteRoadmapConfig,
+ type RoadmapConfig,
+ type RoadmapCategory,
+ type RoadmapConfigConnection,
+ type RoadmapConnectionSide,
+ type RoadmapSectionFrame,
+ type LearningPathAvailability,
+ type LearningPathKind,
+} from "@/lib/roadmap-config";
+import { cn } from "@/lib/utils";
+import { toast } from "sonner";
+
+type CanvasNode = {
+ id: string;
+ lesson: LearnLesson;
+ sectionId: string;
+ x: number;
+ y: number;
+};
+
+type ResizableWidgetId = "inspector";
+
+type DragState =
+ | {
+ type: "nodes";
+ ids: string[];
+ startX: number;
+ startY: number;
+ origins: Record
;
+ }
+ | { type: "canvas"; startX: number; startY: number; originX: number; originY: number }
+ | { type: "selection"; startX: number; startY: number; currentX: number; currentY: number }
+ | {
+ type: "connection";
+ sourceId: string;
+ sourceSide: RoadmapConnectionSide;
+ currentX: number;
+ currentY: number;
+ targetId?: string;
+ targetSide?: RoadmapConnectionSide;
+ }
+ | {
+ type: "section-resize";
+ corner: "nw" | "ne" | "sw" | "se";
+ origin: RoadmapSectionFrame;
+ sectionId: string;
+ startX: number;
+ startY: number;
+ }
+ | {
+ type: "section-move";
+ frameOrigin: RoadmapSectionFrame;
+ nodeOrigins: Record;
+ sectionId: string;
+ startX: number;
+ startY: number;
+ }
+ | {
+ type: "widget-resize";
+ horizontalDirection: -1 | 1;
+ originHeight: number;
+ originWidth: number;
+ startX: number;
+ startY: number;
+ widget: ResizableWidgetId;
+ }
+ | null;
+
+type ConnectionDraft = Extract, { type: "connection" }>;
+
+type LocalizedDraftText = Record;
+
+type QuizDraft = {
+ answerIndex: number;
+ options: LocalizedDraftText[];
+ question: LocalizedDraftText;
+};
+
+type LessonDraft = {
+ code: string;
+ completionRequirement: LessonCompletionRequirement;
+ kind: NonNullable;
+ level: LearnLesson["level"];
+ locked: boolean;
+ markdown: LocalizedDraftText;
+ minutes: number;
+ quiz: QuizDraft[];
+ requiredProblemCodes: string;
+ requiresCorrectQuiz: boolean;
+ sampleInput: string;
+ summary: LocalizedDraftText;
+ tags: string;
+ title: LocalizedDraftText;
+ transcript: LocalizedDraftText;
+ videoUrl: string;
+};
+
+type LessonConfiguratorSnapshot = {
+ categories: RoadmapCategory[];
+ connections: RoadmapConfigConnection[];
+ drafts: Record;
+ nodes: CanvasNode[];
+ sectionFrames: RoadmapSectionFrame[];
+ sections: LearnSection[];
+};
+
+type SelectionRect = {
+ currentX: number;
+ currentY: number;
+ startX: number;
+ startY: number;
+};
+
+type ClipboardNode = {
+ draft: LessonDraft;
+ node: CanvasNode;
+ offsetX: number;
+ offsetY: number;
+};
+
+const NODE_WIDTH = 210;
+const NODE_HEIGHT = 94;
+const NODE_COLUMN_GAP = 400;
+const NODE_ROW_GAP = 180;
+const SECTION_COLUMNS = 4;
+const SECTION_GAP = 480;
+const SECTION_FRAME_GAP = 260;
+const MIN_ZOOM = 55;
+const MAX_ZOOM = 125;
+const CANVAS_SURFACE_OVERSCAN = 160;
+const MIN_SECTION_WIDTH = 300;
+const MIN_SECTION_HEIGHT = 170;
+const MIN_INSPECTOR_WIDTH = 320;
+const MIN_INSPECTOR_HEIGHT = 260;
+const WIDGET_LIMITS: Record = {
+ inspector: { height: MIN_INSPECTOR_HEIGHT, width: MIN_INSPECTOR_WIDTH },
+};
+const HANDLE_SIDES: RoadmapConnectionSide[] = ["top", "right", "bottom", "left"];
+const HANDLE_SNAP_DISTANCE = 34;
+
+const ruleStyles = {
+ required: {
+ badge: "border-border bg-muted text-foreground",
+ border: "border-border",
+ icon: "bg-muted-foreground",
+ node: "bg-card",
+ },
+ bonus: {
+ badge: "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-800 dark:bg-sky-950/50 dark:text-sky-300",
+ border: "border-sky-100 dark:border-sky-900",
+ icon: "bg-sky-400",
+ node: "bg-sky-50/50 dark:bg-sky-950/30",
+ },
+ challenge: {
+ badge: "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950/50 dark:text-amber-300",
+ border: "border-amber-100 dark:border-amber-900",
+ icon: "bg-amber-400",
+ node: "bg-amber-50/50 dark:bg-amber-950/30",
+ },
+ assessment: {
+ badge: "border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-800 dark:bg-violet-950/50 dark:text-violet-300",
+ border: "border-violet-100 dark:border-violet-900",
+ icon: "bg-violet-500",
+ node: "bg-violet-50/50 dark:bg-violet-950/30",
+ },
+ theory: {
+ badge: "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300",
+ border: "border-emerald-100 dark:border-emerald-900",
+ icon: "bg-emerald-500",
+ node: "bg-emerald-50/50 dark:bg-emerald-950/30",
+ },
+ video: {
+ badge: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950/50 dark:text-blue-300",
+ border: "border-blue-100 dark:border-blue-900",
+ icon: "bg-blue-500",
+ node: "bg-blue-50/50 dark:bg-blue-950/30",
+ },
+} as const;
+
+const copy = {
+ en: {
+ title: "Lesson Roadmap Configurator",
+ subtitle: "Arrange lesson nodes, tune metadata, and shape quiz blocks before wiring persistence.",
+ back: "Admin",
+ save: "Save",
+ saving: "Saving...",
+ addMenu: "Add item",
+ addCategory: "New category",
+ addVideo: "Video lesson",
+ addTheory: "Theory lesson",
+ addChallenge: "Code challenge",
+ addAssessment: "Evaluation quiz",
+ addSection: "New section",
+ duplicate: "Duplicate selected",
+ copy: "Copy selected",
+ paste: "Paste",
+ delete: "Delete selected",
+ hideWidgets: "Hide widgets",
+ showWidgets: "Show widgets",
+ keybinds: "Space + drag to pan, Ctrl + wheel to zoom, Cmd/Ctrl + Z to undo",
+ connectHint: "Drag from a node handle to another node",
+ connectionAdded: "Connection added",
+ connectionDeleted: "Connection deleted",
+ connectionReversed: "Connection direction reversed",
+ connection: "Connection",
+ from: "From",
+ to: "To",
+ reverseDirection: "Reverse direction",
+ startSide: "Start side",
+ endSide: "End side",
+ newLesson: "New lesson",
+ preview: "Preview",
+ nodes: "Nodes",
+ sections: "Sections",
+ quizItems: "Quiz items",
+ selected: "Selected lesson",
+ lesson: "Lesson",
+ kind: "Type",
+ quiz: "Quiz",
+ content: "Content",
+ search: "Search lesson...",
+ reset: "Reset layout",
+ canvas: "Roadmap canvas",
+ canvasHint: "Drag nodes or the background. Use zoom for large roadmap passes.",
+ section: "Section",
+ sectionDetails: "Section details",
+ sectionDescription: "Section description",
+ sectionNamePrompt: "Section name",
+ sectionTitle: "Section title",
+ category: "Category",
+ categoryDetails: "Category details",
+ categoryDescription: "Category description",
+ categoryNamePrompt: "Category name",
+ categoryTitle: "Category title",
+ pathKind: "Path type",
+ programmingLanguage: "Programming language",
+ availability: "Availability",
+ prerequisite: "Prerequisite path",
+ noPrerequisite: "No prerequisite",
+ estimatedHours: "Estimated hours",
+ accentColor: "Accent color",
+ pathIcon: "Icon key",
+ completionRequirement: "Completion requirement",
+ cancel: "Cancel",
+ create: "Create",
+ level: "Level",
+ minutes: "Minutes",
+ video: "Video URL",
+ titleLabel: "Title",
+ summary: "Summary",
+ transcript: "Transcript",
+ tags: "Tags",
+ code: "Starter code",
+ sampleInput: "Sample input",
+ quizConfig: "Quiz configuration",
+ question: "Question",
+ options: "Options",
+ correct: "Correct answer",
+ addQuestion: "Add question",
+ requiredQuiz: "Requires correct quiz",
+ requiredProblems: "Required problems",
+ visualRules: "Unlock rules",
+ unlockStatus: "Access",
+ ruleKind: "Rule type",
+ unlocked: "Unlocked",
+ locked: "Locked",
+ saveSuccess: "Roadmap changes saved",
+ saveError: "Could not save roadmap changes",
+ invalidPathSlug: "Every path needs a valid slug using lowercase letters, numbers, and hyphens.",
+ duplicatePathSlug: "Each learning path must have a unique slug.",
+ missingFoundation: "The roadmap needs exactly one foundation path.",
+ invalidPrerequisite: "A prerequisite path is missing or points to the same path.",
+ cyclicPrerequisite: "Prerequisite paths cannot form a cycle.",
+ localFallback: "Loaded local roadmap draft. Supabase could not be reached.",
+ addedLesson: "Lesson added",
+ deletedLesson: "Lesson deleted",
+ duplicatedLesson: "Lesson duplicated",
+ pastedLessons: "Lessons pasted",
+ addedSection: "Section added",
+ deleteCategory: "Delete category",
+ deleteCategoryDescription:
+ "The category will be removed. Its sections will be moved to another category.",
+ deleteSection: "Delete section",
+ deleteSectionDescription:
+ "The section, all of its lessons, and their connections will be removed.",
+ deletedCategory: "Category deleted",
+ deletedSection: "Section deleted",
+ keepCategory: "Keep category",
+ keepSection: "Keep section",
+ resetDone: "Roadmap reset",
+ markdown: "Lesson Markdown",
+ openStudio: "Open full-screen studio",
+ markdownHint: "Write the lesson here. Headings automatically become the learner table of contents.",
+ edit: "Edit",
+ split: "Split",
+ closeStudio: "Exit full screen",
+ studioDraft: "Changes are part of this roadmap draft. Save the roadmap to publish them.",
+ contentLanguage: "Content language",
+ english: "English",
+ romanian: "Romanian",
+ zoomOut: "Zoom out",
+ zoomIn: "Zoom in",
+ slug: "Slug",
+ pathKinds: { foundation: "Foundation", specialization: "Specialization", supplemental: "Supplemental" },
+ availabilityValues: { draft: "Draft", coming_soon: "Coming soon", published: "Published", archived: "Archived" },
+ levelValues: { beginner: "Beginner", practice: "Practice", challenge: "Challenge" },
+ requirementValues: { required: "Required", capstone: "Capstone", optional: "Optional", bonus: "Bonus" },
+ ruleKinds: { required: "Required", bonus: "Bonus", challenge: "Challenge", theory: "Theory", video: "Video", assessment: "Assessment" },
+ },
+ ro: {
+ title: "Configurator Roadmap Lecții",
+ subtitle: "Aranjează nodurile, ajustează metadata și configurează quiz-urile înainte de persistare.",
+ back: "Admin",
+ save: "Salvează",
+ saving: "Se salvează...",
+ addMenu: "Adaugă",
+ addCategory: "Categorie nouă",
+ addVideo: "Lecție video",
+ addTheory: "Lecție teorie",
+ addChallenge: "Code challenge",
+ addAssessment: "Quiz de evaluare",
+ addSection: "Secțiune nouă",
+ duplicate: "Duplică selecția",
+ copy: "Copiază selecția",
+ paste: "Lipește",
+ delete: "Șterge selecția",
+ hideWidgets: "Ascunde widget-uri",
+ showWidgets: "Arată widget-uri",
+ keybinds: "Space + drag pentru pan, Ctrl + wheel pentru zoom, Cmd/Ctrl + Z pentru anulare",
+ connectHint: "Trage dintr-un punct al nodului spre alt nod",
+ connectionAdded: "Conexiune adăugată",
+ connectionDeleted: "Conexiune ștearsă",
+ connectionReversed: "Direcția conexiunii a fost inversată",
+ connection: "Conexiune",
+ from: "Din",
+ to: "Spre",
+ reverseDirection: "Inversează direcția",
+ startSide: "Latura de start",
+ endSide: "Latura finală",
+ newLesson: "Lecție nouă",
+ preview: "Preview",
+ nodes: "Noduri",
+ sections: "Secțiuni",
+ quizItems: "Întrebări quiz",
+ selected: "Lecția selectată",
+ lesson: "Lecție",
+ kind: "Tip",
+ quiz: "Quiz",
+ content: "Conținut",
+ search: "Caută lecție...",
+ reset: "Resetează layout",
+ canvas: "Canvas roadmap",
+ canvasHint: "Mută nodurile sau fundalul. Folosește zoom pentru roadmap-uri mari.",
+ section: "Secțiune",
+ sectionDetails: "Detalii secțiune",
+ sectionDescription: "Descriere secțiune",
+ sectionNamePrompt: "Nume secțiune",
+ sectionTitle: "Titlu secțiune",
+ category: "Categorie",
+ categoryDetails: "Detalii categorie",
+ categoryDescription: "Descriere categorie",
+ categoryNamePrompt: "Nume categorie",
+ categoryTitle: "Titlu categorie",
+ pathKind: "Tip traseu",
+ programmingLanguage: "Limbaj de programare",
+ availability: "Disponibilitate",
+ prerequisite: "Traseu necesar înainte",
+ noPrerequisite: "Fără prerequisite",
+ estimatedHours: "Ore estimate",
+ accentColor: "Culoare accent",
+ pathIcon: "Cheie icon",
+ completionRequirement: "Cerință pentru absolvire",
+ cancel: "Anulează",
+ create: "Creează",
+ level: "Nivel",
+ minutes: "Minute",
+ video: "URL video",
+ titleLabel: "Titlu",
+ summary: "Rezumat",
+ transcript: "Transcript",
+ tags: "Tag-uri",
+ code: "Cod starter",
+ sampleInput: "Input exemplu",
+ quizConfig: "Configurare quiz",
+ question: "Întrebare",
+ options: "Opțiuni",
+ correct: "Răspuns corect",
+ addQuestion: "Adaugă întrebare",
+ requiredQuiz: "Necesită quiz corect",
+ requiredProblems: "Probleme obligatorii",
+ visualRules: "Reguli de deblocare",
+ unlockStatus: "Acces",
+ ruleKind: "Tip regulă",
+ unlocked: "Deblocat",
+ locked: "Blocat",
+ saveSuccess: "Modificările roadmap-ului au fost salvate",
+ saveError: "Nu am putut salva modificările roadmap-ului",
+ invalidPathSlug: "Fiecare traseu trebuie să aibă un slug valid, cu litere mici, cifre și cratime.",
+ duplicatePathSlug: "Fiecare traseu de învățare trebuie să aibă un slug unic.",
+ missingFoundation: "Roadmap-ul trebuie să conțină exact un traseu de fundație.",
+ invalidPrerequisite: "Un prerequisite lipsește sau indică spre același traseu.",
+ cyclicPrerequisite: "Traseele prerequisite nu pot forma un ciclu.",
+ localFallback: "Am încărcat draft-ul local. Supabase nu a putut fi accesat.",
+ addedLesson: "Lecție adăugată",
+ deletedLesson: "Lecție ștearsă",
+ duplicatedLesson: "Lecție duplicată",
+ pastedLessons: "Lecții lipite",
+ addedSection: "Secțiune adăugată",
+ deleteCategory: "Șterge categoria",
+ deleteCategoryDescription:
+ "Categoria va fi eliminată. Secțiunile sale vor fi mutate într-o altă categorie.",
+ deleteSection: "Șterge secțiunea",
+ deleteSectionDescription:
+ "Secțiunea, toate lecțiile sale și conexiunile lor vor fi eliminate.",
+ deletedCategory: "Categorie ștearsă",
+ deletedSection: "Secțiune ștearsă",
+ keepCategory: "Păstrează categoria",
+ keepSection: "Păstrează secțiunea",
+ resetDone: "Roadmap resetat",
+ markdown: "Markdown lecție",
+ openStudio: "Deschide studioul full-screen",
+ markdownHint: "Scrie lecția aici. Heading-urile devin automat cuprinsul elevului.",
+ edit: "Editează",
+ split: "Split",
+ closeStudio: "Ieși din full screen",
+ studioDraft: "Modificările fac parte din draft. Salvează roadmap-ul pentru a le publica.",
+ contentLanguage: "Limba conținutului",
+ english: "Engleză",
+ romanian: "Română",
+ zoomOut: "Micșorează",
+ zoomIn: "Mărește",
+ slug: "Slug",
+ pathKinds: { foundation: "Fundație", specialization: "Specializare", supplemental: "Suplimentar" },
+ availabilityValues: { draft: "Draft", coming_soon: "În curând", published: "Publicat", archived: "Arhivat" },
+ levelValues: { beginner: "Începător", practice: "Practică", challenge: "Challenge" },
+ requirementValues: { required: "Obligatoriu", capstone: "Capstone", optional: "Opțional", bonus: "Bonus" },
+ ruleKinds: { required: "Obligatoriu", bonus: "Bonus", challenge: "Challenge", theory: "Teorie", video: "Video", assessment: "Evaluare" },
+ },
+} as const;
+
+function buildInitialSections() {
+ return [...learnSections];
+}
+
+function buildInitialCategories() {
+ return defaultRoadmapCategories.map((category) => ({ ...category }));
+}
+
+function buildInitialNodes(
+ sections: LearnSection[] = learnSections,
+ lessons: LearnLesson[] = learnLessons,
+ savedNodes?: CanvasNode[]
+): CanvasNode[] {
+ if (savedNodes?.length) return savedNodes;
+
+ let sectionY = 120;
+
+ return sections.flatMap((section) => {
+ const sectionNodes = section.lessonIds
+ .map((lessonId, lessonIndex) => {
+ const lesson = lessons.find((item) => item.id === lessonId);
+ if (!lesson) return null;
+ const row = Math.floor(lessonIndex / SECTION_COLUMNS);
+ const column = lessonIndex % SECTION_COLUMNS;
+
+ return {
+ id: lesson.id,
+ lesson,
+ sectionId: section.id,
+ x: 120 + column * NODE_COLUMN_GAP,
+ y: sectionY + row * NODE_ROW_GAP,
+ };
+ })
+ .filter((node): node is CanvasNode => Boolean(node));
+
+ const rowCount = Math.max(
+ 1,
+ Math.ceil(section.lessonIds.length / SECTION_COLUMNS)
+ );
+ sectionY += rowCount * NODE_ROW_GAP + SECTION_GAP - NODE_ROW_GAP;
+
+ return sectionNodes;
+ });
+}
+
+function buildInitialSectionFrames(
+ nodes: CanvasNode[],
+ sections: LearnSection[],
+ savedFrames: RoadmapSectionFrame[] = []
+) {
+ const savedFrameBySectionId = new Map(
+ savedFrames.map((frame) => [frame.sectionId, frame])
+ );
+
+ let nextFrameY = 54;
+
+ return sections.map((section) => {
+ const savedFrame = savedFrameBySectionId.get(section.id);
+ if (savedFrame) {
+ nextFrameY = Math.max(nextFrameY, savedFrame.y + savedFrame.height + SECTION_FRAME_GAP);
+ return savedFrame;
+ }
+
+ const sectionNodes = nodes.filter((node) => node.sectionId === section.id);
+ if (!sectionNodes.length) {
+ const frame = {
+ sectionId: section.id,
+ x: 80,
+ y: nextFrameY,
+ width: 440,
+ height: 220,
+ };
+ nextFrameY = frame.y + frame.height + SECTION_FRAME_GAP;
+ return frame;
+ }
+
+ const minX = Math.min(...sectionNodes.map((node) => node.x));
+ const maxX = Math.max(...sectionNodes.map((node) => node.x + NODE_WIDTH));
+ const minY = Math.min(...sectionNodes.map((node) => node.y));
+ const maxY = Math.max(...sectionNodes.map((node) => node.y + NODE_HEIGHT));
+
+ const frame = {
+ sectionId: section.id,
+ x: minX - 42,
+ y: minY - 66,
+ width: Math.max(360, maxX - minX + 84),
+ height: Math.max(190, maxY - minY + 108),
+ };
+ nextFrameY = Math.max(nextFrameY, frame.y + frame.height + SECTION_FRAME_GAP);
+ return frame;
+ });
+}
+
+function toLocalizedDraftText(en: string, ro: string): LocalizedDraftText {
+ return {
+ en: en || ro,
+ ro: ro || en,
+ };
+}
+
+function draftText(value: LocalizedDraftText, locale: LessonLocale) {
+ return value[locale] || value.en || value.ro || "";
+}
+
+function buildInitialDrafts(
+ lessons: LearnLesson[] = learnLessons
+): Record {
+ return Object.fromEntries(
+ lessons.map((lesson) => {
+ const rule = getLessonRule(lesson);
+
+ return [
+ lesson.id,
+ {
+ code: lesson.code,
+ completionRequirement: getLessonCompletionRequirement(lesson),
+ kind: getLessonKind(lesson),
+ level: lesson.level,
+ locked:
+ lesson.unlockRule?.locked ??
+ (rule.requiresCorrectQuiz || rule.requiredProblemCodes.length > 0),
+ markdown: toLocalizedDraftText(
+ getLessonMarkdown(lesson, "en"),
+ getLessonMarkdown(lesson, "ro")
+ ),
+ minutes: lesson.minutes,
+ quiz: lesson.quiz.map((question) => ({
+ answerIndex: question.answerIndex,
+ options: question.options.map((option) =>
+ toLocalizedDraftText(text(option, "en"), text(option, "ro"))
+ ),
+ question: toLocalizedDraftText(
+ text(question.question, "en"),
+ text(question.question, "ro")
+ ),
+ })),
+ requiredProblemCodes: rule.requiredProblemCodes.join(", "),
+ requiresCorrectQuiz: rule.requiresCorrectQuiz,
+ sampleInput: lesson.sampleInput,
+ summary: toLocalizedDraftText(
+ text(lesson.summary, "en"),
+ text(lesson.summary, "ro")
+ ),
+ tags: lesson.tags.join(", "),
+ title: toLocalizedDraftText(
+ text(lesson.title, "en"),
+ text(lesson.title, "ro")
+ ),
+ transcript: toLocalizedDraftText(
+ text(lesson.transcript, "en"),
+ text(lesson.transcript, "ro")
+ ),
+ videoUrl: lesson.videoUrl ?? "",
+ },
+ ];
+ })
+ );
+}
+
+function buildAdminState() {
+ const config = readRoadmapConfig();
+ const { lessons, sections } = getRoadmapConfigData(config);
+ const savedNodes = config?.nodes
+ ?.map((node) => {
+ const lesson = lessons.find((item) => item.id === node.id);
+ if (!lesson) return null;
+
+ return {
+ id: node.id,
+ lesson,
+ sectionId: node.sectionId,
+ x: node.x,
+ y: node.y,
+ };
+ })
+ .filter((node): node is CanvasNode => Boolean(node));
+
+ const nodes = buildInitialNodes(sections, lessons, savedNodes);
+
+ return {
+ categories: config?.categories?.length
+ ? config.categories
+ : buildInitialCategories(),
+ connections:
+ config?.connections ?? buildDefaultRoadmapConnections(nodes, sections),
+ drafts: buildInitialDrafts(lessons),
+ nodes,
+ sectionFrames: buildInitialSectionFrames(
+ nodes,
+ sections,
+ config?.sectionFrames
+ ),
+ sections,
+ };
+}
+
+function getDraftRuleKind(lesson: LearnLesson, draft?: LessonDraft) {
+ if (draft?.kind === "theory") return "theory";
+ if (draft?.kind === "assessment") return "assessment";
+ if (draft?.kind === "challenge" || draft?.level === "challenge") return "challenge";
+ if (draft?.kind === "video") return "video";
+
+ return getLessonRule(lesson).kind;
+}
+
+function createLessonShell(
+ id: string,
+ kind: NonNullable,
+ order: number
+): LearnLesson {
+ const titles = {
+ interactive: { en: "New interactive lesson", ro: "Lecție interactivă nouă" },
+ theory: { en: "New theory lesson", ro: "Lecție de teorie nouă" },
+ video: { en: "New video lesson", ro: "Lecție video nouă" },
+ challenge: { en: "New code challenge", ro: "Challenge nou" },
+ assessment: { en: "New evaluation quiz", ro: "Quiz de evaluare nou" },
+ } satisfies Record, LocalizedDraftText>;
+ const title = titles[kind];
+ const summary = {
+ en: "Configure the goal, content, and quiz for this lesson.",
+ ro: "Configurează obiectivul, conținutul și quiz-ul acestei lecții.",
+ } satisfies LocalizedDraftText;
+ const hasCode = kind === "challenge" || kind === "interactive";
+ const hasQuiz = kind !== "theory";
+
+ return {
+ id,
+ order,
+ unit: { en: "Draft", ro: "Draft" },
+ title,
+ summary,
+ transcript: { en: "", ro: "" },
+ videoUrl: kind === "video" ? "https://youtube.com/..." : undefined,
+ tags: [kind],
+ level:
+ kind === "challenge" || kind === "assessment"
+ ? "challenge"
+ : kind === "theory"
+ ? "beginner"
+ : "practice",
+ minutes: 8,
+ markdown: {
+ en: `## ${title.en}\n\n${summary.en}`,
+ ro: `## ${title.ro}\n\n${summary.ro}`,
+ },
+ sampleInput: "",
+ code: hasCode ? "INPUT N\nPRINT N" : "",
+ quiz: hasQuiz
+ ? [
+ {
+ question: { en: "What should students understand?", ro: "Ce trebuie să înțeleagă elevii?" },
+ options: [
+ { en: "The core idea", ro: "Ideea principală" },
+ { en: "Only syntax", ro: "Doar sintaxa" },
+ { en: "Nothing yet", ro: "Nimic momentan" },
+ ],
+ answerIndex: 0,
+ },
+ ]
+ : [],
+ recommendedProblems: [],
+ kind,
+ completionRequirement:
+ kind === "challenge" || kind === "assessment" ? "capstone" : "required",
+ theory:
+ kind === "theory"
+ ? [
+ {
+ heading: title,
+ body: summary,
+ },
+ ]
+ : undefined,
+ };
+}
+
+function materializeLesson(
+ node: CanvasNode,
+ draft: LessonDraft,
+ order: number,
+ section: LearnSection | undefined
+): LearnLesson {
+ const tags = draft.tags
+ .split(",")
+ .map((tag) => tag.trim())
+ .filter(Boolean);
+ const requiredProblemCodes = draft.requiredProblemCodes
+ .split(",")
+ .map((code) => code.trim())
+ .filter(Boolean)
+ .map(Number)
+ .filter((code) => Number.isInteger(code) && code > 0);
+ const ruleKind: LessonRuleKind =
+ draft.completionRequirement === "bonus"
+ ? "bonus"
+ : draft.kind === "challenge" || draft.kind === "assessment"
+ ? "challenge"
+ : draft.kind === "theory"
+ ? "required"
+ : "required";
+
+ return {
+ ...node.lesson,
+ code: draft.code,
+ completionRequirement: draft.completionRequirement,
+ kind: draft.kind,
+ level: draft.level,
+ markdown: draft.markdown,
+ minutes: draft.minutes,
+ order,
+ quiz: draft.quiz.map((question) => ({
+ answerIndex: Math.min(
+ Math.max(question.answerIndex, 0),
+ Math.max(question.options.length - 1, 0)
+ ),
+ options: question.options,
+ question: question.question,
+ })),
+ recommendedProblems: node.lesson.recommendedProblems ?? [],
+ sampleInput: draft.sampleInput,
+ summary: draft.summary,
+ tags,
+ theory:
+ draft.kind === "theory"
+ ? [
+ {
+ heading: draft.title,
+ body: {
+ en: draft.transcript.en || draft.summary.en,
+ ro: draft.transcript.ro || draft.summary.ro,
+ },
+ bullets: tags.map((tag) => ({ en: tag, ro: tag })),
+ },
+ ]
+ : node.lesson.theory,
+ title: draft.title,
+ transcript: draft.transcript,
+ unit: section?.title ?? node.lesson.unit,
+ unlockRule: {
+ kind: ruleKind,
+ locked: draft.locked,
+ requiredProblemCodes: draft.locked ? requiredProblemCodes : [],
+ requiresCorrectQuiz: draft.locked ? draft.requiresCorrectQuiz : false,
+ },
+ videoUrl: draft.videoUrl || undefined,
+ };
+}
+
+function isTextEditingTarget(target: EventTarget | null) {
+ if (!(target instanceof HTMLElement)) return false;
+
+ return Boolean(
+ target.closest(
+ "input, textarea, select, [contenteditable=true]"
+ )
+ );
+}
+
+const CANVAS_WIDGET_SELECTOR = [
+ "[data-canvas-widget]",
+ "[data-radix-popper-content-wrapper]",
+ "[role='dialog']",
+ "[role='listbox']",
+ "[role='menu']",
+].join(", ");
+
+function isCanvasWidgetTarget(target: EventTarget | null) {
+ if (!(target instanceof HTMLElement)) return false;
+
+ return Boolean(target.closest(CANVAS_WIDGET_SELECTOR));
+}
+
+function clampZoom(value: number) {
+ return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, value));
+}
+
+function getSelectionBounds(rect: SelectionRect) {
+ return {
+ left: Math.min(rect.startX, rect.currentX),
+ top: Math.min(rect.startY, rect.currentY),
+ width: Math.abs(rect.currentX - rect.startX),
+ height: Math.abs(rect.currentY - rect.startY),
+ };
+}
+
+function getHandlePoint(node: CanvasNode, side: RoadmapConnectionSide) {
+ switch (side) {
+ case "top":
+ return { x: node.x + NODE_WIDTH / 2, y: node.y };
+ case "right":
+ return { x: node.x + NODE_WIDTH, y: node.y + NODE_HEIGHT / 2 };
+ case "bottom":
+ return { x: node.x + NODE_WIDTH / 2, y: node.y + NODE_HEIGHT };
+ case "left":
+ return { x: node.x, y: node.y + NODE_HEIGHT / 2 };
+ }
+}
+
+function getSideVector(side: RoadmapConnectionSide) {
+ switch (side) {
+ case "top":
+ return { x: 0, y: -1 };
+ case "right":
+ return { x: 1, y: 0 };
+ case "bottom":
+ return { x: 0, y: 1 };
+ case "left":
+ return { x: -1, y: 0 };
+ }
+}
+
+function getConnectionPath(
+ start: { x: number; y: number },
+ sourceSide: RoadmapConnectionSide,
+ end: { x: number; y: number },
+ targetSide?: RoadmapConnectionSide
+) {
+ const distance = Math.max(64, Math.min(220, Math.hypot(end.x - start.x, end.y - start.y) * 0.45));
+ const sourceVector = getSideVector(sourceSide);
+ const targetVector = targetSide ? getSideVector(targetSide) : { x: 0, y: 0 };
+ const sourceControl = {
+ x: start.x + sourceVector.x * distance,
+ y: start.y + sourceVector.y * distance,
+ };
+ const targetControl = {
+ x: end.x + targetVector.x * distance,
+ y: end.y + targetVector.y * distance,
+ };
+
+ return `M ${start.x} ${start.y} C ${sourceControl.x} ${sourceControl.y}, ${targetControl.x} ${targetControl.y}, ${end.x} ${end.y}`;
+}
+
+function getHandleClass(side: RoadmapConnectionSide) {
+ switch (side) {
+ case "top":
+ return "left-1/2 top-0 -translate-x-1/2 -translate-y-1/2";
+ case "right":
+ return "right-0 top-1/2 -translate-y-1/2 translate-x-1/2";
+ case "bottom":
+ return "bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2";
+ case "left":
+ return "left-0 top-1/2 -translate-x-1/2 -translate-y-1/2";
+ }
+}
+
+function LessonsAdminContent() {
+ const { locale } = useLanguage();
+ const queryClient = useQueryClient();
+ const lessonLocale = (locale === "ro" ? "ro" : "en") as LessonLocale;
+ const c = copy[lessonLocale];
+ const [categories, setCategories] = useState(() =>
+ buildAdminState().categories
+ );
+ const [sections, setSections] = useState(() =>
+ buildAdminState().sections
+ );
+ const [nodes, setNodes] = useState(() =>
+ buildAdminState().nodes
+ );
+ const [sectionFrames, setSectionFrames] = useState(() =>
+ buildAdminState().sectionFrames
+ );
+ const [connections, setConnections] = useState(() =>
+ buildAdminState().connections
+ );
+ const [drafts, setDrafts] = useState>(() =>
+ buildAdminState().drafts
+ );
+ const [contentLocale, setContentLocale] = useState(lessonLocale);
+ useEffect(() => {
+ setContentLocale(lessonLocale);
+ }, [lessonLocale]);
+ const [selectedId, setSelectedId] = useState(nodes[0]?.id ?? "");
+ const [selectedIds, setSelectedIds] = useState(
+ nodes[0]?.id ? [nodes[0].id] : []
+ );
+ const [selectedSectionId, setSelectedSectionId] = useState(
+ nodes[0]?.sectionId ?? sections[0]?.id ?? ""
+ );
+ const [selectedSectionFrameId, setSelectedSectionFrameId] = useState("");
+ const [selectedCategoryId, setSelectedCategoryId] = useState(
+ categories[0]?.id ?? ""
+ );
+ const [createDialogType, setCreateDialogType] = useState<
+ "category" | "section" | null
+ >(null);
+ const [deleteDialogType, setDeleteDialogType] = useState<
+ "category" | "section" | null
+ >(null);
+ const [createName, setCreateName] = useState("");
+ const [query, setQuery] = useState("");
+ const [zoom, setZoom] = useState([84]);
+ const [pan, setPan] = useState({ x: 0, y: 0 });
+ const [spacePressed, setSpacePressed] = useState(false);
+ const [widgetsHidden, setWidgetsHidden] = useState(false);
+ const [isSaving, setIsSaving] = useState(false);
+ const [markdownStudioLessonId, setMarkdownStudioLessonId] = useState(null);
+ const [markdownStudioMode, setMarkdownStudioMode] = useState<"edit" | "split" | "preview">("split");
+ const [selectionRect, setSelectionRect] = useState(null);
+ const [connectionDraft, setConnectionDraft] = useState(null);
+ const [selectedConnectionId, setSelectedConnectionId] = useState("");
+ const historySnapshot = useMemo(
+ () => ({
+ categories,
+ connections,
+ drafts,
+ nodes,
+ sectionFrames,
+ sections,
+ }),
+ [
+ categories,
+ connections,
+ drafts,
+ nodes,
+ sectionFrames,
+ sections,
+ ]
+ );
+ const applyHistorySnapshot = useCallback((snapshot: LessonConfiguratorSnapshot) => {
+ const categoryIds = new Set(snapshot.categories.map((category) => category.id));
+ const connectionIds = new Set(snapshot.connections.map((connection) => connection.id));
+ const nodeIds = new Set(snapshot.nodes.map((node) => node.id));
+ const sectionIds = new Set(snapshot.sections.map((section) => section.id));
+
+ setCategories(snapshot.categories);
+ setConnections(snapshot.connections);
+ setDrafts(snapshot.drafts);
+ setNodes(snapshot.nodes);
+ setSectionFrames(snapshot.sectionFrames);
+ setSections(snapshot.sections);
+ setSelectedCategoryId((current) =>
+ !current || categoryIds.has(current) ? current : snapshot.categories[0]?.id ?? ""
+ );
+ setSelectedConnectionId((current) =>
+ !current || connectionIds.has(current) ? current : ""
+ );
+ setSelectedId((current) =>
+ !current || nodeIds.has(current) ? current : snapshot.nodes[0]?.id ?? ""
+ );
+ setSelectedIds((current) => current.filter((id) => nodeIds.has(id)));
+ setSelectedSectionFrameId((current) =>
+ !current || sectionIds.has(current) ? current : ""
+ );
+ setSelectedSectionId((current) =>
+ !current || sectionIds.has(current) ? current : snapshot.sections[0]?.id ?? ""
+ );
+ }, []);
+ const {
+ redo: redoConfiguratorChange,
+ reset: resetConfiguratorHistory,
+ undo: undoConfiguratorChange,
+ } = useUndoHistory({
+ value: historySnapshot,
+ onApply: applyHistorySnapshot,
+ });
+ const [widgetSizes, setWidgetSizes] = useState>({
+ inspector: { height: 680, width: 460 },
+ });
+ const dragRef = useRef(null);
+ const clipboardRef = useRef([]);
+ const viewportRef = useRef(null);
+ const { data: remoteConfig, isError: remoteConfigFailed } = useQuery({
+ queryKey: ["roadmap", "config-raw"],
+ queryFn: () => readRemoteRoadmapConfig(),
+ staleTime: 5 * 60 * 1000,
+ });
+
+ const selectedNode =
+ nodes.find((node) => node.id === selectedId) ?? (selectedId ? nodes[0] : undefined);
+ const selectedConnection = connections.find(
+ (connection) => connection.id === selectedConnectionId
+ );
+ const selectedLesson = selectedNode?.lesson;
+ const selectedDraft = selectedLesson ? drafts[selectedLesson.id] : null;
+ const markdownStudioLesson = markdownStudioLessonId
+ ? nodes.find((node) => node.id === markdownStudioLessonId)?.lesson ?? null
+ : null;
+ const markdownStudioDraft = markdownStudioLessonId
+ ? drafts[markdownStudioLessonId] ?? null
+ : null;
+ const selectedSection = selectedNode
+ ? sections.find((section) => section.id === selectedNode.sectionId)
+ : null;
+ const selectedSectionDetails =
+ selectedSection ??
+ sections.find((section) => section.id === selectedSectionId) ??
+ null;
+ const selectedCategoryDetails =
+ categories.find((category) => category.id === selectedCategoryId) ??
+ categories.find((category) =>
+ selectedSectionDetails
+ ? category.sectionIds.includes(selectedSectionDetails.id)
+ : false
+ ) ??
+ null;
+ const selectedRule =
+ selectedNode && selectedDraft
+ ? getLessonRule(
+ materializeLesson(
+ selectedNode,
+ selectedDraft,
+ selectedNode.lesson.order,
+ selectedSection ?? undefined
+ )
+ )
+ : null;
+ const scale = zoom[0] / 100;
+ const categoryLabelPositions = useMemo(
+ () =>
+ categories.map((category, categoryIndex) => {
+ const frames = sectionFrames.filter((frame) =>
+ category.sectionIds.includes(frame.sectionId)
+ );
+
+ return {
+ category,
+ x: frames.length ? Math.min(...frames.map((frame) => frame.x)) : 80,
+ y: frames.length
+ ? Math.min(...frames.map((frame) => frame.y)) - 46
+ : 44 + categoryIndex * (SECTION_GAP + NODE_ROW_GAP),
+ };
+ }),
+ [categories, sectionFrames]
+ );
+ const canvasSurfaceSize = useMemo(() => {
+ const contentRight = Math.max(
+ 1,
+ ...nodes.map((node) => node.x + NODE_WIDTH),
+ ...sectionFrames.map((frame) => frame.x + frame.width),
+ ...categoryLabelPositions.map(({ x }) => x + 340)
+ );
+ const contentBottom = Math.max(
+ 1,
+ ...nodes.map((node) => node.y + NODE_HEIGHT),
+ ...sectionFrames.map((frame) => frame.y + frame.height),
+ ...categoryLabelPositions.map(({ y }) => y + 48)
+ );
+
+ return {
+ height: Math.ceil(contentBottom + CANVAS_SURFACE_OVERSCAN),
+ width: Math.ceil(contentRight + CANVAS_SURFACE_OVERSCAN),
+ };
+ }, [categoryLabelPositions, nodes, sectionFrames]);
+
+ const categoryIsExplicitlySelected = Boolean(
+ !selectedConnection &&
+ !selectedLesson &&
+ !selectedSectionDetails &&
+ selectedCategoryDetails
+ );
+ const hasConfigurableSelection = Boolean(
+ selectedConnection ||
+ selectedLesson ||
+ selectedSectionDetails ||
+ categoryIsExplicitlySelected
+ );
+
+ useEffect(() => {
+ if (remoteConfigFailed && !remoteConfig && readRoadmapConfig()) {
+ toast.warning(c.localFallback);
+ }
+ }, [c.localFallback, remoteConfig, remoteConfigFailed]);
+
+ useEffect(() => {
+ if (!remoteConfig) return;
+
+ writeRoadmapConfig(remoteConfig);
+ const { categories, lessons, sections } = getRoadmapConfigData(remoteConfig);
+ const remoteNodes = buildInitialNodes(
+ sections,
+ lessons,
+ remoteConfig.nodes
+ .map((node) => {
+ const lesson = lessons.find((item) => item.id === node.id);
+ if (!lesson) return null;
+
+ return {
+ id: node.id,
+ lesson,
+ sectionId: node.sectionId,
+ x: node.x,
+ y: node.y,
+ };
+ })
+ .filter((node): node is CanvasNode => Boolean(node))
+ );
+ const remoteSectionFrames = buildInitialSectionFrames(
+ remoteNodes,
+ sections,
+ remoteConfig.sectionFrames
+ );
+ const remoteDrafts = buildInitialDrafts(lessons);
+ const remoteSelectedId = remoteNodes[0]?.id ?? "";
+ const remoteSelectedIds = remoteSelectedId ? [remoteSelectedId] : [];
+ const remoteSelectedSectionId =
+ remoteNodes[0]?.sectionId ?? sections[0]?.id ?? "";
+ const remoteSelectedCategoryId =
+ categories.find((category) =>
+ category.sectionIds.includes(remoteSelectedSectionId)
+ )?.id ?? categories[0]?.id ?? "";
+
+ resetConfiguratorHistory({
+ categories,
+ connections: remoteConfig.connections,
+ drafts: remoteDrafts,
+ nodes: remoteNodes,
+ sectionFrames: remoteSectionFrames,
+ sections,
+ });
+
+ setCategories(categories);
+ setSections(sections);
+ setNodes(remoteNodes);
+ setSectionFrames(remoteSectionFrames);
+ setConnections(remoteConfig.connections);
+ setDrafts(remoteDrafts);
+ setSelectedId(remoteSelectedId);
+ setSelectedIds(remoteSelectedIds);
+ setSelectedSectionId(remoteSelectedSectionId);
+ setSelectedCategoryId(remoteSelectedCategoryId);
+ setSelectedConnectionId("");
+ setSelectedSectionFrameId("");
+ }, [
+ remoteConfig,
+ resetConfiguratorHistory,
+ ]);
+
+ useEffect(() => {
+ const viewport = viewportRef.current;
+ if (!viewport) return;
+ const activeViewport = viewport;
+
+ function handleNativeWheel(event: WheelEvent) {
+ if (isCanvasWidgetTarget(event.target)) return;
+
+ event.preventDefault();
+
+ const rect = activeViewport.getBoundingClientRect();
+ const pointerX = event.clientX - rect.left;
+ const pointerY = event.clientY - rect.top;
+
+ if (event.ctrlKey || event.metaKey) {
+ const nextZoom = clampZoom(zoom[0] + (event.deltaY > 0 ? -5 : 5));
+ const nextScale = nextZoom / 100;
+ const worldX = (pointerX - pan.x) / scale;
+ const worldY = (pointerY - pan.y) / scale;
+
+ setZoom([nextZoom]);
+ setPan({
+ x: pointerX - worldX * nextScale,
+ y: pointerY - worldY * nextScale,
+ });
+ return;
+ }
+
+ setPan((current) => ({
+ x: current.x - (event.shiftKey && !event.deltaX ? event.deltaY : event.deltaX),
+ y: current.y - (event.shiftKey && !event.deltaX ? 0 : event.deltaY),
+ }));
+ }
+
+ activeViewport.addEventListener("wheel", handleNativeWheel, { passive: false });
+
+ return () => activeViewport.removeEventListener("wheel", handleNativeWheel);
+ }, [pan.x, pan.y, scale, zoom]);
+
+ useEffect(() => {
+ function handleKeyDown(event: KeyboardEvent) {
+ const key = event.key.toLowerCase();
+ const modifierPressed = event.metaKey || event.ctrlKey;
+
+ if (modifierPressed && !event.altKey && (key === "z" || key === "y")) {
+ const usesConfiguratorHistory =
+ event.target instanceof Element &&
+ Boolean(event.target.closest("[data-config-history]"));
+
+ if (isTextEditingTarget(event.target) && !usesConfiguratorHistory) {
+ return;
+ }
+
+ event.preventDefault();
+
+ if (key === "y" || event.shiftKey) {
+ redoConfiguratorChange();
+ } else {
+ undoConfiguratorChange();
+ }
+ return;
+ }
+
+ if (
+ event.key === "Escape" &&
+ !isTextEditingTarget(event.target) &&
+ !isCanvasWidgetTarget(event.target)
+ ) {
+ event.preventDefault();
+ dragRef.current = null;
+ setConnectionDraft(null);
+ setSelectedCategoryId("");
+ setSelectedConnectionId("");
+ setSelectedId("");
+ setSelectedIds([]);
+ setSelectedSectionFrameId("");
+ setSelectedSectionId("");
+ setSelectionRect(null);
+ setSpacePressed(false);
+ return;
+ }
+
+ if (
+ event.code !== "Space" ||
+ isTextEditingTarget(event.target) ||
+ isCanvasWidgetTarget(event.target)
+ ) {
+ return;
+ }
+
+ event.preventDefault();
+ setSpacePressed(true);
+ }
+
+ function handleKeyUp(event: KeyboardEvent) {
+ if (event.code === "Space") {
+ setSpacePressed(false);
+ if (dragRef.current?.type === "canvas") {
+ dragRef.current = null;
+ }
+ }
+ }
+
+ window.addEventListener("keydown", handleKeyDown);
+ window.addEventListener("keyup", handleKeyUp);
+
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown);
+ window.removeEventListener("keyup", handleKeyUp);
+ };
+ }, [redoConfiguratorChange, undoConfiguratorChange]);
+
+ const filteredNodes = useMemo(() => {
+ const normalized = query.trim().toLowerCase();
+ if (!normalized) return nodes;
+
+ return nodes.filter((node) => {
+ const draft = drafts[node.id];
+ const title = draft
+ ? `${draft.title.en} ${draft.title.ro}`.toLowerCase()
+ : text(node.lesson.title, lessonLocale).toLowerCase();
+ const unit = text(node.lesson.unit, lessonLocale).toLowerCase();
+ return title.includes(normalized) || unit.includes(normalized) || node.id.includes(normalized);
+ });
+ }, [drafts, lessonLocale, nodes, query]);
+
+ function resetLayout() {
+ clearRoadmapConfig();
+ const initialCategories = buildInitialCategories();
+ const initialSections = buildInitialSections();
+ const initialNodes = buildInitialNodes(initialSections, learnLessons);
+ const initialConnections = buildDefaultRoadmapConnections(initialNodes, initialSections);
+ const initialSectionFrames = buildInitialSectionFrames(initialNodes, initialSections);
+ setCategories(initialCategories);
+ setSections(initialSections);
+ setNodes(initialNodes);
+ setConnections(initialConnections);
+ setSectionFrames(initialSectionFrames);
+ setDrafts(buildInitialDrafts());
+ setSelectedId(initialNodes[0]?.id ?? "");
+ setSelectedIds(initialNodes[0]?.id ? [initialNodes[0].id] : []);
+ setSelectedSectionId(initialNodes[0]?.sectionId ?? initialSections[0]?.id ?? "");
+ setSelectedCategoryId(initialCategories[0]?.id ?? "");
+ setPan({ x: 0, y: 0 });
+ setZoom([84]);
+ setSelectionRect(null);
+ setSelectedConnectionId("");
+ setSelectedSectionFrameId("");
+ toast.success(c.resetDone);
+ }
+
+ async function saveRoadmapConfig() {
+ const categoryIds = new Set(categories.map((category) => category.id));
+ const categorySlugs = categories.map((category) => category.slug.trim());
+ const invalidSlug = categorySlugs.some(
+ (slug) => !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)
+ );
+ const duplicateSlug = new Set(categorySlugs).size !== categorySlugs.length;
+ const invalidPrerequisite = categories.some(
+ (category) =>
+ category.prerequisitePathId === category.id ||
+ (category.prerequisitePathId &&
+ !categoryIds.has(category.prerequisitePathId))
+ );
+ const cyclicPrerequisite = categories.some((category) => {
+ const visited = new Set([category.id]);
+ let nextId = category.prerequisitePathId;
+
+ while (nextId) {
+ if (visited.has(nextId)) return true;
+ visited.add(nextId);
+ nextId = categories.find((item) => item.id === nextId)
+ ?.prerequisitePathId;
+ }
+
+ return false;
+ });
+ const validationError = invalidSlug
+ ? c.invalidPathSlug
+ : duplicateSlug
+ ? c.duplicatePathSlug
+ : categories.filter((category) => category.kind === "foundation")
+ .length !== 1
+ ? c.missingFoundation
+ : invalidPrerequisite
+ ? c.invalidPrerequisite
+ : cyclicPrerequisite
+ ? c.cyclicPrerequisite
+ : null;
+
+ if (validationError) {
+ toast.error(c.saveError, { description: validationError });
+ return;
+ }
+
+ setIsSaving(true);
+
+ const orderedNodes = [...nodes].sort((a, b) => a.y - b.y || a.x - b.x);
+ const nextSections = sections.map((section, sectionIndex) => ({
+ ...section,
+ pathSlug:
+ categories.find((category) => category.sectionIds.includes(section.id))
+ ?.slug ?? section.pathSlug,
+ lessonIds: orderedNodes
+ .filter((node) => node.sectionId === section.id)
+ .sort((a, b) => a.x - b.x || a.y - b.y)
+ .map((node) => node.id),
+ order: sectionIndex + 1,
+ }));
+ const assignedSectionIds = new Set(
+ categories.flatMap((category) => category.sectionIds)
+ );
+ const nextCategories = categories.map((category, categoryIndex) => ({
+ ...category,
+ order: categoryIndex + 1,
+ sectionIds: [
+ ...category.sectionIds.filter((sectionId) =>
+ nextSections.some((section) => section.id === sectionId)
+ ),
+ ...nextSections
+ .filter((section) => !assignedSectionIds.has(section.id))
+ .map((section) => section.id)
+ .filter((sectionId) => categoryIndex === 0 && sectionId),
+ ],
+ }));
+ const sectionById = new Map(nextSections.map((section) => [section.id, section]));
+ const nextLessons = orderedNodes
+ .map((node, index) => {
+ const draft = drafts[node.id];
+ if (!draft) return null;
+
+ return materializeLesson(
+ node,
+ draft,
+ index + 1,
+ sectionById.get(node.sectionId)
+ );
+ })
+ .filter((lesson): lesson is LearnLesson => Boolean(lesson));
+
+ const config: RoadmapConfig = {
+ categories: nextCategories,
+ connections,
+ lessons: nextLessons,
+ nodes: orderedNodes.map((node) => ({
+ id: node.id,
+ sectionId: node.sectionId,
+ x: node.x,
+ y: node.y,
+ })),
+ sectionFrames,
+ sections: nextSections,
+ updatedAt: new Date().toISOString(),
+ version: 1,
+ };
+
+ try {
+ await writeRemoteRoadmapConfig(config);
+ writeRoadmapConfig(config);
+ queryClient.setQueryData(["roadmap", "config-raw"], config);
+ queryClient.setQueryData(["roadmap", "config"], getRoadmapConfigData(config));
+
+ setNodes((current) =>
+ current.map((node) => {
+ const lesson = nextLessons.find((item) => item.id === node.id);
+ return lesson ? { ...node, lesson } : node;
+ })
+ );
+ setSections(nextSections);
+ setCategories(nextCategories);
+ toast.success(c.saveSuccess);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : c.saveError;
+ toast.error(c.saveError, { description: message });
+ } finally {
+ setIsSaving(false);
+ }
+ }
+
+ function slugify(value: string) {
+ return (
+ value
+ .toLowerCase()
+ .trim()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "") || `item-${Date.now().toString(36)}`
+ );
+ }
+
+ function normalizeSlugInput(value: string) {
+ return value
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+/, "");
+ }
+
+ function openCreateDialog(type: "category" | "section") {
+ setCreateDialogType(type);
+ setCreateName("");
+ }
+
+ function closeCreateDialog() {
+ setCreateDialogType(null);
+ setCreateName("");
+ }
+
+ function createCategory(title: string) {
+ const id = `category-${slugify(title)}-${Date.now().toString(36)}`;
+
+ setCategories((current) => [
+ ...current,
+ {
+ accentColor: "#10b981",
+ availability: "draft",
+ estimatedHours: 12,
+ id,
+ icon: "code",
+ kind: "specialization",
+ language: "python",
+ order: current.length + 1,
+ prerequisitePathId:
+ current.find((category) => category.kind === "foundation")?.id ?? null,
+ sectionIds: [],
+ slug: slugify(title),
+ title: { en: title, ro: title },
+ description: {
+ en: "Configure this category from the inspector.",
+ ro: "Configurează această categorie din inspector.",
+ },
+ },
+ ]);
+ setSelectedId("");
+ setSelectedIds([]);
+ setSelectedSectionId("");
+ setSelectedSectionFrameId("");
+ setSelectedCategoryId(id);
+ toast.success(c.addedSection);
+ }
+
+ function addSection(title: string) {
+ const id = `draft-section-${Date.now().toString(36)}`;
+ const y =
+ sectionFrames.length > 0
+ ? Math.max(...sectionFrames.map((frame) => frame.y + frame.height)) +
+ SECTION_FRAME_GAP
+ : 100;
+
+ setSections((current) => [
+ ...current,
+ {
+ id,
+ order: current.length + 1,
+ pathSlug:
+ categories.find(
+ (category) =>
+ category.id === (selectedCategoryId || categories[0]?.id)
+ )?.slug ?? categories[0]?.slug,
+ label: { en: `Section ${current.length + 1}`, ro: `Secțiunea ${current.length + 1}` },
+ title: { en: title, ro: title },
+ description: {
+ en: "Configure this section from the lesson inspector.",
+ ro: "Configurează această secțiune din inspectorul lecției.",
+ },
+ lessonIds: [],
+ },
+ ]);
+ setSectionFrames((current) => [
+ ...current,
+ {
+ sectionId: id,
+ x: 80,
+ y,
+ width: 440,
+ height: 220,
+ },
+ ]);
+ setCategories((current) =>
+ current.map((category, index) =>
+ category.id === (selectedCategoryId || current[0]?.id) || (!selectedCategoryId && index === 0)
+ ? {
+ ...category,
+ sectionIds: [...category.sectionIds, id],
+ }
+ : category
+ )
+ );
+ setSelectedId("");
+ setSelectedIds([]);
+ setSelectedSectionId(id);
+ setSelectedSectionFrameId(id);
+ setSelectedCategoryId(selectedCategoryId || categories[0]?.id || "");
+ setPan((current) => ({ ...current, y: current.y - y * scale + 180 }));
+ toast.success(c.addedSection);
+ }
+
+ function submitCreateDialog() {
+ const title = createName.trim();
+ if (!title || !createDialogType) return;
+
+ if (createDialogType === "category") {
+ createCategory(title);
+ } else {
+ addSection(title);
+ }
+
+ closeCreateDialog();
+ }
+
+ function updateDraft(id: string, patch: Partial) {
+ setDrafts((current) => ({
+ ...current,
+ [id]: {
+ ...current[id],
+ ...patch,
+ },
+ }));
+ }
+
+ function updateLocalizedDraft(
+ id: string,
+ field: "markdown" | "summary" | "title" | "transcript",
+ value: string
+ ) {
+ setDrafts((current) => {
+ const draft = current[id];
+ if (!draft) return current;
+ return {
+ ...current,
+ [id]: {
+ ...draft,
+ [field]: {
+ ...draft[field],
+ [contentLocale]: value,
+ },
+ },
+ };
+ });
+ }
+
+ function updateSection(id: string, patch: Partial) {
+ setSections((current) =>
+ current.map((section) =>
+ section.id === id ? { ...section, ...patch } : section
+ )
+ );
+ }
+
+ function updateCategory(id: string, patch: Partial) {
+ setCategories((current) =>
+ current.map((category) =>
+ category.id === id ? { ...category, ...patch } : category
+ )
+ );
+ }
+
+ function deleteSelectedCategory() {
+ if (!selectedCategoryDetails || categories.length <= 1) return;
+
+ const fallbackCategory = categories.find(
+ (category) => category.id !== selectedCategoryDetails.id
+ );
+ if (!fallbackCategory) return;
+
+ setCategories((current) =>
+ current
+ .filter((category) => category.id !== selectedCategoryDetails.id)
+ .map((category) => {
+ const nextCategory =
+ category.id === fallbackCategory.id
+ ? {
+ ...category,
+ sectionIds: Array.from(
+ new Set([
+ ...category.sectionIds,
+ ...selectedCategoryDetails.sectionIds,
+ ])
+ ),
+ }
+ : category;
+
+ return nextCategory.prerequisitePathId === selectedCategoryDetails.id
+ ? { ...nextCategory, prerequisitePathId: null }
+ : nextCategory;
+ })
+ );
+ setSelectedCategoryId(fallbackCategory.id);
+ setSelectedSectionId("");
+ setSelectedSectionFrameId("");
+ setDeleteDialogType(null);
+ toast.success(c.deletedCategory);
+ }
+
+ function deleteSelectedSection() {
+ if (!selectedSectionDetails || sections.length <= 1) return;
+
+ const lessonIds = new Set(
+ nodes
+ .filter((node) => node.sectionId === selectedSectionDetails.id)
+ .map((node) => node.id)
+ );
+ const remainingSections = sections.filter(
+ (section) => section.id !== selectedSectionDetails.id
+ );
+ const fallbackSection = remainingSections[0];
+ const fallbackCategory = categories.find((category) =>
+ category.sectionIds.includes(fallbackSection.id)
+ );
+
+ setSections(remainingSections);
+ setSectionFrames((current) =>
+ current.filter((frame) => frame.sectionId !== selectedSectionDetails.id)
+ );
+ setCategories((current) =>
+ current.map((category) => ({
+ ...category,
+ sectionIds: category.sectionIds.filter(
+ (sectionId) => sectionId !== selectedSectionDetails.id
+ ),
+ }))
+ );
+ setNodes((current) => current.filter((node) => !lessonIds.has(node.id)));
+ setConnections((current) =>
+ current.filter(
+ (connection) =>
+ !lessonIds.has(connection.sourceId) &&
+ !lessonIds.has(connection.targetId)
+ )
+ );
+ setDrafts((current) => {
+ const next = { ...current };
+ lessonIds.forEach((lessonId) => delete next[lessonId]);
+ return next;
+ });
+ setSelectedId("");
+ setSelectedIds([]);
+ setSelectedConnectionId("");
+ setSelectedSectionId(fallbackSection.id);
+ setSelectedSectionFrameId(fallbackSection.id);
+ setSelectedCategoryId(fallbackCategory?.id ?? categories[0]?.id ?? "");
+ setDeleteDialogType(null);
+ toast.success(c.deletedSection);
+ }
+
+ function updateQuizQuestion(questionIndex: number, patch: Partial) {
+ if (!selectedLesson) return;
+
+ setDrafts((current) => {
+ const draft = current[selectedLesson.id];
+ if (!draft) return current;
+ return {
+ ...current,
+ [selectedLesson.id]: {
+ ...draft,
+ quiz: draft.quiz.map((question, index) =>
+ index === questionIndex ? { ...question, ...patch } : question
+ ),
+ },
+ };
+ });
+ }
+
+ function updateQuizOption(questionIndex: number, optionIndex: number, value: string) {
+ if (!selectedLesson) return;
+
+ setDrafts((current) => {
+ const draft = current[selectedLesson.id];
+ const question = draft?.quiz[questionIndex];
+ if (!draft || !question) return current;
+ return {
+ ...current,
+ [selectedLesson.id]: {
+ ...draft,
+ quiz: draft.quiz.map((item, index) =>
+ index === questionIndex
+ ? {
+ ...item,
+ options: question.options.map((option, currentOptionIndex) =>
+ currentOptionIndex === optionIndex
+ ? { ...option, [contentLocale]: value }
+ : option
+ ),
+ }
+ : item
+ ),
+ },
+ };
+ });
+ }
+
+ function addQuizQuestion() {
+ if (!selectedLesson || !selectedDraft) return;
+
+ updateDraft(selectedLesson.id, {
+ quiz: [
+ ...selectedDraft.quiz,
+ {
+ answerIndex: 0,
+ options: [
+ { en: "First option", ro: "Prima opțiune" },
+ { en: "Second option", ro: "A doua opțiune" },
+ { en: "Third option", ro: "A treia opțiune" },
+ ],
+ question: {
+ en: "New quiz question",
+ ro: "Întrebare nouă pentru quiz",
+ },
+ },
+ ],
+ });
+ }
+
+ function addLesson(kind: NonNullable) {
+ const id = `draft-${kind}-${Date.now().toString(36)}`;
+ const lesson = createLessonShell(id, kind, nodes.length + 1);
+ const viewport = viewportRef.current;
+ const centerX = viewport ? viewport.clientWidth / 2 : 520;
+ const centerY = viewport ? viewport.clientHeight / 2 : 320;
+ const x = (centerX - pan.x) / scale - NODE_WIDTH / 2;
+ const y = (centerY - pan.y) / scale - NODE_HEIGHT / 2;
+ const sectionId = selectedNode?.sectionId ?? selectedSectionId ?? sections[0]?.id ?? "draft";
+
+ setNodes((current) => [
+ ...current,
+ {
+ id,
+ lesson,
+ sectionId,
+ x,
+ y,
+ },
+ ]);
+ setDrafts((current) => ({
+ ...current,
+ [id]: buildInitialDrafts([lesson])[id] ?? {
+ code: lesson.code,
+ completionRequirement: getLessonCompletionRequirement(lesson),
+ kind,
+ level: lesson.level,
+ locked: kind !== "theory",
+ markdown: toLocalizedDraftText(
+ getLessonMarkdown(lesson, "en"),
+ getLessonMarkdown(lesson, "ro")
+ ),
+ minutes: lesson.minutes,
+ quiz: lesson.quiz.map((question) => ({
+ answerIndex: question.answerIndex,
+ options: question.options.map((option) =>
+ toLocalizedDraftText(text(option, "en"), text(option, "ro"))
+ ),
+ question: toLocalizedDraftText(
+ text(question.question, "en"),
+ text(question.question, "ro")
+ ),
+ })),
+ requiredProblemCodes: "",
+ requiresCorrectQuiz: kind !== "theory",
+ sampleInput: lesson.sampleInput,
+ summary: toLocalizedDraftText(
+ text(lesson.summary, "en"),
+ text(lesson.summary, "ro")
+ ),
+ tags: lesson.tags.join(", "),
+ title: toLocalizedDraftText(
+ text(lesson.title, "en"),
+ text(lesson.title, "ro")
+ ),
+ transcript: toLocalizedDraftText(
+ text(lesson.transcript, "en"),
+ text(lesson.transcript, "ro")
+ ),
+ videoUrl: lesson.videoUrl ?? "",
+ },
+ }));
+ setSelectedId(id);
+ setSelectedIds([id]);
+ setSelectedSectionId(sectionId);
+ setSelectedSectionFrameId("");
+ toast.success(c.addedLesson);
+ }
+
+ function duplicateSelectedLesson() {
+ if (!selectedLesson || !selectedDraft || !selectedNode) return;
+
+ const id = `${selectedLesson.id}-copy-${Date.now().toString(36)}`;
+ const lesson: LearnLesson = {
+ ...selectedLesson,
+ id,
+ order: nodes.length + 1,
+ title: {
+ en: `${selectedDraft.title.en} copy`,
+ ro: `${selectedDraft.title.ro} copie`,
+ },
+ };
+
+ setNodes((current) => [
+ ...current,
+ {
+ ...selectedNode,
+ id,
+ lesson,
+ x: selectedNode.x + 36,
+ y: selectedNode.y + 36,
+ },
+ ]);
+ setDrafts((current) => ({
+ ...current,
+ [id]: {
+ ...selectedDraft,
+ title: {
+ en: `${selectedDraft.title.en} copy`,
+ ro: `${selectedDraft.title.ro} copie`,
+ },
+ },
+ }));
+ setSelectedId(id);
+ setSelectedIds([id]);
+ setSelectedSectionFrameId("");
+ toast.success(c.duplicatedLesson);
+ }
+
+ function deleteSelectedLesson() {
+ if (!selectedLesson || nodes.length <= 1) return;
+
+ const idsToDelete = new Set(selectedIds.length ? selectedIds : [selectedLesson.id]);
+ if (idsToDelete.size >= nodes.length) return;
+
+ const nextNodes = nodes.filter((node) => !idsToDelete.has(node.id));
+ setNodes(nextNodes);
+ setConnections((current) =>
+ current.filter(
+ (connection) =>
+ !idsToDelete.has(connection.sourceId) && !idsToDelete.has(connection.targetId)
+ )
+ );
+ setDrafts((current) => {
+ const next = { ...current };
+ idsToDelete.forEach((id) => delete next[id]);
+ return next;
+ });
+ setSelectedId(nextNodes[0]?.id ?? "");
+ setSelectedIds(nextNodes[0]?.id ? [nextNodes[0].id] : []);
+ setSelectedSectionId(nextNodes[0]?.sectionId ?? sections[0]?.id ?? "");
+ setSelectedSectionFrameId("");
+ toast.success(c.deletedLesson);
+ }
+
+ function deleteSelectedConnection() {
+ if (!selectedConnectionId) return;
+
+ setConnections((current) =>
+ current.filter((connection) => connection.id !== selectedConnectionId)
+ );
+ setSelectedConnectionId("");
+ toast.success(c.connectionDeleted);
+ }
+
+ function deleteCurrentSelection() {
+ if (selectedConnectionId) {
+ deleteSelectedConnection();
+ return;
+ }
+
+ if (selectedSectionFrameId) {
+ setDeleteDialogType("section");
+ return;
+ }
+
+ if (categoryIsExplicitlySelected) {
+ setDeleteDialogType("category");
+ return;
+ }
+
+ deleteSelectedLesson();
+ }
+
+ function updateSelectedConnection(patch: Partial) {
+ if (!selectedConnectionId) return;
+
+ setConnections((current) =>
+ current.map((connection) =>
+ connection.id === selectedConnectionId
+ ? { ...connection, ...patch }
+ : connection
+ )
+ );
+ }
+
+ function reverseSelectedConnection() {
+ if (!selectedConnection) return;
+
+ updateSelectedConnection({
+ sourceId: selectedConnection.targetId,
+ sourceSide: selectedConnection.targetSide,
+ targetId: selectedConnection.sourceId,
+ targetSide: selectedConnection.sourceSide,
+ });
+ toast.success(c.connectionReversed);
+ }
+
+ function getViewportPoint(event: React.PointerEvent) {
+ const viewport = viewportRef.current;
+ const rect = viewport?.getBoundingClientRect();
+
+ return {
+ x: rect ? event.clientX - rect.left : event.clientX,
+ y: rect ? event.clientY - rect.top : event.clientY,
+ };
+ }
+
+ function getNodeIdsInRect(rect: SelectionRect) {
+ const bounds = getSelectionBounds(rect);
+ const worldBounds = {
+ left: (bounds.left - pan.x) / scale,
+ right: (bounds.left + bounds.width - pan.x) / scale,
+ top: (bounds.top - pan.y) / scale,
+ bottom: (bounds.top + bounds.height - pan.y) / scale,
+ };
+
+ return nodes
+ .filter((node) => {
+ const nodeBounds = {
+ left: node.x,
+ right: node.x + NODE_WIDTH,
+ top: node.y,
+ bottom: node.y + NODE_HEIGHT,
+ };
+
+ return (
+ nodeBounds.left <= worldBounds.right &&
+ nodeBounds.right >= worldBounds.left &&
+ nodeBounds.top <= worldBounds.bottom &&
+ nodeBounds.bottom >= worldBounds.top
+ );
+ })
+ .map((node) => node.id);
+ }
+
+ function cloneDraft(draft: LessonDraft): LessonDraft {
+ return {
+ ...draft,
+ markdown: { ...draft.markdown },
+ summary: { ...draft.summary },
+ title: { ...draft.title },
+ transcript: { ...draft.transcript },
+ quiz: draft.quiz.map((question) => ({
+ ...question,
+ options: question.options.map((option) => ({ ...option })),
+ question: { ...question.question },
+ })),
+ };
+ }
+
+ function copySelectedNodes() {
+ const idsToCopy = selectedIds.length ? selectedIds : selectedId ? [selectedId] : [];
+ const nodesToCopy = nodes.filter((node) => idsToCopy.includes(node.id));
+ if (!nodesToCopy.length) return;
+
+ const minX = Math.min(...nodesToCopy.map((node) => node.x));
+ const minY = Math.min(...nodesToCopy.map((node) => node.y));
+
+ clipboardRef.current = nodesToCopy
+ .map((node) => {
+ const draft = drafts[node.id];
+ if (!draft) return null;
+
+ return {
+ draft: cloneDraft(draft),
+ node,
+ offsetX: node.x - minX,
+ offsetY: node.y - minY,
+ };
+ })
+ .filter((item): item is ClipboardNode => Boolean(item));
+ }
+
+ function pasteCopiedNodes() {
+ const copiedNodes = clipboardRef.current;
+ if (!copiedNodes.length) return;
+
+ const viewport = viewportRef.current;
+ const centerX = viewport ? viewport.clientWidth / 2 : 520;
+ const centerY = viewport ? viewport.clientHeight / 2 : 320;
+ const baseX = (centerX - pan.x) / scale - NODE_WIDTH / 2;
+ const baseY = (centerY - pan.y) / scale - NODE_HEIGHT / 2;
+ const pastedIds: string[] = [];
+ const nextDraftEntries: Array<[string, LessonDraft]> = [];
+ const nextNodes = copiedNodes.map((item, index) => {
+ const id = `${item.node.id}-copy-${Date.now().toString(36)}-${index + 1}`;
+ const draft = {
+ ...cloneDraft(item.draft),
+ title: {
+ en: `${item.draft.title.en} copy`,
+ ro: `${item.draft.title.ro} copie`,
+ },
+ };
+ const lesson: LearnLesson = {
+ ...item.node.lesson,
+ id,
+ kind: draft.kind,
+ level: draft.level,
+ minutes: draft.minutes,
+ order: nodes.length + index + 1,
+ title: draft.title,
+ };
+
+ pastedIds.push(id);
+ nextDraftEntries.push([id, draft]);
+
+ return {
+ ...item.node,
+ id,
+ lesson,
+ x: baseX + item.offsetX,
+ y: baseY + item.offsetY,
+ };
+ });
+
+ setNodes((current) => [...current, ...nextNodes]);
+ setDrafts((current) => ({
+ ...current,
+ ...Object.fromEntries(nextDraftEntries),
+ }));
+ setSelectedIds(pastedIds);
+ setSelectedId(pastedIds[0] ?? selectedId);
+ setSelectedSectionFrameId("");
+ toast.success(c.pastedLessons);
+ }
+
+ useEffect(() => {
+ function handleClipboardShortcuts(event: KeyboardEvent) {
+ if (isTextEditingTarget(event.target) || isCanvasWidgetTarget(event.target)) {
+ return;
+ }
+
+ const key = event.key.toLowerCase();
+ if (key === "backspace" || key === "delete") {
+ event.preventDefault();
+ if (selectedConnectionId) {
+ deleteSelectedConnection();
+ } else if (selectedSectionFrameId && sections.length > 1) {
+ setDeleteDialogType("section");
+ } else if (
+ !selectedId &&
+ !selectedSectionId &&
+ selectedCategoryDetails &&
+ categories.length > 1
+ ) {
+ setDeleteDialogType("category");
+ } else {
+ deleteSelectedLesson();
+ }
+ return;
+ }
+
+ if (!event.metaKey && !event.ctrlKey) {
+ return;
+ }
+
+ if (key === "c") {
+ event.preventDefault();
+ copySelectedNodes();
+ }
+
+ if (key === "v") {
+ event.preventDefault();
+ pasteCopiedNodes();
+ }
+ }
+
+ window.addEventListener("keydown", handleClipboardShortcuts);
+
+ return () => window.removeEventListener("keydown", handleClipboardShortcuts);
+ });
+
+ function getWorldPoint(clientX: number, clientY: number) {
+ const viewport = viewportRef.current;
+ const rect = viewport?.getBoundingClientRect();
+ const viewportX = rect ? clientX - rect.left : clientX;
+ const viewportY = rect ? clientY - rect.top : clientY;
+
+ return {
+ x: (viewportX - pan.x) / scale,
+ y: (viewportY - pan.y) / scale,
+ };
+ }
+
+ function findClosestHandle(
+ point: { x: number; y: number },
+ excludedNodeId?: string
+ ) {
+ let closest:
+ | { nodeId: string; side: RoadmapConnectionSide; x: number; y: number; distance: number }
+ | undefined;
+
+ nodes.forEach((node) => {
+ if (node.id === excludedNodeId) return;
+
+ HANDLE_SIDES.forEach((side) => {
+ const handle = getHandlePoint(node, side);
+ const distance = Math.hypot(point.x - handle.x, point.y - handle.y);
+ if (distance <= HANDLE_SNAP_DISTANCE && (!closest || distance < closest.distance)) {
+ closest = { nodeId: node.id, side, ...handle, distance };
+ }
+ });
+ });
+
+ return closest;
+ }
+
+ function beginConnection(
+ event: React.PointerEvent,
+ node: CanvasNode,
+ side: RoadmapConnectionSide
+ ) {
+ event.preventDefault();
+ event.stopPropagation();
+ viewportRef.current?.setPointerCapture(event.pointerId);
+ const point = getHandlePoint(node, side);
+ const draft: ConnectionDraft = {
+ type: "connection",
+ sourceId: node.id,
+ sourceSide: side,
+ currentX: point.x,
+ currentY: point.y,
+ };
+
+ dragRef.current = draft;
+ setConnectionDraft(draft);
+ setSelectedConnectionId("");
+ setSelectedId(node.id);
+ setSelectedIds([node.id]);
+ setSelectedSectionId(node.sectionId);
+ setSelectedSectionFrameId("");
+ }
+
+ function beginSectionResize(
+ event: React.PointerEvent,
+ frame: RoadmapSectionFrame,
+ corner: "nw" | "ne" | "sw" | "se"
+ ) {
+ event.preventDefault();
+ event.stopPropagation();
+ viewportRef.current?.setPointerCapture(event.pointerId);
+ dragRef.current = {
+ type: "section-resize",
+ corner,
+ origin: frame,
+ sectionId: frame.sectionId,
+ startX: event.clientX,
+ startY: event.clientY,
+ };
+ setSelectedId("");
+ setSelectedIds([]);
+ setSelectedConnectionId("");
+ setSelectedSectionId(frame.sectionId);
+ setSelectedSectionFrameId(frame.sectionId);
+ }
+
+ function beginSectionMove(
+ event: React.PointerEvent,
+ frame: RoadmapSectionFrame
+ ) {
+ event.preventDefault();
+ event.stopPropagation();
+ event.currentTarget.setPointerCapture(event.pointerId);
+ dragRef.current = {
+ type: "section-move",
+ frameOrigin: frame,
+ nodeOrigins: Object.fromEntries(
+ nodes
+ .filter((node) => node.sectionId === frame.sectionId)
+ .map((node) => [node.id, { x: node.x, y: node.y }])
+ ),
+ sectionId: frame.sectionId,
+ startX: event.clientX,
+ startY: event.clientY,
+ };
+ setSelectedId("");
+ setSelectedIds([]);
+ setSelectedConnectionId("");
+ setSelectedSectionId(frame.sectionId);
+ setSelectedSectionFrameId(frame.sectionId);
+ }
+
+ function beginWidgetResize(
+ event: React.PointerEvent,
+ widget: ResizableWidgetId,
+ horizontalDirection: -1 | 1
+ ) {
+ const bounds = event.currentTarget.parentElement?.getBoundingClientRect();
+ if (!bounds) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+ event.currentTarget.setPointerCapture(event.pointerId);
+ dragRef.current = {
+ type: "widget-resize",
+ horizontalDirection,
+ originHeight: bounds.height,
+ originWidth: bounds.width,
+ startX: event.clientX,
+ startY: event.clientY,
+ widget,
+ };
+ }
+
+ function beginNodeDrag(event: React.PointerEvent, node: CanvasNode) {
+ if (spacePressed) return;
+
+ event.stopPropagation();
+ setSelectedConnectionId("");
+ setSelectedSectionFrameId("");
+
+ if (event.shiftKey) {
+ setSelectedIds((current) => {
+ const next = current.includes(node.id)
+ ? current.filter((id) => id !== node.id)
+ : [...current, node.id];
+
+ if (!next.length) {
+ setSelectedId(node.id);
+ setSelectedSectionId(node.sectionId);
+ return [node.id];
+ }
+
+ setSelectedId(next.includes(node.id) ? node.id : next[0]);
+ setSelectedSectionId(node.sectionId);
+ return next;
+ });
+ return;
+ }
+
+ event.currentTarget.setPointerCapture(event.pointerId);
+ const ids = selectedIds.includes(node.id) ? selectedIds : [node.id];
+ const origins = Object.fromEntries(
+ nodes
+ .filter((item) => ids.includes(item.id))
+ .map((item) => [item.id, { x: item.x, y: item.y }])
+ );
+
+ dragRef.current = {
+ type: "nodes",
+ ids,
+ startX: event.clientX,
+ startY: event.clientY,
+ origins,
+ };
+ setSelectedId(node.id);
+ setSelectedIds(ids);
+ setSelectedSectionId(node.sectionId);
+ }
+
+ function beginCanvasDrag(event: React.PointerEvent) {
+ if (
+ isTextEditingTarget(event.target) ||
+ isCanvasWidgetTarget(event.target) ||
+ event.button !== 0
+ ) {
+ return;
+ }
+
+ event.preventDefault();
+ event.currentTarget.setPointerCapture(event.pointerId);
+
+ if (!spacePressed && event.pointerType !== "touch") {
+ const point = getViewportPoint(event);
+ const rect = {
+ startX: point.x,
+ startY: point.y,
+ currentX: point.x,
+ currentY: point.y,
+ };
+
+ dragRef.current = { type: "selection", ...rect };
+ setSelectionRect(rect);
+ setConnectionDraft(null);
+ setSelectedCategoryId("");
+ setSelectedIds([]);
+ setSelectedId("");
+ setSelectedConnectionId("");
+ setSelectedSectionFrameId("");
+ setSelectedSectionId("");
+ return;
+ }
+
+ dragRef.current = {
+ type: "canvas",
+ startX: event.clientX,
+ startY: event.clientY,
+ originX: pan.x,
+ originY: pan.y,
+ };
+ }
+
+ function handlePointerMove(event: React.PointerEvent) {
+ const drag = dragRef.current;
+ if (!drag) return;
+
+ if (drag.type === "canvas") {
+ setPan({
+ x: drag.originX + event.clientX - drag.startX,
+ y: drag.originY + event.clientY - drag.startY,
+ });
+ return;
+ }
+
+ if (drag.type === "selection") {
+ const point = getViewportPoint(event);
+ const rect = {
+ startX: drag.startX,
+ startY: drag.startY,
+ currentX: point.x,
+ currentY: point.y,
+ };
+ const nextIds = getNodeIdsInRect(rect);
+
+ dragRef.current = { type: "selection", ...rect };
+ setSelectionRect(rect);
+ setSelectedIds(nextIds);
+ if (nextIds[0]) setSelectedId(nextIds[0]);
+ return;
+ }
+
+ if (drag.type === "connection") {
+ const pointer = getWorldPoint(event.clientX, event.clientY);
+ const target = findClosestHandle(pointer, drag.sourceId);
+ const nextDraft: ConnectionDraft = {
+ ...drag,
+ currentX: target?.x ?? pointer.x,
+ currentY: target?.y ?? pointer.y,
+ targetId: target?.nodeId,
+ targetSide: target?.side,
+ };
+
+ dragRef.current = nextDraft;
+ setConnectionDraft(nextDraft);
+ return;
+ }
+
+ if (drag.type === "section-resize") {
+ const deltaX = (event.clientX - drag.startX) / scale;
+ const deltaY = (event.clientY - drag.startY) / scale;
+ const resizesFromLeft = drag.corner === "nw" || drag.corner === "sw";
+ const resizesFromTop = drag.corner === "nw" || drag.corner === "ne";
+ const width = Math.max(
+ MIN_SECTION_WIDTH,
+ drag.origin.width + (resizesFromLeft ? -deltaX : deltaX)
+ );
+ const height = Math.max(
+ MIN_SECTION_HEIGHT,
+ drag.origin.height + (resizesFromTop ? -deltaY : deltaY)
+ );
+
+ setSectionFrames((current) =>
+ current.map((frame) =>
+ frame.sectionId === drag.sectionId
+ ? {
+ ...frame,
+ height,
+ width,
+ x: resizesFromLeft
+ ? drag.origin.x + drag.origin.width - width
+ : drag.origin.x,
+ y: resizesFromTop
+ ? drag.origin.y + drag.origin.height - height
+ : drag.origin.y,
+ }
+ : frame
+ )
+ );
+ return;
+ }
+
+ if (drag.type === "section-move") {
+ const deltaX = (event.clientX - drag.startX) / scale;
+ const deltaY = (event.clientY - drag.startY) / scale;
+ const nextX = drag.frameOrigin.x + deltaX;
+ const nextY = drag.frameOrigin.y + deltaY;
+ const appliedDeltaX = nextX - drag.frameOrigin.x;
+ const appliedDeltaY = nextY - drag.frameOrigin.y;
+
+ setSectionFrames((current) =>
+ current.map((frame) =>
+ frame.sectionId === drag.sectionId
+ ? { ...frame, x: nextX, y: nextY }
+ : frame
+ )
+ );
+ setNodes((current) =>
+ current.map((node) => {
+ const origin = drag.nodeOrigins[node.id];
+ return origin
+ ? {
+ ...node,
+ x: origin.x + appliedDeltaX,
+ y: origin.y + appliedDeltaY,
+ }
+ : node;
+ })
+ );
+ return;
+ }
+
+ if (drag.type === "widget-resize") {
+ const viewport = viewportRef.current;
+ const limits = WIDGET_LIMITS[drag.widget];
+ const maxHeight =
+ drag.widget === "inspector"
+ ? Math.max(limits.height, (viewport?.clientHeight ?? 760) - 96)
+ : 240;
+ const width = Math.min(
+ Math.max(
+ limits.width,
+ drag.originWidth +
+ (event.clientX - drag.startX) * drag.horizontalDirection
+ ),
+ Math.max(limits.width, (viewport?.clientWidth ?? 900) - 32)
+ );
+ const height = Math.min(
+ Math.max(limits.height, drag.originHeight + event.clientY - drag.startY),
+ maxHeight
+ );
+
+ setWidgetSizes((current) => ({
+ ...current,
+ [drag.widget]: { height, width },
+ }));
+ return;
+ }
+
+ setNodes((current) =>
+ current.map((node) =>
+ drag.ids.includes(node.id)
+ ? {
+ ...node,
+ x:
+ drag.origins[node.id].x +
+ (event.clientX - drag.startX) / scale,
+ y:
+ drag.origins[node.id].y +
+ (event.clientY - drag.startY) / scale,
+ }
+ : node
+ )
+ );
+ }
+
+ function endDrag() {
+ if (dragRef.current?.type === "connection") {
+ const draft = dragRef.current;
+ if (draft.targetId && draft.targetSide) {
+ const connection: RoadmapConfigConnection = {
+ id: `${draft.sourceId}-${draft.sourceSide}-${draft.targetId}-${draft.targetSide}-${Date.now().toString(36)}`,
+ sourceId: draft.sourceId,
+ sourceSide: draft.sourceSide,
+ targetId: draft.targetId,
+ targetSide: draft.targetSide,
+ };
+ const existing = connections.find(
+ (item) =>
+ item.sourceId === connection.sourceId &&
+ item.sourceSide === connection.sourceSide &&
+ item.targetId === connection.targetId &&
+ item.targetSide === connection.targetSide
+ );
+
+ if (existing) {
+ setSelectedConnectionId(existing.id);
+ } else {
+ setConnections((current) => [...current, connection]);
+ setSelectedConnectionId(connection.id);
+ toast.success(c.connectionAdded);
+ }
+ setSelectedIds([]);
+ setSelectedId("");
+ }
+
+ setConnectionDraft(null);
+ dragRef.current = null;
+ return;
+ }
+
+ if (dragRef.current?.type === "selection") {
+ const ids = getNodeIdsInRect(dragRef.current);
+ setSelectedIds(ids);
+ if (ids[0]) setSelectedId(ids[0]);
+ }
+
+ setSelectionRect(null);
+ dragRef.current = null;
+ }
+
+ return (
+
+ {markdownStudioLesson && markdownStudioDraft && (
+
+
+
+
+
+
+
+
+ {draftText(markdownStudioDraft.title, contentLocale)}
+
+
{c.studioDraft}
+
+
+
+
+
setContentLocale(value as LessonLocale)}
+ >
+
+
+
+
+ {c.english}
+ {c.romanian}
+
+
+
+ {([
+ ["edit", PencilLine, c.edit],
+ ["split", Columns2, c.split],
+ ["preview", Eye, c.preview],
+ ] as const).map(([mode, Icon, label]) => (
+ setMarkdownStudioMode(mode)}
+ >
+
+ {label}
+
+ ))}
+
+
+
+ {isSaving ? c.saving : c.save}
+
+
setMarkdownStudioLessonId(null)}
+ >
+
+
+
+
+
+
+ {markdownStudioMode !== "preview" && (
+
+
+ updateLocalizedDraft(markdownStudioLesson.id, "markdown", value)
+ }
+ language="markdown"
+ path={`lesson-${markdownStudioLesson.id}-${contentLocale}.md`}
+ theme="dark"
+ height="100%"
+ options={{
+ automaticLayout: true,
+ folding: true,
+ glyphMargin: false,
+ lineDecorationsWidth: 10,
+ lineNumbers: "on",
+ lineNumbersMinChars: 3,
+ minimap: { enabled: false },
+ padding: { top: 28, bottom: 32 },
+ quickSuggestions: false,
+ renderWhitespace: "selection",
+ scrollBeyondLastLine: false,
+ wordWrap: "on",
+ wrappingIndent: "same",
+ }}
+ />
+
+ )}
+ {markdownStudioMode !== "edit" && (
+
+
+
+
{c.preview}
+
+ {draftText(markdownStudioDraft.title, contentLocale)}
+
+
+ {draftText(markdownStudioDraft.summary, contentLocale)}
+
+
+
+ {draftText(markdownStudioDraft.markdown, contentLocale)}
+
+
+
+ )}
+
+
+ )}
+ {
+ if (!open) closeCreateDialog();
+ }}
+ >
+
+
+
+ {createDialogType === "category" ? c.addCategory : c.addSection}
+
+
+
+ {createDialogType === "category"
+ ? c.categoryNamePrompt
+ : c.sectionNamePrompt}
+ setCreateName(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") submitCreateDialog();
+ }}
+ autoFocus
+ />
+
+
+
+ {c.cancel}
+
+
+ {c.create}
+
+
+
+
+ {
+ if (!open) setDeleteDialogType(null);
+ }}
+ >
+
+
+
+ {deleteDialogType === "category"
+ ? c.deleteCategory
+ : c.deleteSection}
+
+
+ {deleteDialogType === "category"
+ ? c.deleteCategoryDescription
+ : c.deleteSectionDescription}
+
+
+
+
+ {deleteDialogType === "category"
+ ? c.keepCategory
+ : c.keepSection}
+
+
+ {deleteDialogType === "category"
+ ? c.deleteCategory
+ : c.deleteSection}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {c.back}
+
+
+
+
+
+
+
+
+
+ event.stopPropagation()}
+ >
+
+
+ setQuery(event.target.value)}
+ placeholder={c.search}
+ className="h-9 pl-8"
+ />
+
+
+
+
+
+ setQuery(event.target.value)}
+ placeholder={c.search}
+ className="h-8 pl-8 pr-2"
+ />
+
+
+
+
+
+
+
+ event.stopPropagation()}
+ >
+
+ {c.addMenu}
+
+
+ {[
+ {
+ icon: Video,
+ label: c.addVideo,
+ onClick: () => addLesson("video" as const),
+ tone: "text-blue-600 dark:text-blue-400",
+ },
+ {
+ icon: BookOpen,
+ label: c.addTheory,
+ onClick: () => addLesson("theory" as const),
+ tone: "text-emerald-600 dark:text-emerald-400",
+ },
+ {
+ icon: Trophy,
+ label: c.addChallenge,
+ onClick: () => addLesson("challenge" as const),
+ tone: "text-amber-600 dark:text-amber-400",
+ },
+ {
+ icon: ClipboardList,
+ label: c.addAssessment,
+ onClick: () => addLesson("assessment" as const),
+ tone: "text-violet-600 dark:text-violet-400",
+ },
+ {
+ icon: Plus,
+ label: c.addSection,
+ onClick: () => openCreateDialog("section"),
+ tone: "text-foreground",
+ },
+ {
+ icon: ClipboardList,
+ label: c.addCategory,
+ onClick: () => openCreateDialog("category"),
+ tone: "text-foreground",
+ },
+ ].map((item) => {
+ const Icon = item.icon;
+
+ return (
+ {
+ event.stopPropagation();
+ item.onClick();
+ }}
+ onPointerDown={(event) => event.stopPropagation()}
+ className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition hover:bg-muted"
+ >
+
+ {item.label}
+
+ );
+ })}
+
+
+
+
+
+
+ setZoom(([value]) => [clampZoom(value - 10)])}
+ aria-label={c.zoomOut}
+ >
+
+
+
+ {c.zoomOut}
+
+
+
+
+
+
+ setZoom(([value]) => [clampZoom(value + 10)])}
+ aria-label={c.zoomIn}
+ >
+
+
+
+ {c.zoomIn}
+
+
+
+ {c.reset}
+
+
setWidgetsHidden((hidden) => !hidden)}
+ aria-label={widgetsHidden ? c.showWidgets : c.hideWidgets}
+ >
+ {widgetsHidden ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isSaving ? c.saving : c.save}
+
+
+
+
+ {sectionFrames.map((frame) => {
+ const section = sections.find((item) => item.id === frame.sectionId);
+ if (!section) return null;
+ const active = selectedSectionFrameId === section.id;
+ const isEmpty = !nodes.some((node) => node.sectionId === section.id);
+
+ return (
+
+ {isEmpty && (
+ event.stopPropagation()}
+ onContextMenu={() => {
+ setSelectedId("");
+ setSelectedIds([]);
+ setSelectedConnectionId("");
+ setSelectedSectionId(section.id);
+ setSelectedSectionFrameId(section.id);
+ setSelectedCategoryId(
+ categories.find((category) =>
+ category.sectionIds.includes(section.id)
+ )?.id ?? selectedCategoryId
+ );
+ }}
+ onClick={(event) => {
+ event.stopPropagation();
+ setSelectedId("");
+ setSelectedIds([]);
+ setSelectedConnectionId("");
+ setSelectedSectionId(section.id);
+ setSelectedSectionFrameId(section.id);
+ setSelectedCategoryId(
+ categories.find((category) =>
+ category.sectionIds.includes(section.id)
+ )?.id ?? selectedCategoryId
+ );
+ }}
+ />
+ )}
+ {active && (
+ (["nw", "ne", "sw", "se"] as const).map((corner) => (
+ beginSectionResize(event, frame, corner)}
+ />
+ ))
+ )}
+ beginSectionMove(event, frame)}
+ onContextMenu={() => {
+ setSelectedId("");
+ setSelectedIds([]);
+ setSelectedConnectionId("");
+ setSelectedSectionId(section.id);
+ setSelectedSectionFrameId(section.id);
+ setSelectedCategoryId(
+ categories.find((category) =>
+ category.sectionIds.includes(section.id)
+ )?.id ?? selectedCategoryId
+ );
+ }}
+ onClick={(event) => {
+ event.stopPropagation();
+ setSelectedId("");
+ setSelectedIds([]);
+ setSelectedConnectionId("");
+ setSelectedSectionId(section.id);
+ setSelectedSectionFrameId(section.id);
+ setSelectedCategoryId(
+ categories.find((category) =>
+ category.sectionIds.includes(section.id)
+ )?.id ?? selectedCategoryId
+ );
+ }}
+ >
+
+ {text(section.title, lessonLocale)}
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+
+ {connections.map((connection) => {
+ const source = nodes.find((node) => node.id === connection.sourceId);
+ const target = nodes.find((node) => node.id === connection.targetId);
+ if (!source || !target) return null;
+ const start = getHandlePoint(source, connection.sourceSide);
+ const end = getHandlePoint(target, connection.targetSide);
+ const path = getConnectionPath(
+ start,
+ connection.sourceSide,
+ end,
+ connection.targetSide
+ );
+ const selected = selectedConnectionId === connection.id;
+
+ return (
+
+
+ event.stopPropagation()}
+ onClick={(event) => {
+ event.stopPropagation();
+ setSelectedConnectionId(connection.id);
+ setSelectedId("");
+ setSelectedIds([]);
+ setSelectedSectionFrameId("");
+ }}
+ />
+
+ );
+ })}
+ {connectionDraft && (() => {
+ const source = nodes.find((node) => node.id === connectionDraft.sourceId);
+ if (!source) return null;
+ const start = getHandlePoint(source, connectionDraft.sourceSide);
+ const end = { x: connectionDraft.currentX, y: connectionDraft.currentY };
+
+ return (
+
+ );
+ })()}
+
+
+ {categoryLabelPositions.map(({ category, x, y }) => {
+ return (
+
event.stopPropagation()}
+ onContextMenu={() => {
+ setSelectedId("");
+ setSelectedIds([]);
+ setSelectedConnectionId("");
+ setSelectedSectionId("");
+ setSelectedSectionFrameId("");
+ setSelectedCategoryId(category.id);
+ }}
+ onClick={(event) => {
+ event.stopPropagation();
+ setSelectedId("");
+ setSelectedIds([]);
+ setSelectedConnectionId("");
+ setSelectedSectionId("");
+ setSelectedSectionFrameId("");
+ setSelectedCategoryId(category.id);
+ }}
+ >
+ {text(category.title, lessonLocale)}
+
+ );
+ })}
+
+ {filteredNodes.map((node) => {
+ const isSelected = selectedIds.includes(node.id);
+ const nodeDraft = drafts[node.id];
+ const nodeRuleKind = getDraftRuleKind(node.lesson, nodeDraft);
+ const styles = ruleStyles[nodeRuleKind];
+ const nodeSection = sections.find(
+ (section) => section.id === node.sectionId
+ );
+
+ return (
+
1 &&
+ "outline outline-2 outline-blue-500"
+ )}
+ style={{ left: node.x, top: node.y }}
+ onPointerDown={(event) => beginNodeDrag(event, node)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ setSelectedId(node.id);
+ setSelectedIds([node.id]);
+ setSelectedSectionId(node.sectionId);
+ setSelectedConnectionId("");
+ setSelectedSectionFrameId("");
+ }
+ }}
+ >
+
+
+
+ {nodeRuleKind === "challenge" ? (
+
+ ) : nodeRuleKind === "assessment" ? (
+
+ ) : nodeRuleKind === "theory" ? (
+
+ ) : nodeRuleKind === "video" ? (
+
+ ) : nodeRuleKind === "bonus" ? (
+
+ ) : (
+
+ )}
+
+
+
+ {nodeDraft
+ ? draftText(nodeDraft.title, lessonLocale)
+ : text(node.lesson.title, lessonLocale)}
+
+
+ {nodeSection
+ ? text(nodeSection.title, lessonLocale)
+ : text(node.lesson.unit, lessonLocale)}
+
+
+
+
+
+
+
+
+ {nodeDraft?.minutes ?? node.lesson.minutes} min
+
+
+ {c.ruleKinds[nodeRuleKind]}
+
+
+ {HANDLE_SIDES.map((side) => {
+ const isSnapTarget =
+ connectionDraft?.targetId === node.id &&
+ connectionDraft.targetSide === side;
+
+ return (
+
beginConnection(event, node, side)}
+ />
+ );
+ })}
+
+ );
+ })}
+
+
+ {selectionRect && (
+
+ )}
+
+
+
+ {Math.round(scale * 100)}%
+
+
+
+
+ {c.keybinds}
+
+
+ {!widgetsHidden && hasConfigurableSelection && (
+
+
+ {selectedConnection && (
+
+
+
+
+ {c.connection}
+ {selectedConnection.id}
+
+
+
+
+
+
+
+
+
+
{c.from}
+
{selectedConnection.sourceId}
+
+
+
{c.to}
+
{selectedConnection.targetId}
+
+
+
+
+ {c.startSide}
+
+ updateSelectedConnection({
+ sourceSide: value as RoadmapConnectionSide,
+ })
+ }
+ >
+
+
+
+
+ {HANDLE_SIDES.map((side) => (
+ {side}
+ ))}
+
+
+
+
+ {c.endSide}
+
+ updateSelectedConnection({
+ targetSide: value as RoadmapConnectionSide,
+ })
+ }
+ >
+
+
+
+
+ {HANDLE_SIDES.map((side) => (
+ {side}
+ ))}
+
+
+
+
+
+
+ {c.reverseDirection}
+
+
+
+ )}
+ {!selectedConnection && !selectedLesson && !selectedSectionDetails && selectedCategoryDetails && (
+
+
+
+
+ {c.categoryDetails}
+
+ {selectedCategoryDetails.id}
+
+
+
setDeleteDialogType("category")}
+ aria-label={c.deleteCategory}
+ >
+
+
+
+
+
+
+ {c.categoryTitle}
+
+ updateCategory(selectedCategoryDetails.id, {
+ title: {
+ ...selectedCategoryDetails.title,
+ [lessonLocale]: event.target.value,
+ },
+ })
+ }
+ />
+
+
+ {c.categoryDescription}
+
+
+
+ {c.pathKind}
+
+ updateCategory(selectedCategoryDetails.id, {
+ kind: value as LearningPathKind,
+ })
+ }
+ >
+
+
+
+
+ {c.pathKinds.foundation}
+ {c.pathKinds.specialization}
+ {c.pathKinds.supplemental}
+
+
+
+
+ {c.availability}
+
+ updateCategory(selectedCategoryDetails.id, {
+ availability: value as LearningPathAvailability,
+ })
+ }
+ >
+
+
+
+
+ {c.availabilityValues.draft}
+ {c.availabilityValues.coming_soon}
+ {c.availabilityValues.published}
+ {c.availabilityValues.archived}
+
+
+
+
+
+
+ {c.programmingLanguage}
+
+ updateCategory(selectedCategoryDetails.id, {
+ language: event.target.value
+ .toLowerCase()
+ .replace(/\s+/g, "-"),
+ })
+ }
+ placeholder="python"
+ />
+
+
+ {c.slug}
+
+ updateCategory(selectedCategoryDetails.id, {
+ slug: normalizeSlugInput(event.target.value),
+ })
+ }
+ />
+
+
+
+ {c.prerequisite}
+
+ updateCategory(selectedCategoryDetails.id, {
+ prerequisitePathId: value === "none" ? null : value,
+ })
+ }
+ >
+
+
+
+
+ {c.noPrerequisite}
+ {categories
+ .filter(
+ (category) => category.id !== selectedCategoryDetails.id
+ )
+ .map((category) => (
+
+ {text(category.title, lessonLocale)}
+
+ ))}
+
+
+
+
+
+ {c.estimatedHours}
+
+ updateCategory(selectedCategoryDetails.id, {
+ estimatedHours: event.target.value
+ ? Math.max(1, Number(event.target.value))
+ : undefined,
+ })
+ }
+ />
+
+
+ {c.pathIcon}
+
+ updateCategory(selectedCategoryDetails.id, {
+ icon: event.target.value,
+ })
+ }
+ />
+
+
+ {c.accentColor}
+
+ updateCategory(selectedCategoryDetails.id, {
+ accentColor: event.target.value,
+ })
+ }
+ className="h-10 p-1"
+ />
+
+
+
+
+ )}
+ {!selectedConnection && !selectedLesson && selectedSectionDetails && (
+
+
+
+
+ {c.sectionDetails}
+
+ {selectedSectionDetails.id}
+
+
+
setDeleteDialogType("section")}
+ aria-label={c.deleteSection}
+ >
+
+
+
+
+
+
+ {c.sectionTitle}
+
+ updateSection(selectedSectionDetails.id, {
+ title: {
+ ...selectedSectionDetails.title,
+ [lessonLocale]: event.target.value,
+ },
+ })
+ }
+ />
+
+
+ {c.category}
+ {
+ setCategories((current) =>
+ current.map((category) => ({
+ ...category,
+ sectionIds:
+ category.id === value
+ ? Array.from(
+ new Set([
+ ...category.sectionIds,
+ selectedSectionDetails.id,
+ ])
+ )
+ : category.sectionIds.filter(
+ (sectionId) =>
+ sectionId !== selectedSectionDetails.id
+ ),
+ }))
+ );
+ updateSection(selectedSectionDetails.id, {
+ pathSlug: categories.find(
+ (category) => category.id === value
+ )?.slug,
+ });
+ setSelectedCategoryId(value);
+ }}
+ >
+
+
+
+
+ {categories.map((category) => (
+
+ {text(category.title, lessonLocale)}
+
+ ))}
+
+
+
+
+ {c.section}
+
+ updateSection(selectedSectionDetails.id, {
+ label: {
+ ...selectedSectionDetails.label,
+ [lessonLocale]: event.target.value,
+ },
+ })
+ }
+ />
+
+
+ {c.sectionDescription}
+
+
+
+ )}
+ {!selectedConnection && selectedLesson && selectedDraft && selectedRule && (
+
+
+
+
+
+ {c.selected}
+ {selectedLesson.id}
+
+
+ setContentLocale(value as LessonLocale)}
+ >
+
+
+
+
+ {c.english}
+ {c.romanian}
+
+
+
+ {c.ruleKinds[getDraftRuleKind(selectedLesson, selectedDraft)]}
+
+
+
+
+
+
+
+
{c.requiredQuiz}
+
+ {selectedRule.requiresCorrectQuiz ? (
+
+ ) : (
+
+ )}
+ {selectedRule.requiresCorrectQuiz ? c.locked : c.unlocked}
+
+
+
+
{c.requiredProblems}
+
+ {selectedRule.requiredProblemCodes.length || 0}
+
+
+
+
+
+
+
+
+
+
+ {c.lesson}
+
+
+
+ {c.content}
+
+
+
+ {c.quiz}
+
+
+
+
+
+
+
+ {c.titleLabel}
+
+ updateLocalizedDraft(selectedLesson.id, "title", event.target.value)
+ }
+ />
+
+
+ {c.summary}
+
+
+
+ {c.section}
+
+ setNodes((current) =>
+ current.map((node) =>
+ node.id === selectedLesson.id
+ ? { ...node, sectionId: value }
+ : node
+ )
+ )
+ }
+ >
+
+
+
+
+ {sections.map((section) => (
+
+ {text(section.title, lessonLocale)}
+
+ ))}
+
+
+
+ {selectedSection && (
+
+ {c.sectionTitle}
+
+ updateSection(selectedSection.id, {
+ title: {
+ ...selectedSection.title,
+ [lessonLocale]: event.target.value,
+ },
+ label: {
+ ...selectedSection.label,
+ [lessonLocale]: event.target.value,
+ },
+ })
+ }
+ />
+
+ )}
+
+ {c.kind}
+
+ updateDraft(selectedLesson.id, {
+ kind: value as NonNullable,
+ level:
+ value === "challenge" || value === "assessment"
+ ? "challenge"
+ : value === "theory"
+ ? "beginner"
+ : selectedDraft.level,
+ })
+ }
+ >
+
+
+
+
+ {c.addVideo}
+ {c.addTheory}
+ {c.addChallenge}
+ {c.addAssessment}
+
+
+
+
+ {c.level}
+
+ updateDraft(selectedLesson.id, {
+ level: value as LearnLesson["level"],
+ })
+ }
+ >
+
+
+
+
+ {c.levelValues.beginner}
+ {c.levelValues.practice}
+ {c.levelValues.challenge}
+
+
+
+
+ {c.completionRequirement}
+
+ updateDraft(selectedLesson.id, {
+ completionRequirement:
+ value as LessonCompletionRequirement,
+ })
+ }
+ >
+
+
+
+
+ {c.requirementValues.required}
+ {c.requirementValues.capstone}
+ {c.requirementValues.optional}
+ {c.requirementValues.bonus}
+
+
+
+
+
+ {c.minutes}
+
+ updateDraft(selectedLesson.id, {
+ minutes: Number(event.target.value || 0),
+ })
+ }
+ type="number"
+ />
+
+
+
+ {c.video}
+
+
+
+ updateDraft(selectedLesson.id, {
+ videoUrl: event.target.value,
+ })
+ }
+ placeholder="https://youtube.com/..."
+ />
+
+
+
+
+
+
+ {c.tags}
+
+ updateDraft(selectedLesson.id, { tags: event.target.value })
+ }
+ placeholder="beginner, variables"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{c.markdown}
+
{c.markdownHint}
+
+
+
+
+ updateLocalizedDraft(selectedLesson.id, "markdown", value)
+ }
+ language="markdown"
+ path={`lesson-${selectedLesson.id}-${contentLocale}-inline.md`}
+ theme="dark"
+ height="100%"
+ options={{
+ automaticLayout: true,
+ folding: true,
+ glyphMargin: false,
+ lineDecorationsWidth: 8,
+ lineNumbers: "on",
+ lineNumbersMinChars: 3,
+ minimap: { enabled: false },
+ padding: { top: 16, bottom: 18 },
+ quickSuggestions: false,
+ renderWhitespace: "selection",
+ scrollBeyondLastLine: false,
+ wordWrap: "on",
+ }}
+ />
+
+
+
+ {draftText(selectedDraft.markdown, contentLocale) || "—"}
+
+
+
{
+ setMarkdownStudioMode("split");
+ setMarkdownStudioLessonId(selectedLesson.id);
+ }}
+ >
+
+ {c.openStudio}
+
+
+
+ {c.transcript}
+
+
+ {c.code}
+
+
+ {c.sampleInput}
+
+ updateDraft(selectedLesson.id, {
+ sampleInput: event.target.value,
+ })
+ }
+ className="font-mono"
+ />
+
+
+
+
+
+
+
+
+ {c.quizConfig}
+
+ {selectedDraft.quiz.length} {c.quizItems.toLowerCase()}
+
+
+
+ {selectedDraft.quiz.map((question, questionIndex) => (
+
+
+ Q{questionIndex + 1}
+
+ updateQuizQuestion(questionIndex, {
+ answerIndex:
+ (question.answerIndex + 1) % question.options.length,
+ })
+ }
+ >
+
+
+
+
+ {c.question}
+
+
+ {question.options.map((option, optionIndex) => (
+
+
+ updateQuizQuestion(questionIndex, {
+ answerIndex: optionIndex,
+ })
+ }
+ className={cn(
+ "flex h-5 w-5 shrink-0 items-center justify-center rounded-full border text-[11px] font-semibold",
+ optionIndex === question.answerIndex
+ ? "border-emerald-500 bg-emerald-500 text-white"
+ : "bg-background text-muted-foreground"
+ )}
+ >
+ {optionIndex + 1}
+
+
+ updateQuizOption(
+ questionIndex,
+ optionIndex,
+ event.target.value
+ )
+ }
+ className="h-8 border-0 bg-transparent px-0 shadow-none focus-visible:ring-0"
+ />
+
+ ))}
+
+
+ ))}
+
+
+
+
+
+ {c.addQuestion}
+
+
+
+
+
+
+
+
+ {c.visualRules}
+
+
+
+ {c.unlockStatus}
+
+ updateDraft(selectedLesson.id, {
+ locked: value === "locked",
+ })
+ }
+ >
+
+
+
+
+ {c.locked}
+ {c.unlocked}
+
+
+
+
+ {c.requiredQuiz}
+
+ updateDraft(selectedLesson.id, {
+ requiresCorrectQuiz: value === "yes",
+ })
+ }
+ disabled={!selectedDraft.locked}
+ >
+
+
+
+
+ {c.locked}
+ {c.unlocked}
+
+
+
+
+ {c.requiredProblems}
+
+ updateDraft(selectedLesson.id, {
+ requiredProblemCodes: event.target.value,
+ })
+ }
+ disabled={!selectedDraft.locked}
+ placeholder="1, 6, 17"
+ />
+
+
+
+
+ {c.requiredQuiz}
+
+
+ {selectedRule.requiresCorrectQuiz ? c.locked : c.unlocked}
+
+
+
+
+
+ {c.requiredProblems}
+
+
+ {selectedRule.requiredProblemCodes.join(", ") || "0"}
+
+
+
+
+
+ )}
+
+
beginWidgetResize(event, "inspector", -1)}
+ >
+
+
+
+ )}
+
+
+
+ {c.newLesson}
+ addLesson("video")}>
+
+ {c.addVideo}
+
+ addLesson("theory")}>
+
+ {c.addTheory}
+
+ addLesson("challenge")}>
+
+ {c.addChallenge}
+
+ addLesson("assessment")}>
+
+ {c.addAssessment}
+
+ openCreateDialog("section")}>
+
+ {c.addSection}
+
+ openCreateDialog("category")}>
+
+ {c.addCategory}
+
+
+
+
+ {c.copy}
+
+
+
+ {c.paste}
+
+
+
+ {c.duplicate}
+
+ {selectedConnectionId && (
+
+
+ {c.reverseDirection}
+
+ )}
+
+
+ {selectedSectionFrameId
+ ? c.deleteSection
+ : categoryIsExplicitlySelected
+ ? c.deleteCategory
+ : c.delete}
+
+
+
+
+ {c.reset}
+
+
+
+
+ );
+}
+
+export default function AdminLessonsPage() {
+ return (
+
+
+
+ );
+}
diff --git a/app/admin/page.tsx b/app/admin/page.tsx
index 501cb58..a03d81a 100644
--- a/app/admin/page.tsx
+++ b/app/admin/page.tsx
@@ -1,115 +1,12 @@
"use client";
-
-import RouteGuard from "@/components/RouteGuard";
-import { useRouter } from "next/navigation";
-
-import { Card, CardContent } from "@/components/ui/card";
-import { Button } from "@/components/ui/button";
-
-import { Users, FileText, Sparkles, Mail } from "lucide-react";
+import { PlatformAccessDialog } from "@/components/admin/PlatformAccessDialog";
+import Link from "next/link";
+import { useAuth } from "@/hooks/useAuth";
import { useLanguage } from "@/components/LanguageProvider";
-
-function AdminContent() {
- const router = useRouter();
- const { t } = useLanguage();
-
- return (
-
-
-
{t("admin.title")}
-
-
-
-
-
-
-
-
{t("admin.problems.title")}
-
-
-
- {t("admin.problems.description")}
-
-
- router.push("/admin/problems")}>
- {t("admin.problems.action")}
-
-
-
-
-
-
-
-
-
{t("admin.users.title")}
-
-
-
- {t("admin.users.description")}
-
-
- router.push("/admin/users")}
- >
- {t("admin.users.action")}
-
-
-
-
-
-
-
-
-
{t("admin.updates.title")}
-
-
-
- {t("admin.updates.description")}
-
-
- router.push("/admin/updates")}
- >
- {t("admin.updates.action")}
-
-
-
-
-
-
-
-
-
{t("admin.contact.cardTitle")}
-
-
-
- {t("admin.contact.cardDescription")}
-
-
- router.push("/admin/contact")}
- >
- {t("admin.contact.cardAction")}
-
-
-
-
-
-
-
- );
-}
-
+import { Button } from "@/components/ui/button";
+import { AdminContent } from "@/components/admin/AdminOverviewContent";
export default function AdminPage() {
- return (
-
-
-
- );
+ const { isAdmin, can } = useAuth();
+ const { locale } = useLanguage();
+ return {(isAdmin || can("admin.platform")) &&
{can("admin.platform") &&
}{isAdmin &&
{locale === "ro" ? "Roluri și permisiuni" : "Roles & permissions"}}
}
;
}
diff --git a/app/admin/platform/page.tsx b/app/admin/platform/page.tsx
new file mode 100644
index 0000000..a720a27
--- /dev/null
+++ b/app/admin/platform/page.tsx
@@ -0,0 +1,7 @@
+"use client";
+import { PlatformAccessForm } from "@/components/admin/PlatformAccessDialog";
+import { useLanguage } from "@/components/LanguageProvider";
+export default function PlatformAccessPage() {
+ const { locale } = useLanguage();
+ return {locale === "ro" ? "Acces platformă" : "Platform access"} ;
+}
diff --git a/app/admin/problems/[id]/page.tsx b/app/admin/problems/[id]/page.tsx
index 35f7020..812032c 100644
--- a/app/admin/problems/[id]/page.tsx
+++ b/app/admin/problems/[id]/page.tsx
@@ -1,84 +1,110 @@
"use client";
-import { useEffect, useState } from "react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { ArrowLeft, ExternalLink, FileCode2 } from "lucide-react";
import Link from "next/link";
-import { supabase } from "@/lib/supabase";
import { useParams, useRouter } from "next/navigation";
-import RouteGuard from "@/components/RouteGuard";
+
import { ProblemForm } from "@/components/admin/ProblemForm";
-import { UserAvatar } from "@/components/user/UserAvatar";
-import { Skeleton } from "@/components/ui/skeleton";
+import { EmptyState } from "@/components/common/EmptyState";
+import { PageHeader } from "@/components/common/PageHeader";
import { useLanguage } from "@/components/LanguageProvider";
+import RouteGuard from "@/components/RouteGuard";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+import { getLocalized } from "@/lib/getLocalized";
+import { supabase } from "@/lib/supabase";
function EditProblemContent() {
const params = useParams();
const router = useRouter();
- const { t } = useLanguage();
-
- const id = params?.id;
-
- const [problem, setProblem] = useState(null);
- const [loading, setLoading] = useState(true);
-
- useEffect(() => {
- async function fetchProblem() {
- const { data } = await supabase
- .from("problems")
- .select("*, author:profiles!problems_author_id_fkey(id, username, avatar_url)")
- .eq("id", id)
- .single();
+ const { locale, t } = useLanguage();
+ const queryClient = useQueryClient();
+ const id = typeof params?.id === "string" ? params.id : "";
+ const problemQuery = useQuery({
+ queryKey: ["admin", "problems", id],
+ queryFn: async () => {
+ const { data, error } = await supabase.from("problems").select("*").eq("id", id).maybeSingle();
+ if (error) throw error;
+ return data;
+ },
+ enabled: Boolean(id),
+ staleTime: 2 * 60 * 1000,
+ });
- setProblem(data);
- setLoading(false);
- }
-
- if (id) fetchProblem();
- }, [id]);
-
- if (loading) {
+ if (problemQuery.isPending) {
return (
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
);
}
- if (!problem) {
- return {t("admin.problems.editPage.notFound")}
;
+ const problem = problemQuery.data;
+ if (!problem || problemQuery.isError) {
+ return (
+
+ }
+ title={t("admin.problems.editPage.notFound")}
+ action={(
+
+ {t("admin.problems.editPage.title")}
+
+ )}
+ />
+
+ );
}
+ const title = getLocalized(problem.title_i18n, locale);
+
return (
-
+
+
+ {t("admin.problems.manageTitle")}
+
-
-
- {t("admin.problems.editPage.title")}
-
- {problem.author?.username && (
-
- {t("admin.problems.editPage.publishedBy")}
-
-
- {problem.author.username}
+
+ {problem.code != null && #{problem.code} }
+ {problem.difficulty || "easy"}
+
+ )}
+ action={(
+
+
+
+ {locale === "ro" ? "Vezi pagina publică" : "View public page"}
-
+
)}
-
+ />
router.push("/admin/problems")}
+ onCancel={() => router.push("/admin/problems")}
+ onSuccess={() => {
+ void queryClient.invalidateQueries({ queryKey: ["admin", "problems"] });
+ void queryClient.invalidateQueries({ queryKey: ["admin", "problems", id] });
+ void queryClient.invalidateQueries({ queryKey: ["problems"] });
+ }}
/>
-
-
+
);
}
@@ -88,4 +114,4 @@ export default function EditProblemPage() {
);
-}
\ No newline at end of file
+}
diff --git a/app/admin/problems/chapters/page.tsx b/app/admin/problems/chapters/page.tsx
new file mode 100644
index 0000000..0cac95a
--- /dev/null
+++ b/app/admin/problems/chapters/page.tsx
@@ -0,0 +1,108 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { ArrowDown, ArrowUp, Plus, Save } from "lucide-react";
+import { toast } from "sonner";
+import RouteGuard from "@/components/RouteGuard";
+import { useLanguage } from "@/components/LanguageProvider";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
+import { Checkbox } from "@/components/ui/checkbox";
+import { getLocalized } from "@/lib/getLocalized";
+import { supabase } from "@/lib/supabase";
+import { fetchProblemChapters, validateProblemChapters, type ProblemChapter, type ChapterText } from "@/lib/problem-chapters";
+
+function ChaptersEditor() {
+ const { locale } = useLanguage();
+ const ro = locale === "ro";
+ const queryClient = useQueryClient();
+ const catalog = useQuery({ queryKey: ["problem-catalog"], queryFn: fetchProblemChapters });
+ const problems = useQuery({ queryKey: ["admin", "catalog-problems"], queryFn: async () => {
+ const { data, error } = await supabase.from("problems").select("id, code, title_i18n").order("code");
+ if (error) throw error;
+ return data as { id: string; code: number | null; title_i18n: Record