Merge remote-tracking branch 'upstream/main' into feature/desktop-ready-offline

This commit is contained in:
Kesku 2025-06-23 15:49:13 +01:00
commit 344234b368
7 changed files with 841 additions and 158 deletions

View File

@ -0,0 +1,8 @@
CREATE TABLE "waitlist" (
"id" text PRIMARY KEY NOT NULL,
"email" text NOT NULL,
"created_at" timestamp NOT NULL,
CONSTRAINT "waitlist_email_unique" UNIQUE("email")
);
--> statement-breakpoint
ALTER TABLE "waitlist" ENABLE ROW LEVEL SECURITY;

View File

@ -0,0 +1,358 @@
{
"id": "b7d920ca-6dd0-430f-8ee6-1d38fdf3e80f",
"prevId": "4440fb90-cf0e-4cb2-afad-08250ce3dc1e",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.accounts": {
"name": "accounts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"accounts_user_id_users_id_fk": {
"name": "accounts_user_id_users_id_fk",
"tableFrom": "accounts",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"sessions_token_unique": {
"name": "sessions_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.verifications": {
"name": "verifications",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.waitlist": {
"name": "waitlist",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"waitlist_email_unique": {
"name": "waitlist_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1750581188229,
"tag": "0000_hot_the_fallen",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1750689835736,
"tag": "0001_tricky_jackpot",
"breakpoints": true
}
]
}

View File

@ -4,3 +4,9 @@
[[plugins]]
package = "@netlify/plugin-nextjs"
[[redirects]]
from = "https://appcut.app/*"
to = "https://opencut.app/:splat"
status = 301
force = true

View File

@ -7,7 +7,7 @@ import {
ResizableHandle,
} from "../../components/ui/resizable";
import { MediaPanel } from "../../components/editor/media-panel";
import { PropertiesPanel } from "../../components/editor/properties-panel";
// import { PropertiesPanel } from "../../components/editor/properties-panel";
import { Timeline } from "../../components/editor/timeline";
import { PreviewPanel } from "../../components/editor/preview-panel";
import { EditorHeader } from "@/components/editor-header";
@ -74,13 +74,13 @@ export default function Editor() {
<ResizableHandle withHandle />
{/* Properties Panel */}
<ResizablePanel
{/* <ResizablePanel
defaultSize={propertiesPanel}
minSize={15}
onResize={setPropertiesPanel}
>
<PropertiesPanel />
</ResizablePanel>
</ResizablePanel> */}
</ResizablePanelGroup>
</ResizablePanel>

View File

@ -3,110 +3,397 @@
import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
import { VideoPlayer } from "@/components/ui/video-player";
import { Button } from "@/components/ui/button";
import { Play, Pause } from "lucide-react";
import { Play, Pause, Move, RotateCw, Crop, ZoomIn, ZoomOut } from "lucide-react";
import { useState, useRef, useEffect, useCallback } from "react";
interface ClipTransform {
x: number;
y: number;
scale: number;
rotation: number;
opacity: number;
width: number;
height: number;
blendMode: string;
cropTop: number;
cropBottom: number;
cropLeft: number;
cropRight: number;
}
interface DragState {
isDragging: boolean;
dragType: 'move' | 'resize-nw' | 'resize-ne' | 'resize-sw' | 'resize-se' | 'rotate' | 'scale' | 'crop-n' | 'crop-s' | 'crop-e' | 'crop-w';
startMouseX: number;
startMouseY: number;
startTransform: ClipTransform;
clipId: string;
}
export function PreviewPanel() {
const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
const { isPlaying, toggle, currentTime } = usePlaybackStore();
// Find the active clip at the current playback time
const getActiveClip = () => {
for (const track of tracks) {
for (const clip of track.clips) {
const [clipTransforms, setClipTransforms] = useState<Record<string, ClipTransform>>({});
const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 }); // Default 16:9
const [dragState, setDragState] = useState<DragState | null>(null);
const previewRef = useRef<HTMLDivElement>(null);
// Get all active clips at current time (for overlaying)
const getActiveClips = () => {
const activeClips: Array<{
clip: any;
track: any;
mediaItem: any;
layer: number;
}> = [];
tracks.forEach((track, trackIndex) => {
track.clips.forEach((clip) => {
const clipStart = clip.startTime;
const clipEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (currentTime >= clipStart && currentTime < clipEnd) {
return clip;
const mediaItem = clip.mediaId === "test"
? { type: "test", name: clip.name, url: "", thumbnailUrl: "" }
: mediaItems.find((item) => item.id === clip.mediaId);
if (mediaItem || clip.mediaId === "test") {
activeClips.push({
clip,
track,
mediaItem,
layer: trackIndex, // Track index determines layer order
});
}
}
});
});
// Sort by layer (track order) - higher index = on top
return activeClips.sort((a, b) => a.layer - b.layer);
};
const activeClips = getActiveClips();
const aspectRatio = canvasSize.width / canvasSize.height;
// Get or create transform for a clip
const getClipTransform = (clipId: string): ClipTransform => {
return clipTransforms[clipId] || {
x: 0,
y: 0,
scale: 1,
rotation: 0,
opacity: 1,
width: 100, // Percentage of canvas
height: 100,
blendMode: 'normal',
cropTop: 0,
cropBottom: 0,
cropLeft: 0,
cropRight: 0,
};
};
// Update clip transform
const updateClipTransform = useCallback((clipId: string, updates: Partial<ClipTransform>) => {
setClipTransforms(prev => {
const currentTransform = prev[clipId] || {
x: 0,
y: 0,
scale: 1,
rotation: 0,
opacity: 1,
width: 100,
height: 100,
blendMode: 'normal',
cropTop: 0,
cropBottom: 0,
cropLeft: 0,
cropRight: 0,
};
return {
...prev,
[clipId]: { ...currentTransform, ...updates }
};
});
}, []);
// Mouse event handlers
const handleMouseDown = (e: React.MouseEvent, clipId: string, dragType: DragState['dragType']) => {
e.preventDefault();
e.stopPropagation();
setDragState({
isDragging: true,
dragType,
startMouseX: e.clientX,
startMouseY: e.clientY,
startTransform: getClipTransform(clipId),
clipId
});
};
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!dragState || !dragState.isDragging) return;
const deltaX = e.clientX - dragState.startMouseX;
const deltaY = e.clientY - dragState.startMouseY;
const { startTransform, clipId, dragType } = dragState;
switch (dragType) {
case 'move':
updateClipTransform(clipId, {
x: Math.max(-100, Math.min(100, startTransform.x + deltaX * 0.3)),
y: Math.max(-100, Math.min(100, startTransform.y + deltaY * 0.3))
});
break;
case 'resize-nw':
updateClipTransform(clipId, {
width: Math.max(20, startTransform.width - deltaX * 0.5),
height: Math.max(20, startTransform.height - deltaY * 0.5)
});
break;
case 'resize-ne':
updateClipTransform(clipId, {
width: Math.max(20, startTransform.width + deltaX * 0.5),
height: Math.max(20, startTransform.height - deltaY * 0.5)
});
break;
case 'resize-sw':
updateClipTransform(clipId, {
width: Math.max(20, startTransform.width - deltaX * 0.5),
height: Math.max(20, startTransform.height + deltaY * 0.5)
});
break;
case 'resize-se':
updateClipTransform(clipId, {
width: Math.max(20, startTransform.width + deltaX * 0.5),
height: Math.max(20, startTransform.height + deltaY * 0.5)
});
break;
case 'rotate':
updateClipTransform(clipId, {
rotation: (startTransform.rotation + deltaX * 2) % 360
});
break;
case 'scale':
updateClipTransform(clipId, {
scale: Math.max(0.1, Math.min(3, startTransform.scale + deltaX * 0.01))
});
break;
case 'crop-n':
updateClipTransform(clipId, {
cropTop: Math.max(0, Math.min(40, startTransform.cropTop + deltaY * 0.2))
});
break;
case 'crop-s':
updateClipTransform(clipId, {
cropBottom: Math.max(0, Math.min(40, startTransform.cropBottom - deltaY * 0.2))
});
break;
case 'crop-e':
updateClipTransform(clipId, {
cropRight: Math.max(0, Math.min(40, startTransform.cropRight - deltaX * 0.2))
});
break;
case 'crop-w':
updateClipTransform(clipId, {
cropLeft: Math.max(0, Math.min(40, startTransform.cropLeft + deltaX * 0.2))
});
break;
}
}, [dragState, updateClipTransform]);
const handleMouseUp = useCallback(() => {
setDragState(null);
}, []);
// Add global mouse event listeners
useEffect(() => {
if (dragState?.isDragging) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}
}, [dragState, handleMouseMove, handleMouseUp]);
// Initialize transforms for new clips
useEffect(() => {
const activeClips = getActiveClips();
const newTransforms: Record<string, ClipTransform> = {};
let hasNewClips = false;
activeClips.forEach(({ clip }) => {
if (!clipTransforms[clip.id]) {
hasNewClips = true;
newTransforms[clip.id] = {
x: 0,
y: 0,
scale: 1,
rotation: 0,
opacity: 1,
width: 100,
height: 100,
blendMode: 'normal',
cropTop: 0,
cropBottom: 0,
cropLeft: 0,
cropRight: 0,
};
}
});
if (hasNewClips) {
setClipTransforms(prev => ({ ...prev, ...newTransforms }));
}
return null;
};
}, [tracks, currentTime]); // Re-run when tracks or time changes
const activeClip = getActiveClip();
const activeMediaItem = activeClip
? mediaItems.find((item) => item.id === activeClip.mediaId)
: null;
const aspectRatio = activeMediaItem?.aspectRatio || 16 / 9;
const renderContent = () => {
if (!activeClip) {
// Render a single clip layer
const renderClipLayer = (clipData: any, index: number) => {
const { clip, mediaItem } = clipData;
const transform = getClipTransform(clip.id);
const layerStyle = {
position: 'absolute' as const,
left: '50%',
top: '50%',
width: `${transform.width}%`,
height: `${transform.height}%`,
transform: `translate(-50%, -50%) translate(${transform.x}%, ${transform.y}%) scale(${transform.scale}) rotate(${transform.rotation}deg)`,
opacity: transform.opacity,
mixBlendMode: transform.blendMode as any,
clipPath: `inset(${transform.cropTop}% ${transform.cropRight}% ${transform.cropBottom}% ${transform.cropLeft}%)`,
zIndex: index + 10,
cursor: dragState?.isDragging && dragState.clipId === clip.id ? 'grabbing' : 'grab',
userSelect: 'none' as const,
};
const handleClipMouseDown = (e: React.MouseEvent) => {
e.stopPropagation();
handleMouseDown(e, clip.id, 'move');
};
// Handle test clips
if (!mediaItem || clip.mediaId === "test") {
return (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground/50">
{tracks.length === 0 ? "Drop media to start editing" : "No clip at current time"}
</div>
);
}
// Handle test clips without media items
if (!activeMediaItem && activeClip.mediaId === "test") {
return (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-blue-500/20 to-purple-500/20">
<div className="text-center">
<div className="text-6xl mb-4">🎬</div>
<p className="text-muted-foreground">{activeClip.name}</p>
<p className="text-xs text-muted-foreground/70 mt-2">Test clip for playback</p>
<div
key={clip.id}
style={layerStyle}
onMouseDown={handleClipMouseDown}
className="bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center group"
>
<div className="text-center pointer-events-none">
<div className="text-2xl">🎬</div>
<p className="text-xs text-white">{clip.name}</p>
</div>
{/* Hover resize corners */}
<div className="absolute -top-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-nw-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-nw'); }}></div>
<div className="absolute -top-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-ne-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-ne'); }}></div>
<div className="absolute -bottom-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-sw-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-sw'); }}></div>
<div className="absolute -bottom-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-se-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-se'); }}></div>
</div>
);
}
if (!activeMediaItem) {
// Render video
if (mediaItem.type === "video") {
return (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground/50">
Media not found
<div key={clip.id} style={layerStyle} onMouseDown={handleClipMouseDown} className="group">
<VideoPlayer
src={mediaItem.url}
poster={mediaItem.thumbnailUrl}
className="w-full h-full pointer-events-none"
clipStartTime={clip.startTime}
trimStart={clip.trimStart}
trimEnd={clip.trimEnd}
clipDuration={clip.duration}
/>
{/* Hover resize corners */}
<div className="absolute -top-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-nw-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-nw'); }}></div>
<div className="absolute -top-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-ne-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-ne'); }}></div>
<div className="absolute -bottom-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-sw-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-sw'); }}></div>
<div className="absolute -bottom-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-se-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-se'); }}></div>
</div>
);
}
if (activeMediaItem.type === "video") {
// Render image
if (mediaItem.type === "image") {
return (
<VideoPlayer
src={activeMediaItem.url}
poster={activeMediaItem.thumbnailUrl}
className="w-full h-full"
clipStartTime={activeClip.startTime}
trimStart={activeClip.trimStart}
trimEnd={activeClip.trimEnd}
clipDuration={activeClip.duration}
key={`${activeClip.id}-${activeClip.trimStart}-${activeClip.trimEnd}`}
/>
<div key={clip.id} style={layerStyle} onMouseDown={handleClipMouseDown} className="group">
<img
src={mediaItem.url}
alt={mediaItem.name}
className="w-full h-full object-cover rounded pointer-events-none"
draggable={false}
/>
{/* Hover resize corners */}
<div className="absolute -top-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-nw-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-nw'); }}></div>
<div className="absolute -top-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-ne-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-ne'); }}></div>
<div className="absolute -bottom-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-sw-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-sw'); }}></div>
<div className="absolute -bottom-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-se-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-se'); }}></div>
</div>
);
}
if (activeMediaItem.type === "image") {
// Render audio (visual representation)
if (mediaItem.type === "audio") {
return (
<ImageTimelineTreatment
src={activeMediaItem.url}
alt={activeMediaItem.name}
targetAspectRatio={aspectRatio}
className="w-full h-full"
backgroundType="blur"
/>
);
}
if (activeMediaItem.type === "audio") {
return (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-green-500/20 to-emerald-500/20">
<div className="text-center">
<div className="text-6xl mb-4">🎵</div>
<p className="text-muted-foreground">{activeMediaItem.name}</p>
<Button
variant="outline"
className="mt-4"
onClick={toggle}
>
{isPlaying ? <Pause className="h-4 w-4 mr-2" /> : <Play className="h-4 w-4 mr-2" />}
{isPlaying ? "Pause" : "Play"}
</Button>
<div
key={clip.id}
style={layerStyle}
onMouseDown={handleClipMouseDown}
className="bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex items-center justify-center group"
>
<div className="text-center pointer-events-none">
<div className="text-2xl">🎵</div>
<p className="text-xs text-white">{mediaItem.name}</p>
</div>
{/* Hover resize corners */}
<div className="absolute -top-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-nw-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-nw'); }}></div>
<div className="absolute -top-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-ne-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-ne'); }}></div>
<div className="absolute -bottom-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-sw-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-sw'); }}></div>
<div className="absolute -bottom-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-se-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-se'); }}></div>
</div>
);
}
@ -114,31 +401,100 @@ export function PreviewPanel() {
return null;
};
// Canvas size presets
const canvasPresets = [
{ name: "16:9 HD", width: 1920, height: 1080 },
{ name: "16:9 4K", width: 3840, height: 2160 },
{ name: "9:16 Mobile", width: 1080, height: 1920 },
{ name: "1:1 Square", width: 1080, height: 1080 },
{ name: "4:3 Standard", width: 1440, height: 1080 },
];
return (
<div className="h-full flex flex-col items-center justify-center p-4 overflow-hidden">
<div
className="bg-black rounded-lg shadow-lg relative overflow-hidden flex-shrink-0"
style={{
aspectRatio: aspectRatio.toString(),
width: aspectRatio > 1 ? "100%" : "auto",
height: aspectRatio <= 1 ? "100%" : "auto",
maxWidth: "100%",
maxHeight: "100%",
}}
>
{renderContent()}
<div className="h-full flex flex-col">
{/* Canvas Controls */}
<div className="border-b p-2 flex items-center gap-2 text-xs">
<span className="text-muted-foreground">Canvas:</span>
<select
value={`${canvasSize.width}x${canvasSize.height}`}
onChange={(e) => {
const preset = canvasPresets.find(p => `${p.width}x${p.height}` === e.target.value);
if (preset) setCanvasSize({ width: preset.width, height: preset.height });
}}
className="bg-background border rounded px-2 py-1"
>
{canvasPresets.map(preset => (
<option key={preset.name} value={`${preset.width}x${preset.height}`}>
{preset.name} ({preset.width}×{preset.height})
</option>
))}
</select>
<Button
variant="outline"
size="sm"
onClick={toggle}
className="ml-auto"
>
{isPlaying ? <Pause className="h-3 w-3 mr-1" /> : <Play className="h-3 w-3 mr-1" />}
{isPlaying ? "Pause" : "Play"}
</Button>
</div>
{activeMediaItem && (
<div className="mt-4 text-center">
<p className="text-sm text-muted-foreground">
{activeMediaItem.name}
</p>
<p className="text-xs text-muted-foreground/70">
{aspectRatio.toFixed(2)} {aspectRatio > 1 ? "Landscape" : aspectRatio < 1 ? "Portrait" : "Square"}
</p>
{/* Preview Area - Full Width */}
<div className="flex-1 flex items-center justify-center p-4 bg-gray-900">
<div
ref={previewRef}
className="relative overflow-hidden"
style={{
aspectRatio: aspectRatio.toString(),
width: aspectRatio > 1 ? "100%" : "auto",
height: aspectRatio <= 1 ? "100%" : "auto",
maxWidth: "100%",
maxHeight: "100%",
background: '#000000',
border: '1px solid #374151'
}}
>
{/* Render all active clips as layers */}
{activeClips.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center text-white/50">
{tracks.length === 0 ? "Drop media to start editing" : "No clips at current time"}
</div>
) : (
activeClips.map((clipData, index) => renderClipLayer(clipData, index))
)}
</div>
)}
</div>
{/* Bottom Info Panel */}
<div className="border-t bg-background">
{/* Layer List */}
<div className="p-2 border-b">
<div className="text-xs font-medium mb-2">Active Layers ({activeClips.length})</div>
<div className="space-y-1 max-h-20 overflow-y-auto">
{activeClips.map((clipData, index) => (
<div
key={clipData.clip.id}
className="flex items-center gap-2 p-1 rounded text-xs hover:bg-muted/50"
>
<span className="w-4 h-4 bg-muted rounded text-center text-xs leading-4">
{index + 1}
</span>
<span className="flex-1 truncate">{clipData.clip.name}</span>
<span className="text-muted-foreground">{clipData.track.name}</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}

View File

@ -40,72 +40,7 @@ export function PropertiesPanel() {
return (
<ScrollArea className="h-full">
<div className="space-y-6 p-5">
{/* Image Treatment - only show if an image is selected */}
{firstImageItem && (
<>
<div className="space-y-4">
<h3 className="text-sm font-medium">Image Treatment</h3>
<div className="space-y-4">
{/* Preview */}
<div className="space-y-2">
<Label>Preview</Label>
<div className="w-full aspect-video max-w-48">
<ImageTimelineTreatment
src={firstImageItem.url}
alt={firstImageItem.name}
targetAspectRatio={16 / 9}
className="rounded-sm border"
backgroundType={backgroundType}
backgroundColor={backgroundColor}
/>
</div>
</div>
{/* Background Type */}
<div className="space-y-2">
<Label htmlFor="bg-type">Background Type</Label>
<Select
value={backgroundType}
onValueChange={(value: any) => setBackgroundType(value)}
>
<SelectTrigger>
<SelectValue placeholder="Select background type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="blur">Blur</SelectItem>
<SelectItem value="mirror">Mirror</SelectItem>
<SelectItem value="color">Solid Color</SelectItem>
</SelectContent>
</Select>
</div>
{/* Background Color - only show for color type */}
{backgroundType === "color" && (
<div className="space-y-2">
<Label htmlFor="bg-color">Background Color</Label>
<div className="flex gap-2">
<Input
id="bg-color"
type="color"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="w-16 h-10 p-1"
/>
<Input
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
placeholder="#000000"
className="flex-1"
/>
</div>
</div>
)}
</div>
</div>
<Separator />
</>
)}
{/* Transform */}
<div className="space-y-4">