OpenMAIC-React / src /components /stage /scene-sidebar.tsx
muthuk1's picture
Rebrand: OpenMAIC → MultiMind Classroom — rename in all source, DB name, cookie name, zip extension, prompts, docs, skills
ed07c96 verified
import { useState, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
PanelLeftClose,
PieChart,
Cpu,
MousePointer2,
BookOpen,
Globe,
AlertCircle,
RefreshCw,
Trophy,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { ThumbnailSlide } from '@/components/slide-renderer/components/ThumbnailSlide';
import { ThumbnailInteractive } from '@/components/slide-renderer/components/ThumbnailInteractive';
import { useStageStore, useCanvasStore } from '@/lib/store';
import { useI18n } from '@/lib/hooks/use-i18n';
import type { SceneType, SlideContent, InteractiveContent } from '@/lib/types/stage';
import { PENDING_SCENE_ID } from '@/lib/store/stage';
interface SceneSidebarProps {
readonly collapsed: boolean;
readonly onCollapseChange: (collapsed: boolean) => void;
readonly onSceneSelect?: (sceneId: string) => void;
readonly onRetryOutline?: (outlineId: string) => Promise<void>;
readonly isCourseComplete?: boolean;
}
const DEFAULT_WIDTH = 220;
const MIN_WIDTH = 170;
const MAX_WIDTH = 400;
export function SceneSidebar({
collapsed,
onCollapseChange,
onSceneSelect,
onRetryOutline,
isCourseComplete,
}: SceneSidebarProps) {
const { t } = useI18n();
const router = useNavigate();
const { scenes, currentSceneId, setCurrentSceneId, generatingOutlines, generationStatus } =
useStageStore();
const failedOutlines = useStageStore.use.failedOutlines();
const viewportSize = useCanvasStore.use.viewportSize();
const viewportRatio = useCanvasStore.use.viewportRatio();
const [retryingOutlineId, setRetryingOutlineId] = useState<string | null>(null);
const handleRetryOutline = async (outlineId: string) => {
if (!onRetryOutline) return;
setRetryingOutlineId(outlineId);
try {
await onRetryOutline(outlineId);
} finally {
setRetryingOutlineId(null);
}
};
const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_WIDTH);
const isDraggingRef = useRef(false);
const handleDragStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
isDraggingRef.current = true;
const startX = e.clientX;
const startWidth = sidebarWidth;
const handleMouseMove = (me: MouseEvent) => {
const delta = me.clientX - startX;
const newWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + delta));
setSidebarWidth(newWidth);
};
const handleMouseUp = () => {
isDraggingRef.current = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
},
[sidebarWidth],
);
const getSceneTypeIcon = (type: SceneType) => {
const icons = {
slide: BookOpen,
quiz: PieChart,
interactive: MousePointer2,
pbl: Cpu,
};
return icons[type] || BookOpen;
};
const displayWidth = collapsed ? 0 : sidebarWidth;
return (
<div
style={{
width: displayWidth,
transition: isDraggingRef.current ? 'none' : 'width 0.3s ease',
}}
className="bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl border-r border-gray-100 dark:border-gray-800 shadow-[2px_0_24px_rgba(0,0,0,0.02)] flex flex-col shrink-0 z-20 relative overflow-visible"
>
{/* Drag handle */}
{!collapsed && (
<div
onMouseDown={handleDragStart}
className="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize z-50 group hover:bg-purple-400/30 dark:hover:bg-purple-600/30 active:bg-purple-500/40 dark:active:bg-purple-500/40 transition-colors"
>
<div className="absolute right-0.5 top-1/2 -translate-y-1/2 w-0.5 h-8 rounded-full bg-gray-300 dark:bg-gray-600 group-hover:bg-purple-400 dark:group-hover:bg-purple-500 transition-colors" />
</div>
)}
<div className={cn('flex flex-col w-full h-full overflow-hidden', collapsed && 'hidden')}>
{/* Logo Header */}
<div className="h-10 flex items-center justify-between shrink-0 relative mt-3 mb-1 px-3">
<button
onClick={() => router('/')}
className="flex items-center gap-2 cursor-pointer rounded-lg px-1.5 -mx-1.5 py-1 -my-1 hover:bg-gray-100/80 dark:hover:bg-gray-800/60 active:scale-[0.97] transition-all duration-150"
title={t('generation.backToHome')}
>
<img src="/logo-horizontal.png" alt="MultiMind Classroom" className="h-6" />
</button>
<button
onClick={() => onCollapseChange(true)}
className="w-7 h-7 shrink-0 rounded-lg flex items-center justify-center bg-gray-100/80 dark:bg-gray-800/80 text-gray-500 dark:text-gray-400 ring-1 ring-black/[0.04] dark:ring-white/[0.06] hover:bg-gray-200/90 dark:hover:bg-gray-700/90 hover:text-gray-700 dark:hover:text-gray-200 active:scale-90 transition-all duration-200"
>
<PanelLeftClose className="w-4 h-4" />
</button>
</div>
{/* Scenes List */}
<div
data-testid="scene-list"
className="flex-1 overflow-y-auto overflow-x-hidden p-2 space-y-2 scrollbar-hide pt-1"
>
{scenes.map((scene, index) => {
const isActive = currentSceneId === scene.id;
const Icon = getSceneTypeIcon(scene.type);
const isSlide = scene.type === 'slide';
const isInteractive = scene.type === 'interactive';
const slideContent = isSlide ? (scene.content as SlideContent) : null;
const interactiveContent = isInteractive ? (scene.content as InteractiveContent) : null;
return (
<div
key={scene.id}
data-testid="scene-item"
onClick={() => {
if (onSceneSelect) {
onSceneSelect(scene.id);
} else {
setCurrentSceneId(scene.id);
}
}}
className={cn(
'group relative rounded-lg transition-all duration-200 cursor-pointer flex flex-col gap-1 p-1.5',
isActive
? 'bg-purple-50 dark:bg-purple-900/20 ring-1 ring-purple-200 dark:ring-purple-700'
: 'hover:bg-gray-50/80 dark:hover:bg-gray-800/50',
)}
>
{/* Scene Header */}
<div className="flex justify-between items-center px-2 pt-0.5">
<div className="flex items-center gap-2 max-w-full">
<span
className={cn(
'text-[10px] font-black w-4 h-4 rounded-full flex items-center justify-center shrink-0',
isActive
? 'bg-purple-600 dark:bg-purple-500 text-white shadow-sm shadow-purple-500/30'
: 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400',
)}
>
{index + 1}
</span>
<span
data-testid="scene-title"
className={cn(
'text-xs font-bold truncate transition-colors',
isActive
? 'text-purple-700 dark:text-purple-300'
: 'text-gray-600 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100',
)}
>
{scene.title}
</span>
</div>
</div>
{/* Thumbnail */}
<div className="relative aspect-video w-full rounded overflow-hidden bg-gray-100 dark:bg-gray-800 ring-1 ring-black/5 dark:ring-white/5">
<div className="absolute inset-0 flex items-center justify-center">
{isSlide && slideContent ? (
<ThumbnailSlide
slide={slideContent.canvas}
viewportSize={viewportSize}
viewportRatio={viewportRatio}
size={Math.max(100, sidebarWidth - 28)}
/>
) : scene.type === 'quiz' ? (
/* Quiz: question bar + 2x2 option grid */
<div className="w-full h-full bg-gradient-to-br from-orange-50 to-amber-50 dark:from-orange-950/30 dark:to-amber-950/20 p-2 flex flex-col">
<div className="h-1.5 w-4/5 bg-orange-200/70 dark:bg-orange-700/30 rounded-full mb-1.5" />
<div className="flex-1 grid grid-cols-2 gap-1">
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className={cn(
'rounded flex items-center gap-1 px-1',
i === 1
? 'bg-orange-400/20 dark:bg-orange-500/20 border border-orange-300/50 dark:border-orange-600/30'
: 'bg-white/60 dark:bg-white/5 border border-orange-100/60 dark:border-orange-800/20',
)}
>
<div
className={cn(
'w-1.5 h-1.5 rounded-full shrink-0',
i === 1
? 'bg-orange-400 dark:bg-orange-500'
: 'bg-orange-200 dark:bg-orange-700/50',
)}
/>
<div
className={cn(
'h-1 rounded-full flex-1',
i === 1
? 'bg-orange-300/60 dark:bg-orange-600/40'
: 'bg-orange-100/80 dark:bg-orange-800/30',
)}
/>
</div>
))}
</div>
</div>
) : scene.type === 'interactive' && interactiveContent?.html ? (
/* Interactive: live iframe preview */
<ThumbnailInteractive
content={interactiveContent}
size={Math.max(100, sidebarWidth - 28)}
/>
) : scene.type === 'interactive' ? (
/* Interactive: browser window with chrome + content */
<div className="w-full h-full bg-gradient-to-br from-emerald-50 to-teal-50 dark:from-emerald-950/30 dark:to-teal-950/20 p-1.5 flex flex-col">
<div className="flex items-center gap-1 mb-1 pb-1 border-b border-emerald-200/40 dark:border-emerald-700/20">
<div className="flex gap-0.5">
<div className="w-1 h-1 rounded-full bg-red-300 dark:bg-red-500/60" />
<div className="w-1 h-1 rounded-full bg-amber-300 dark:bg-amber-500/60" />
<div className="w-1 h-1 rounded-full bg-green-300 dark:bg-green-500/60" />
</div>
<div className="h-1.5 flex-1 bg-emerald-200/40 dark:bg-emerald-700/30 rounded-full ml-0.5" />
</div>
<div className="flex-1 flex gap-1">
<div className="w-1/4 space-y-1 pt-0.5">
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-0.5 w-full bg-emerald-200/60 dark:bg-emerald-700/30 rounded-full"
/>
))}
</div>
<div className="flex-1 bg-emerald-100/40 dark:bg-emerald-800/20 rounded flex items-center justify-center border border-emerald-200/40 dark:border-emerald-700/20">
<Globe className="w-4 h-4 text-emerald-300/80 dark:text-emerald-600/50" />
</div>
</div>
</div>
) : scene.type === 'pbl' ? (
/* PBL: kanban board with 3 columns */
<div className="w-full h-full bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-950/30 dark:to-indigo-950/20 p-1.5 flex flex-col">
<div className="flex items-center gap-1 mb-1.5">
<div className="w-1.5 h-1.5 rounded bg-blue-300 dark:bg-blue-600" />
<div className="h-1 w-8 bg-blue-200/60 dark:bg-blue-700/30 rounded-full" />
</div>
<div className="flex-1 flex gap-1 overflow-hidden">
{[0, 1, 2].map((col) => (
<div
key={col}
className="flex-1 bg-white/50 dark:bg-white/5 rounded p-0.5 flex flex-col gap-0.5"
>
<div
className={cn(
'h-0.5 w-3 rounded-full mb-0.5',
col === 0
? 'bg-blue-300/70'
: col === 1
? 'bg-amber-300/70'
: 'bg-green-300/70',
)}
/>
{Array.from({
length: col === 0 ? 3 : col === 1 ? 2 : 1,
}).map((_, i) => (
<div
key={i}
className="h-2 w-full bg-blue-100/60 dark:bg-blue-800/20 rounded border border-blue-200/30 dark:border-blue-700/20"
/>
))}
</div>
))}
</div>
</div>
) : (
/* Fallback */
<div className="w-full h-full flex flex-col items-center justify-center gap-1 bg-gray-50 dark:bg-gray-800 text-gray-300 dark:text-gray-500">
<Icon className="w-4 h-4" />
<span className="text-[9px] font-bold uppercase tracking-wider opacity-80">
{scene.type}
</span>
</div>
)}
{isSlide && (
<div
className={cn(
'absolute inset-0 bg-purple-500/0 transition-colors',
isActive
? 'bg-purple-500/0'
: 'group-hover:bg-black/5 dark:group-hover:bg-white/5',
)}
/>
)}
</div>
</div>
</div>
);
})}
{/* Single placeholder for the next generating page (clickable) */}
{generatingOutlines.length > 0 &&
(() => {
const outline = generatingOutlines[0];
const isFailed = failedOutlines.some((f) => f.id === outline.id);
const isRetrying = retryingOutlineId === outline.id;
const isPaused = generationStatus === 'paused';
const isActive = currentSceneId === PENDING_SCENE_ID;
return (
<div
key={`generating-${outline.id}`}
onClick={() => {
if (isFailed) return;
if (onSceneSelect) {
onSceneSelect(PENDING_SCENE_ID);
} else {
setCurrentSceneId(PENDING_SCENE_ID);
}
}}
className={cn(
'group relative rounded-lg flex flex-col gap-1 p-1.5 transition-all duration-200',
isFailed
? 'opacity-100 cursor-default'
: 'cursor-pointer hover:bg-gray-50/80 dark:hover:bg-gray-800/50',
!isFailed && !isActive && 'opacity-60',
isActive &&
!isFailed &&
'bg-purple-50 dark:bg-purple-900/20 ring-1 ring-purple-200 dark:ring-purple-700 opacity-100',
)}
>
{/* Scene Header */}
<div className="flex justify-between items-center px-2 pt-0.5">
<div className="flex items-center gap-2 max-w-full">
<span
className={cn(
'text-[10px] font-black w-4 h-4 rounded-full flex items-center justify-center shrink-0',
isActive && !isFailed
? 'bg-purple-600 dark:bg-purple-500 text-white shadow-sm shadow-purple-500/30'
: 'bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500',
)}
>
{scenes.length + 1}
</span>
<span
className={cn(
'text-xs font-bold truncate transition-colors',
isActive && !isFailed
? 'text-purple-700 dark:text-purple-300'
: isFailed
? 'text-gray-700 dark:text-gray-200'
: 'text-gray-400 dark:text-gray-500',
)}
>
{outline.title}
</span>
</div>
</div>
{/* Skeleton Thumbnail */}
<div
className={cn(
'relative aspect-video w-full rounded overflow-hidden ring-1',
isFailed
? 'bg-red-50/30 dark:bg-red-950/10 ring-red-100 dark:ring-red-900/20'
: 'bg-gray-100 dark:bg-gray-800 ring-black/5 dark:ring-white/5',
)}
>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1.5">
{isFailed ? (
<div className="flex items-center gap-1 text-xs font-medium text-red-500/90 dark:text-red-400">
{onRetryOutline ? (
<button
onClick={(e) => {
e.stopPropagation();
handleRetryOutline(outline.id);
}}
disabled={isRetrying}
className="p-1 -ml-1 rounded-md hover:bg-red-100 dark:hover:bg-red-900/40 transition-colors active:scale-95 disabled:opacity-50 disabled:active:scale-100"
title={t('generation.retryScene')}
>
<RefreshCw
className={cn('w-3.5 h-3.5', isRetrying && 'animate-spin')}
/>
</button>
) : (
<AlertCircle className="w-3.5 h-3.5" />
)}
<span>
{isRetrying
? t('generation.retryingScene')
: t('stage.generationFailed')}
</span>
</div>
) : (
<>
<div
className={cn(
'h-2 w-3/5 bg-gray-200 dark:bg-gray-700 rounded',
!isPaused && 'animate-pulse',
)}
/>
<div
className={cn(
'h-1.5 w-2/5 bg-gray-200 dark:bg-gray-700 rounded',
!isPaused && 'animate-pulse',
)}
/>
<span className="text-[9px] font-medium text-gray-400 dark:text-gray-500 mt-0.5">
{isPaused ? t('stage.paused') : t('stage.generating')}
</span>
</>
)}
</div>
{!isFailed && !isPaused && (
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/40 dark:via-white/10 to-transparent" />
)}
</div>
</div>
);
})()}
{/* Course-complete placeholder (shown when outline is exhausted) */}
{isCourseComplete &&
generatingOutlines.length === 0 &&
(() => {
const isActive = currentSceneId === PENDING_SCENE_ID;
return (
<div
key="course-complete-slot"
onClick={() => {
if (onSceneSelect) {
onSceneSelect(PENDING_SCENE_ID);
} else {
setCurrentSceneId(PENDING_SCENE_ID);
}
}}
className={cn(
'group relative rounded-lg flex flex-col gap-1 p-1.5 transition-all duration-200 cursor-pointer hover:bg-amber-50/60 dark:hover:bg-amber-900/10',
!isActive && 'opacity-80',
isActive &&
'bg-amber-50 dark:bg-amber-900/20 ring-1 ring-amber-200 dark:ring-amber-700 opacity-100',
)}
>
<div className="flex justify-between items-center px-2 pt-0.5">
<div className="flex items-center gap-2 max-w-full">
<span
className={cn(
'text-[10px] font-black w-4 h-4 rounded-full flex items-center justify-center shrink-0',
isActive
? 'bg-amber-500 dark:bg-amber-400 text-white shadow-sm shadow-amber-500/30'
: 'bg-amber-100 dark:bg-amber-900/40 text-amber-600 dark:text-amber-400',
)}
>
{scenes.length + 1}
</span>
<span
className={cn(
'text-xs font-bold truncate transition-colors',
isActive
? 'text-amber-700 dark:text-amber-300'
: 'text-amber-600 dark:text-amber-400',
)}
>
{t('stage.courseComplete')}
</span>
</div>
</div>
<div
className={cn(
'relative aspect-video w-full rounded overflow-hidden ring-1 flex items-center justify-center transition-all',
'bg-amber-50/80 dark:bg-amber-950/20',
isActive
? 'ring-amber-300 dark:ring-amber-700'
: 'ring-amber-100 dark:ring-amber-900/40',
)}
>
{/* soft radial glow */}
<div
className="absolute inset-0"
style={{
background:
'radial-gradient(circle at 50% 55%, rgba(251, 191, 36, 0.14), transparent 65%)',
}}
/>
{/* sparkles (subtle) */}
<svg
viewBox="0 0 20 20"
className="absolute top-1 right-1.5 w-1.5 h-1.5 text-amber-300/70 dark:text-amber-400/60"
aria-hidden
>
<path
d="M10 1 L12 8 L19 10 L12 12 L10 19 L8 12 L1 10 L8 8 Z"
fill="currentColor"
/>
</svg>
<svg
viewBox="0 0 20 20"
className="absolute bottom-1 left-1.5 w-1 h-1 text-amber-300/60 dark:text-amber-400/50"
aria-hidden
>
<path
d="M10 1 L12 8 L19 10 L12 12 L10 19 L8 12 L1 10 L8 8 Z"
fill="currentColor"
/>
</svg>
<Trophy
className="relative w-8 h-8 text-amber-500 dark:text-amber-400"
strokeWidth={1.6}
/>
</div>
</div>
);
})()}
</div>
{/* Spacer to push toggle button area */}
<div className="mt-auto" />
</div>
</div>
);
}