diff --git a/package-lock.json b/package-lock.json
index dc64872..e3548ef 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -38,6 +38,7 @@
"@radix-ui/react-tooltip": "^1.2.7",
"@supabase/supabase-js": "^2.88.0",
"@tanstack/react-query": "^5.83.0",
+ "@tiptap/extension-code-block-lowlight": "^3.14.0",
"@tiptap/extension-image": "^3.13.0",
"@tiptap/extension-link": "^3.13.0",
"@tiptap/extension-placeholder": "^3.13.0",
@@ -52,8 +53,10 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
+ "dompurify": "^3.3.1",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
+ "lowlight": "^3.3.0",
"lucide-react": "^0.462.0",
"next-themes": "^0.3.0",
"qrcode.react": "^4.2.0",
@@ -2959,17 +2962,34 @@
}
},
"node_modules/@tiptap/extension-code-block": {
- "version": "3.13.0",
- "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.13.0.tgz",
- "integrity": "sha512-kIwfQ4iqootsWg9e74iYJK54/YMIj6ahUxEltjZRML5z/h4gTDcQt2eTpnEC8yjDjHeUVOR94zH9auCySyk9CQ==",
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.14.0.tgz",
+ "integrity": "sha512-hRSdIhhm3Q9JBMQdKaifRVFnAa4sG+M7l1QcTKR3VSYVy2/oR0U+aiOifi5OvMRBUwhaR71Ro+cMT9FH9s26Kg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
- "@tiptap/core": "^3.13.0",
- "@tiptap/pm": "^3.13.0"
+ "@tiptap/core": "^3.14.0",
+ "@tiptap/pm": "^3.14.0"
+ }
+ },
+ "node_modules/@tiptap/extension-code-block-lowlight": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-3.14.0.tgz",
+ "integrity": "sha512-vkiDvPZUadrjAGNzvJYYXl5R+U1XmGALSbm+VlrGCR7iXHgYaMHdkqxHwGZMSqtsF2szPEqcAzLZShlAKl+AkA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.14.0",
+ "@tiptap/extension-code-block": "^3.14.0",
+ "@tiptap/pm": "^3.14.0",
+ "highlight.js": "^11",
+ "lowlight": "^2 || ^3"
}
},
"node_modules/@tiptap/extension-document": {
@@ -3471,6 +3491,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/hast": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
+ "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -3540,6 +3569,19 @@
"@types/react": "^18.0.0"
}
},
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/@types/unist": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
+ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
+ "license": "MIT"
+ },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@@ -4411,12 +4453,34 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
+ "node_modules/devlop": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
+ "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -4439,6 +4503,15 @@
"csstype": "^3.0.2"
}
},
+ "node_modules/dompurify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
+ "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -5033,6 +5106,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/highlight.js": {
+ "version": "11.11.1",
+ "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
+ "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
@@ -5790,6 +5872,21 @@
"@esbuild/win32-x64": "0.25.0"
}
},
+ "node_modules/lowlight": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
+ "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "devlop": "^1.0.0",
+ "highlight.js": "~11.11.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
diff --git a/package.json b/package.json
index 3ae6c54..8019f70 100644
--- a/package.json
+++ b/package.json
@@ -41,6 +41,7 @@
"@radix-ui/react-tooltip": "^1.2.7",
"@supabase/supabase-js": "^2.88.0",
"@tanstack/react-query": "^5.83.0",
+ "@tiptap/extension-code-block-lowlight": "^3.14.0",
"@tiptap/extension-image": "^3.13.0",
"@tiptap/extension-link": "^3.13.0",
"@tiptap/extension-placeholder": "^3.13.0",
@@ -55,8 +56,10 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
+ "dompurify": "^3.3.1",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
+ "lowlight": "^3.3.0",
"lucide-react": "^0.462.0",
"next-themes": "^0.3.0",
"qrcode.react": "^4.2.0",
diff --git a/src/App.tsx b/src/App.tsx
index 30da905..9101255 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -37,6 +37,7 @@ import AdminEvents from "./pages/admin/AdminEvents";
import AdminSettings from "./pages/admin/AdminSettings";
import AdminConsulting from "./pages/admin/AdminConsulting";
import AdminReviews from "./pages/admin/AdminReviews";
+import ProductCurriculum from "./pages/admin/ProductCurriculum";
const queryClient = new QueryClient();
@@ -73,6 +74,7 @@ const App = () => (
{/* Admin routes */}
} />
} />
+ } />
} />
} />
} />
diff --git a/src/components/RichTextEditor.tsx b/src/components/RichTextEditor.tsx
index 98f3c73..1a8710d 100644
--- a/src/components/RichTextEditor.tsx
+++ b/src/components/RichTextEditor.tsx
@@ -4,12 +4,13 @@ import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import Placeholder from '@tiptap/extension-placeholder';
import TextAlign from '@tiptap/extension-text-align';
+import CodeBlock from '@tiptap/extension-code-block';
import { Node } from '@tiptap/core';
import { Button } from '@/components/ui/button';
import {
Bold, Italic, List, ListOrdered, Quote, Link as LinkIcon,
Image as ImageIcon, Heading1, Heading2, Undo, Redo,
- Maximize2, Minimize2, MousePointer, Square, AlignLeft, AlignCenter, AlignRight, AlignJustify, MoreVertical, Minus
+ Maximize2, Minimize2, MousePointer, Square, AlignLeft, AlignCenter, AlignRight, AlignJustify, MoreVertical, Minus, Code, Copy, Check
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useCallback, useEffect, useState } from 'react';
@@ -18,6 +19,38 @@ import { toast } from '@/hooks/use-toast';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
+import { common, createLowlight } from 'lowlight';
+
+// Register common languages for syntax highlighting
+const lowlight = createLowlight(common);
+
+// Code Block Component with Copy Button
+const CodeBlockWithCopy = ({ node }: { node: any }) => {
+ const [copied, setCopied] = useState(false);
+ const code = node.textContent;
+
+ const handleCopy = async () => {
+ await navigator.clipboard.writeText(code);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ return (
+
+
+ {copied ? : }
+
+
+ {code}
+
+
+ );
+};
interface RichTextEditorProps {
content: string;
@@ -249,6 +282,20 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
levels: [1, 2, 3],
},
horizontalRule: true,
+ codeBlock: false, // Disable default code block to use custom one
+ }),
+ CodeBlock.configure({
+ lowlight,
+ defaultLanguage: 'text',
+ HTMLAttributes: {
+ class: 'code-block-wrapper',
+ },
+ }).extend({
+ addKeyboardShortcuts() {
+ return {
+ 'Mod-Shift-c': () => this.editor.commands.toggleCodeBlock(),
+ };
+ },
}),
TextAlign.configure({
types: ['heading', 'paragraph'],
@@ -516,6 +563,16 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
>
+ editor.chain().focus().toggleCodeBlock().run()}
+ className={editor.isActive('codeBlock') ? 'bg-primary text-primary-foreground' : ''}
+ title="Code Block (Ctrl+Shift+C)"
+ >
+
+
Release Date (optional)
diff --git a/src/index.css b/src/index.css
index 4f72df0..839e893 100644
--- a/src/index.css
+++ b/src/index.css
@@ -226,4 +226,160 @@ All colors MUST be HSL.
.prose pre code {
@apply bg-transparent p-0;
}
+
+ /* Code Blocks with Syntax Highlighting */
+ .ProseMirror {
+ /* Code block wrapper */
+ .code-block-wrapper {
+ @apply relative my-4;
+ }
+
+ /* Pre element styling */
+ pre {
+ @apply bg-slate-900 text-slate-50 rounded-lg p-4 overflow-x-auto;
+ font-family: 'Space Mono', ui-monospace, monospace;
+ font-size: 0.875rem;
+ line-height: 1.5;
+ }
+
+ /* Inline code styling */
+ code:not(pre code) {
+ @apply bg-muted px-1.5 py-0.5 rounded text-sm font-mono text-red-600;
+ }
+
+ /* Code inside pre blocks */
+ pre code {
+ @apply bg-transparent p-0 text-slate-50;
+ }
+ }
+
+ /* Line numbers for code blocks */
+ .ProseMirror pre.line-numbers {
+ counter-reset: line;
+ padding-left: 3.5em;
+ position: relative;
+ }
+
+ .ProseMirror pre.line-numbers .line {
+ counter-increment: line;
+ padding-left: 0.5em;
+ }
+
+ .ProseMirror pre.line-numbers .line::before {
+ content: counter(line);
+ display: inline-block;
+ width: 2.5em;
+ margin-right: 1em;
+ text-align: right;
+ color: #64748b;
+ position: absolute;
+ left: 0.5em;
+ }
+
+ /* Syntax highlighting colors (dark theme) */
+ .hljs {
+ color: #e2e8f0;
+ background: #0f172a;
+ }
+
+ .hljs-comment,
+ .hljs-quote {
+ color: #64748b;
+ font-style: italic;
+ }
+
+ .hljs-keyword,
+ .hljs-selector-tag,
+ .hljs-literal,
+ .hljs-type {
+ color: #c084fc;
+ font-weight: bold;
+ }
+
+ .hljs-string,
+ .hljs-title,
+ .hljs-name {
+ color: #86efac;
+ }
+
+ .hljs-number,
+ .hljs-symbol {
+ color: #fcd34d;
+ }
+
+ .hljs-attr,
+ .hljs-variable,
+ .hljs-template-variable {
+ color: #38bdf8;
+ }
+
+ .hljs-built_in,
+ .hljs-builtin-name {
+ color: #f472b6;
+ }
+
+ .hljs-function {
+ color: #60a5fa;
+ }
+
+ .hljs-class .hljs-title {
+ color: #fbbf24;
+ }
+
+ .hljs-tag {
+ color: #94a3b8;
+ }
+
+ .hljs-regexp {
+ color: #a78bfa;
+ }
+
+ .hljs-link {
+ color: #60a5fa;
+ text-decoration: underline;
+ }
+
+ .hljs-meta,
+ .hljs-selector-attr,
+ .hljs-selector-pseudo {
+ color: #818cf8;
+ }
+
+ .hljs-deletion {
+ background: #fecaca;
+ color: #991b1b;
+ }
+
+ .hljs-addition {
+ background: #bbf7d0;
+ color: #166534;
+ }
+
+ .hljs-emphasis {
+ font-style: italic;
+ }
+
+ .hljs-strong {
+ font-weight: bold;
+ }
+
+ /* Bootcamp content display styling */
+ .prose pre {
+ @apply bg-slate-900 text-slate-50 rounded-lg p-4 overflow-x-auto my-4;
+ font-family: 'Space Mono', ui-monospace, monospace;
+ font-size: 0.875rem;
+ line-height: 1.5;
+ }
+
+ .prose pre code {
+ @apply bg-transparent p-0 text-slate-50;
+ }
+
+ .prose code:not(pre code) {
+ @apply bg-red-50 text-red-600 px-1.5 py-0.5 rounded text-sm font-mono;
+ }
+
+ .prose img {
+ @apply rounded-lg my-4;
+ }
}
\ No newline at end of file
diff --git a/src/pages/Bootcamp.tsx b/src/pages/Bootcamp.tsx
index 1467035..a5549ec 100644
--- a/src/pages/Bootcamp.tsx
+++ b/src/pages/Bootcamp.tsx
@@ -12,11 +12,13 @@ import { ChevronLeft, ChevronRight, Check, Play, BookOpen, Clock, Menu, Star, Ch
import { cn } from '@/lib/utils';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { ReviewModal } from '@/components/reviews/ReviewModal';
+import DOMPurify from 'dompurify';
interface Product {
id: string;
title: string;
slug: string;
+ video_source?: string;
}
interface Module {
@@ -31,6 +33,8 @@ interface Lesson {
title: string;
content: string | null;
video_url: string | null;
+ youtube_url: string | null;
+ embed_code: string | null;
duration_seconds: number | null;
position: number;
release_at: string | null;
@@ -76,7 +80,7 @@ export default function Bootcamp() {
const checkAccessAndFetch = async () => {
const { data: productData, error: productError } = await supabase
.from('products')
- .select('id, title, slug')
+ .select('id, title, slug, video_source')
.eq('slug', slug)
.eq('type', 'bootcamp')
.maybeSingle();
@@ -113,6 +117,8 @@ export default function Bootcamp() {
title,
content,
video_url,
+ youtube_url,
+ embed_code,
duration_seconds,
position,
release_at
@@ -230,12 +236,93 @@ export default function Bootcamp() {
}
};
- const getVideoEmbed = (url: string) => {
- const youtubeMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/);
- if (youtubeMatch) return `https://www.youtube.com/embed/${youtubeMatch[1]}`;
- const vimeoMatch = url.match(/vimeo\.com\/(\d+)/);
- if (vimeoMatch) return `https://player.vimeo.com/video/${vimeoMatch[1]}`;
- return url;
+ const getYouTubeEmbedUrl = (url: string): string => {
+ const match = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/);
+ return match ? `https://www.youtube.com/embed/${match[1]}` : url;
+ };
+
+ const VideoPlayer = ({ lesson }: { lesson: Lesson }) => {
+ const activeSource = product?.video_source || 'youtube';
+
+ // Get video based on product's active source
+ const getVideoSource = () => {
+ if (activeSource === 'youtube') {
+ if (lesson.youtube_url) {
+ return {
+ type: 'youtube',
+ url: lesson.youtube_url,
+ embedUrl: getYouTubeEmbedUrl(lesson.youtube_url)
+ };
+ } else if (lesson.video_url) {
+ // Fallback to old video_url for backward compatibility
+ return {
+ type: 'youtube',
+ url: lesson.video_url,
+ embedUrl: getYouTubeEmbedUrl(lesson.video_url)
+ };
+ } else {
+ // Fallback to embed if YouTube not available
+ return lesson.embed_code ? {
+ type: 'embed',
+ html: lesson.embed_code
+ } : null;
+ }
+ } else {
+ if (lesson.embed_code) {
+ return {
+ type: 'embed',
+ html: lesson.embed_code
+ };
+ } else {
+ // Fallback to YouTube if embed not available
+ return lesson.youtube_url ? {
+ type: 'youtube',
+ url: lesson.youtube_url,
+ embedUrl: getYouTubeEmbedUrl(lesson.youtube_url)
+ } : lesson.video_url ? {
+ type: 'youtube',
+ url: lesson.video_url,
+ embedUrl: getYouTubeEmbedUrl(lesson.video_url)
+ } : null;
+ }
+ }
+ };
+
+ const video = getVideoSource();
+
+ // Show warning if no video available
+ if (!video) {
+ return (
+
+
+ Konten tidak tersedia
+ Video belum dikonfigurasi untuk pelajaran ini.
+
+
+ );
+ }
+
+ // Render based on video type
+ if (video.type === 'embed') {
+ return (
+
+ );
+ }
+
+ // YouTube or other URL-based videos
+ return (
+
+
+
+ );
};
const completedCount = progress.length;
@@ -273,7 +360,7 @@ export default function Bootcamp() {
>
{isCompleted ? (
- ) : lesson.video_url ? (
+ ) : lesson.video_url || lesson.youtube_url || lesson.embed_code ? (
) : (
@@ -382,23 +469,23 @@ export default function Bootcamp() {
)}
- {selectedLesson.video_url && (
-
-
-
- )}
+
{selectedLesson.content && (
diff --git a/src/pages/admin/AdminProducts.tsx b/src/pages/admin/AdminProducts.tsx
index b4bc71d..8e48a2a 100644
--- a/src/pages/admin/AdminProducts.tsx
+++ b/src/pages/admin/AdminProducts.tsx
@@ -15,7 +15,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { toast } from '@/hooks/use-toast';
import { Skeleton } from '@/components/ui/skeleton';
-import { Plus, Pencil, Trash2, Search, X } from 'lucide-react';
+import { Plus, Pencil, Trash2, Search, X, BookOpen } from 'lucide-react';
import { CurriculumEditor } from '@/components/admin/CurriculumEditor';
import { RichTextEditor } from '@/components/RichTextEditor';
import { formatIDR } from '@/lib/format';
@@ -315,6 +315,17 @@ export default function AdminProducts() {
+ {product.type === 'bootcamp' && (
+ navigate(`/admin/products/${product.id}/curriculum`)}
+ className="mr-1"
+ >
+
+ Curriculum
+
+ )}
handleEdit(product)}>
@@ -349,6 +360,15 @@ export default function AdminProducts() {
{product.type}
+ {product.type === 'bootcamp' && (
+
navigate(`/admin/products/${product.id}/curriculum`)}
+ >
+
+
+ )}
handleEdit(product)}>
diff --git a/src/pages/admin/ProductCurriculum.tsx b/src/pages/admin/ProductCurriculum.tsx
new file mode 100644
index 0000000..e30790a
--- /dev/null
+++ b/src/pages/admin/ProductCurriculum.tsx
@@ -0,0 +1,614 @@
+import { useEffect, useState } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { supabase } from '@/integrations/supabase/client';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { toast } from '@/hooks/use-toast';
+import { Plus, Pencil, Trash2, ChevronUp, ChevronDown, GripVertical, ArrowLeft, Save, Loader2 } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { RichTextEditor } from '@/components/RichTextEditor';
+import { AppLayout } from '@/components/AppLayout';
+
+interface Module {
+ id: string;
+ title: string;
+ position: number;
+}
+
+interface Lesson {
+ id: string;
+ module_id: string;
+ title: string;
+ content: string | null;
+ video_url: string | null;
+ youtube_url: string | null;
+ embed_code: string | null;
+ position: number;
+ release_at: string | null;
+}
+
+export default function ProductCurriculum() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+
+ const [product, setProduct] = useState
(null);
+ const [modules, setModules] = useState([]);
+ const [lessons, setLessons] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+
+ const [selectedModuleId, setSelectedModuleId] = useState(null);
+ const [selectedLessonId, setSelectedLessonId] = useState(null);
+ const [expandedModules, setExpandedModules] = useState>(new Set());
+
+ // Lesson editing state
+ const [editingLesson, setEditingLesson] = useState(null);
+ const [lessonForm, setLessonForm] = useState({
+ module_id: '',
+ title: '',
+ content: '',
+ video_url: '',
+ youtube_url: '',
+ embed_code: '',
+ release_at: '',
+ });
+
+ useEffect(() => {
+ if (id) {
+ fetchData();
+ }
+ }, [id]);
+
+ const fetchData = async () => {
+ if (!id) return;
+
+ const [productRes, modulesRes, lessonsRes] = await Promise.all([
+ supabase.from('products').select('id, title, slug').eq('id', id).single(),
+ supabase.from('bootcamp_modules').select('*').eq('product_id', id).order('position'),
+ supabase.from('bootcamp_lessons').select('*').order('position'),
+ ]);
+
+ if (productRes.data) {
+ setProduct(productRes.data);
+ }
+
+ if (modulesRes.data) {
+ setModules(modulesRes.data);
+ setExpandedModules(new Set(modulesRes.data.map(m => m.id)));
+ }
+
+ if (lessonsRes.data) {
+ setLessons(lessonsRes.data);
+ }
+
+ setLoading(false);
+ };
+
+ const getLessonsForModule = (moduleId: string) => {
+ return lessons.filter(l => l.module_id === moduleId).sort((a, b) => a.position - b.position);
+ };
+
+ const getSelectedModule = () => {
+ return modules.find(m => m.id === selectedModuleId) || null;
+ };
+
+ const getSelectedLesson = () => {
+ return lessons.find(l => l.id === selectedLessonId) || null;
+ };
+
+ // Module CRUD
+ const handleAddModule = async () => {
+ if (!id) return;
+
+ const title = prompt('Module title:');
+ if (!title?.trim()) return;
+
+ const maxPosition = modules.length > 0 ? Math.max(...modules.map(m => m.position)) : 0;
+
+ const { error } = await supabase
+ .from('bootcamp_modules')
+ .insert({ product_id: id, title, position: maxPosition + 1 });
+
+ if (error) {
+ toast({ title: 'Error', description: 'Failed to create module', variant: 'destructive' });
+ } else {
+ toast({ title: 'Success', description: 'Module created' });
+ fetchData();
+ }
+ };
+
+ const handleEditModule = async (module: Module) => {
+ const title = prompt('Module title:', module.title);
+ if (!title?.trim()) return;
+
+ const { error } = await supabase.from('bootcamp_modules').update({ title }).eq('id', module.id);
+
+ if (error) {
+ toast({ title: 'Error', description: 'Failed to update module', variant: 'destructive' });
+ } else {
+ toast({ title: 'Success', description: 'Module updated' });
+ fetchData();
+ }
+ };
+
+ const handleDeleteModule = async (moduleId: string) => {
+ if (!confirm('Delete this module and all its lessons?')) return;
+
+ const { error } = await supabase.from('bootcamp_modules').delete().eq('id', moduleId);
+
+ if (error) {
+ toast({ title: 'Error', description: 'Failed to delete module', variant: 'destructive' });
+ } else {
+ toast({ title: 'Success', description: 'Module deleted' });
+ if (selectedModuleId === moduleId) {
+ setSelectedModuleId(null);
+ setSelectedLessonId(null);
+ }
+ fetchData();
+ }
+ };
+
+ const moveModule = async (moduleId: string, direction: 'up' | 'down') => {
+ const index = modules.findIndex(m => m.id === moduleId);
+ if ((direction === 'up' && index === 0) || (direction === 'down' && index === modules.length - 1)) return;
+
+ const swapIndex = direction === 'up' ? index - 1 : index + 1;
+ const currentModule = modules[index];
+ const swapModule = modules[swapIndex];
+
+ await Promise.all([
+ supabase.from('bootcamp_modules').update({ position: swapModule.position }).eq('id', currentModule.id),
+ supabase.from('bootcamp_modules').update({ position: currentModule.position }).eq('id', swapModule.id),
+ ]);
+
+ fetchData();
+ };
+
+ // Lesson CRUD
+ const handleAddLesson = (moduleId: string) => {
+ setEditingLesson(null);
+ setLessonForm({
+ module_id: moduleId,
+ title: '',
+ content: '',
+ video_url: '',
+ youtube_url: '',
+ embed_code: '',
+ release_at: '',
+ });
+ setSelectedModuleId(moduleId);
+ setSelectedLessonId('new');
+ };
+
+ const handleEditLesson = (lesson: Lesson) => {
+ setEditingLesson(lesson);
+ setLessonForm({
+ module_id: lesson.module_id,
+ title: lesson.title,
+ content: lesson.content || '',
+ video_url: lesson.video_url || '',
+ youtube_url: lesson.youtube_url || '',
+ embed_code: lesson.embed_code || '',
+ release_at: lesson.release_at ? lesson.release_at.split('T')[0] : '',
+ });
+ setSelectedModuleId(lesson.module_id);
+ setSelectedLessonId(lesson.id);
+ };
+
+ const handleSaveLesson = async () => {
+ if (!lessonForm.title.trim()) {
+ toast({ title: 'Error', description: 'Lesson title is required', variant: 'destructive' });
+ return;
+ }
+
+ setSaving(true);
+
+ const lessonData = {
+ module_id: lessonForm.module_id,
+ title: lessonForm.title,
+ content: lessonForm.content || null,
+ video_url: lessonForm.video_url || null,
+ youtube_url: lessonForm.youtube_url || null,
+ embed_code: lessonForm.embed_code || null,
+ release_at: lessonForm.release_at ? new Date(lessonForm.release_at).toISOString() : null,
+ };
+
+ if (editingLesson) {
+ const { error } = await supabase.from('bootcamp_lessons').update(lessonData).eq('id', editingLesson.id);
+
+ if (error) {
+ toast({ title: 'Error', description: 'Failed to update lesson', variant: 'destructive' });
+ } else {
+ toast({ title: 'Success', description: 'Lesson updated' });
+ fetchData();
+ }
+ } else {
+ const moduleLessons = getLessonsForModule(lessonForm.module_id);
+ const maxPosition = moduleLessons.length > 0 ? Math.max(...moduleLessons.map(l => l.position)) : 0;
+
+ const { error } = await supabase.from('bootcamp_lessons').insert({ ...lessonData, position: maxPosition + 1 });
+
+ if (error) {
+ toast({ title: 'Error', description: 'Failed to create lesson', variant: 'destructive' });
+ } else {
+ toast({ title: 'Success', description: 'Lesson created' });
+ fetchData();
+ }
+ }
+
+ setSaving(false);
+ setSelectedLessonId(null);
+ };
+
+ const handleDeleteLesson = async (lessonId: string) => {
+ if (!confirm('Delete this lesson?')) return;
+
+ const { error } = await supabase.from('bootcamp_lessons').delete().eq('id', lessonId);
+
+ if (error) {
+ toast({ title: 'Error', description: 'Failed to delete lesson', variant: 'destructive' });
+ } else {
+ toast({ title: 'Success', description: 'Lesson deleted' });
+ if (selectedLessonId === lessonId) {
+ setSelectedLessonId(null);
+ }
+ fetchData();
+ }
+ };
+
+ const moveLesson = async (lessonId: string, direction: 'up' | 'down') => {
+ const lesson = lessons.find(l => l.id === lessonId);
+ if (!lesson) return;
+
+ const moduleLessons = getLessonsForModule(lesson.module_id);
+ const index = moduleLessons.findIndex(l => l.id === lessonId);
+
+ if ((direction === 'up' && index === 0) || (direction === 'down' && index === moduleLessons.length - 1)) return;
+
+ const swapIndex = direction === 'up' ? index - 1 : index + 1;
+ const swapLesson = moduleLessons[swapIndex];
+
+ await Promise.all([
+ supabase.from('bootcamp_lessons').update({ position: swapLesson.position }).eq('id', lesson.id),
+ supabase.from('bootcamp_lessons').update({ position: lesson.position }).eq('id', swapLesson.id),
+ ]);
+
+ fetchData();
+ };
+
+ const toggleModule = (moduleId: string) => {
+ const newExpanded = new Set(expandedModules);
+ if (newExpanded.has(moduleId)) {
+ newExpanded.delete(moduleId);
+ } else {
+ newExpanded.add(moduleId);
+ }
+ setExpandedModules(newExpanded);
+ };
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Header with breadcrumb */}
+
+
navigate('/admin/products')}>
+
+ Back to Products
+
+
+
{product?.title}
+
Curriculum Management
+
+
+
+
+ {/* Left: Modules List (3 columns) */}
+
+
+
+ Modules
+
+
+ Add Module
+
+
+
+ {modules.map((module, index) => {
+ const moduleLessons = getLessonsForModule(module.id);
+ const isSelected = selectedModuleId === module.id;
+
+ return (
+
+
+
{
+ setSelectedModuleId(module.id);
+ if (expandedModules.has(module.id)) {
+ toggleModule(module.id);
+ } else {
+ const newExpanded = new Set(expandedModules);
+ newExpanded.add(module.id);
+ setExpandedModules(newExpanded);
+ }
+ }}
+ >
+
+ {module.title}
+
+
+
moveModule(module.id, 'up')}
+ disabled={index === 0}
+ >
+
+
+
moveModule(module.id, 'down')}
+ disabled={index === modules.length - 1}
+ >
+
+
+
handleEditModule(module)}
+ >
+
+
+
handleDeleteModule(module.id)}
+ >
+
+
+
+
+
+ {moduleLessons.length} lesson{moduleLessons.length !== 1 ? 's' : ''}
+
+
+ );
+ })}
+ {modules.length === 0 && (
+
+ No modules yet
+
+ )}
+
+
+
+
+ {/* Middle: Lessons List (5 columns) */}
+
+
+
+ Lessons
+ {selectedModuleId && (
+ handleAddLesson(selectedModuleId)} className="w-full">
+
+ Add Lesson
+
+ )}
+
+
+ {!selectedModuleId ? (
+
+ Select a module to view lessons
+
+ ) : (
+
+ {getLessonsForModule(selectedModuleId).map((lesson, index) => {
+ const isSelected = selectedLessonId === lesson.id;
+
+ return (
+
+
+
handleEditLesson(lesson)}
+ >
+
{lesson.title}
+
+
+ {index + 1}. {lesson.video_url || lesson.youtube_url || lesson.embed_code ? '✓ Video' : 'No video'}
+
+ {lesson.youtube_url && (
+ YouTube
+ )}
+ {lesson.embed_code && (
+ Embed
+ )}
+ {lesson.content && (
+ ✓ Content
+ )}
+
+
+
+ moveLesson(lesson.id, 'up')}
+ disabled={index === 0}
+ >
+
+
+ moveLesson(lesson.id, 'down')}
+ disabled={index === getLessonsForModule(selectedModuleId).length - 1}
+ >
+
+
+ handleDeleteLesson(lesson.id)}
+ >
+
+
+
+
+
+ );
+ })}
+ {getLessonsForModule(selectedModuleId).length === 0 && (
+
+ No lessons yet
+
+ )}
+
+ )}
+
+
+
+
+ {/* Right: Lesson Editor (4 columns) */}
+
+
+
+ Lesson Editor
+
+
+ {!selectedLessonId ? (
+
+ Select a lesson to edit
+
+ ) : (
+
+
+ Title *
+ setLessonForm({ ...lessonForm, title: e.target.value })}
+ placeholder="Lesson title"
+ className="border-2"
+ />
+
+
+
+
YouTube URL (Primary)
+
setLessonForm({ ...lessonForm, youtube_url: e.target.value })}
+ placeholder="https://www.youtube.com/watch?v=..."
+ className="border-2"
+ />
+ {lessonForm.youtube_url && (
+
✓ YouTube configured
+ )}
+
+
+
+
+
+
+ 💡 Tip: Configure both YouTube URL and embed code for redundancy. Use product settings to toggle between sources.
+
+
+
+
+
Content
+
setLessonForm({ ...lessonForm, content: html })}
+ placeholder="Write your lesson content here... Use code blocks for syntax highlighting."
+ className="min-h-[300px]"
+ />
+
+ Supports rich text formatting, code blocks with syntax highlighting, images, and more.
+
+
+
+
+ Release Date (optional)
+ setLessonForm({ ...lessonForm, release_at: e.target.value })}
+ className="border-2"
+ />
+
+
+
+
+ {saving ? (
+ <>
+
+ Saving...
+ >
+ ) : (
+ <>
+
+ Save Lesson
+ >
+ )}
+
+ setSelectedLessonId(null)}
+ disabled={saving}
+ className="border-2"
+ >
+ Cancel
+
+
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/supabase/migrations/20241230_video_source_columns.sql b/supabase/migrations/20241230_video_source_columns.sql
new file mode 100644
index 0000000..b0feff0
--- /dev/null
+++ b/supabase/migrations/20241230_video_source_columns.sql
@@ -0,0 +1,41 @@
+-- ============================================
+-- Add Video Source Columns for YouTube/Embed Toggle
+-- ============================================
+-- This migration adds support for dual video sources (YouTube and Embed)
+-- at the product level, allowing quick switching between sources
+
+-- Add video source columns to bootcamp_lessons table
+ALTER TABLE bootcamp_lessons
+ ADD COLUMN IF NOT EXISTS youtube_url TEXT,
+ ADD COLUMN IF NOT EXISTS embed_code TEXT;
+
+-- Migrate existing video_url to youtube_url
+UPDATE bootcamp_lessons
+SET youtube_url = video_url
+WHERE video_url IS NOT NULL
+ AND youtube_url IS NULL;
+
+-- Note: Keep old video_url column for backward compatibility
+-- Can drop after verification if desired
+
+-- Add video_source column to products table
+ALTER TABLE products
+ ADD COLUMN IF NOT EXISTS video_source TEXT DEFAULT 'youtube',
+ ADD COLUMN IF NOT EXISTS video_source_config JSONB DEFAULT '{}';
+
+-- Add constraint to ensure valid sources
+ALTER TABLE products
+ DROP CONSTRAINT IF EXISTS products_video_source_check;
+
+ALTER TABLE products
+ ADD CONSTRAINT products_video_source_check
+ CHECK (video_source IN ('youtube', 'embed'));
+
+-- Create index for faster queries
+CREATE INDEX IF NOT EXISTS idx_products_video_source ON products(video_source);
+
+-- Add comments for documentation
+COMMENT ON COLUMN products.video_source IS 'Active video source for bootcamp lessons: youtube or embed';
+COMMENT ON COLUMN products.video_source_config IS 'Configuration metadata for video source settings';
+COMMENT ON COLUMN bootcamp_lessons.youtube_url IS 'YouTube video URL for the lesson';
+COMMENT ON COLUMN bootcamp_lessons.embed_code IS 'Custom embed code (Adilo, Vimeo, iframe) for the lesson';