refactor(core): switch to scene-based architecture

This commit is contained in:
Maze Winther
2025-09-01 16:17:06 +02:00
parent 2b700b7474
commit 350b55081c
15 changed files with 793 additions and 164 deletions
@@ -0,0 +1,64 @@
"use client";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { useSceneStore } from "@/stores/scene-store";
import { Check } from "lucide-react";
export function ScenesView({ children }: { children: React.ReactNode }) {
const { scenes, currentScene, switchToScene } = useSceneStore();
const handleSceneSwitch = async (sceneId: string) => {
try {
await switchToScene({ sceneId });
} catch (error) {
console.error("Failed to switch scene:", error);
}
};
return (
<Sheet>
<SheetTrigger asChild>{children}</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Scenes</SheetTitle>
<SheetDescription>
Switch between scenes in your project
</SheetDescription>
</SheetHeader>
<div className="py-4">
{scenes.length === 0 ? (
<div className="text-sm text-muted-foreground">
No scenes available
</div>
) : (
<div className="space-y-2">
{scenes.map((scene) => (
<Button
key={scene.id}
variant={
currentScene?.id === scene.id ? "default" : "outline"
}
className="w-full justify-between"
onClick={() => handleSceneSwitch(scene.id)}
>
<span>{scene.name}</span>
{currentScene?.id === scene.id && (
<Check className="h-4 w-4" />
)}
</Button>
))}
</div>
)}
</div>
</SheetContent>
</Sheet>
);
}
@@ -1,6 +1,7 @@
import { usePlaybackStore } from "@/stores/playback-store";
import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { useSceneStore } from "@/stores/scene-store";
import { toast } from "sonner";
import {
TooltipProvider,
@@ -38,6 +39,7 @@ import { DEFAULT_FPS } from "@/stores/project-store";
import { formatTimeCode } from "@/lib/time";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { EditableTimecode } from "@/components/ui/editable-timecode";
import { ScenesView } from "../scenes-view";
export function TimelineToolbar({
zoomLevel,
@@ -65,6 +67,7 @@ export function TimelineToolbar({
} = useTimelineStore();
const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
const { toggleBookmark, isBookmarked, activeProject } = useProjectStore();
const { currentScene } = useSceneStore();
const handleSplitSelected = () => {
if (selectedElements.length === 0) return;
@@ -357,11 +360,13 @@ export function TimelineToolbar({
</div>
<div>
<SplitButton>
<SplitButtonLeft>Main scene</SplitButtonLeft>
<SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft>
<SplitButtonSeparator />
<SplitButtonRight onClick={() => {}}>
<LayersIcon />
</SplitButtonRight>
<ScenesView>
<SplitButtonRight onClick={() => {}}>
<LayersIcon />
</SplitButtonRight>
</ScenesView>
</SplitButton>
</div>
<div className="flex items-center gap-1">
-17
View File
@@ -2,28 +2,11 @@
import { motion } from "motion/react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { RiDiscordFill, RiTwitterXLine } from "react-icons/ri";
import { FaGithub } from "react-icons/fa6";
import { getStars } from "@/lib/fetch-github-stars";
import Image from "next/image";
export function Footer() {
const [star, setStar] = useState<string>();
useEffect(() => {
const fetchStars = async () => {
try {
const data = await getStars();
setStar(data);
} catch (err) {
console.error("Failed to fetch GitHub stars", err);
}
};
fetchStars();
}, []);
return (
<motion.footer
className="bg-background border-t"
@@ -0,0 +1,176 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { storageService } from "@/lib/storage/storage-service";
import { TProject, Scene } from "@/types/project";
import { generateUUID } from "@/lib/utils";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress";
interface MigrationProgress {
current: number;
total: number;
currentProjectName: string;
}
export function ScenesMigrator({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const [isMigrating, setIsMigrating] = useState(false);
const [progress, setProgress] = useState<MigrationProgress>({
current: 0,
total: 0,
currentProjectName: "",
});
const shouldCheckMigration =
pathname.startsWith("/editor") || pathname.startsWith("/projects");
useEffect(() => {
if (!shouldCheckMigration) return;
checkAndMigrateProjects();
}, [shouldCheckMigration]);
const checkAndMigrateProjects = async () => {
try {
const projects = await storageService.loadAllProjects();
const legacyProjects = projects.filter(
(project) => !project.scenes || project.scenes.length === 0
);
if (legacyProjects.length === 0) {
// No migration needed
return;
}
setIsMigrating(true);
setProgress({
current: 0,
total: legacyProjects.length,
currentProjectName: "",
});
// Migrate each legacy project
for (let i = 0; i < legacyProjects.length; i++) {
const project = legacyProjects[i];
setProgress({
current: i,
total: legacyProjects.length,
currentProjectName: project.name,
});
await migrateLegacyProject(project);
}
setProgress({
current: legacyProjects.length,
total: legacyProjects.length,
currentProjectName: "Complete!",
});
setTimeout(() => {
setIsMigrating(false);
}, 1000);
} catch (error) {
console.error("Migration failed:", error);
setIsMigrating(false);
}
};
const migrateLegacyProject = async (project: TProject) => {
try {
const mainScene: Scene = {
id: generateUUID(),
name: "Main Scene",
isMain: true,
createdAt: new Date(),
updatedAt: new Date(),
};
const migratedProject: TProject = {
...project,
scenes: [mainScene],
currentSceneId: mainScene.id,
updatedAt: new Date(),
};
// Load existing timeline data (legacy format)
const legacyTimeline = await storageService.loadTimeline({
projectId: project.id,
});
await storageService.saveProject({ project: migratedProject });
// If timeline data, migrate it to the main scene
if (legacyTimeline && legacyTimeline.length > 0) {
await storageService.saveTimeline({
projectId: project.id,
tracks: legacyTimeline,
sceneId: mainScene.id,
});
}
// Clean up legacy timeline storage
await storageService.deleteProjectTimeline({ projectId: project.id });
} catch (error) {
console.error(`Failed to migrate project ${project.name}:`, error);
throw error;
}
};
if (!shouldCheckMigration) {
return children;
}
if (isMigrating) {
const progressPercent =
progress.total > 0 ? (progress.current / progress.total) * 100 : 0;
return (
<Dialog open={true}>
<DialogContent
className="sm:max-w-md"
onPointerDownOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Updating Projects</DialogTitle>
<DialogDescription>
We're adding scene support to your projects. This will only take a
moment.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>Progress</span>
<span>
{progress.current} of {progress.total}
</span>
</div>
<Progress value={progressPercent} className="w-full" />
</div>
{progress.currentProjectName && (
<div className="text-sm text-muted-foreground">
{progress.current < progress.total
? `Updating: ${progress.currentProjectName}`
: progress.currentProjectName}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
return children;
}
+8 -4
View File
@@ -21,7 +21,7 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-200 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
@@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease data-[state=closed]:duration-250 data-[state=open]:duration-250 data-[state=open]:animate-in data-[state=closed]:animate-out",
"fixed z-250 gap-4 bg-background p-6 shadow-lg transition ease data-[state=closed]:duration-250 data-[state=open]:duration-250 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
@@ -62,10 +62,14 @@ const SheetContent = React.forwardRef<
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
onOpenAutoFocus={(e) => {
e.preventDefault();
e.stopPropagation();
}}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<SheetPrimitive.Close className="absolute cursor-pointer right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="size-5" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}