Merge branch 'staging'

This commit is contained in:
Maze Winther 2025-07-15 23:28:06 +02:00
commit d5c52557df
10 changed files with 314 additions and 97 deletions

View File

@ -1,18 +1,12 @@
import { Hero } from "@/components/landing/hero";
import { Header } from "@/components/header";
import { Footer } from "@/components/footer";
import { getWaitlistCount } from "@/lib/waitlist";
// Force dynamic rendering so waitlist count updates in real-time
export const dynamic = "force-dynamic";
export default async function Home() {
const signupCount = await getWaitlistCount();
return (
<div>
<Header />
<Hero signupCount={signupCount} />
<Hero />
<Footer />
</div>
);

View File

@ -24,6 +24,7 @@ import {
} from "@/components/ui/select";
import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store";
export function MediaView() {
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
@ -288,6 +289,11 @@ export function MediaView() {
name: item.name,
}}
showPlusOnDrag={false}
onAddToTimeline={(currentTime) =>
useTimelineStore
.getState()
.addMediaAtTime(item, currentTime)
}
rounded={false}
/>
</ContextMenuTrigger>

View File

@ -1,4 +1,30 @@
import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useTimelineStore } from "@/stores/timeline-store";
import { type TextElement } from "@/types/timeline";
let textData: TextElement = {
id: "default-text",
type: "text",
name: "Default text",
content: "Default text",
fontSize: 48,
fontFamily: "Arial",
color: "#ffffff",
backgroundColor: "transparent",
textAlign: "center" as const,
fontWeight: "normal" as const,
fontStyle: "normal" as const,
textDecoration: "none" as const,
x: 0,
y: 0,
rotation: 0,
opacity: 1,
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
startTime: 0,
trimStart: 0,
trimEnd: 0,
};
export function TextView() {
return (
@ -11,12 +37,15 @@ export function TextView() {
</div>
}
dragData={{
id: "default-text",
type: "text",
name: "Default text",
content: "Default text",
id: textData.id,
type: textData.type,
name: textData.name,
content: textData.content,
}}
aspectRatio={1}
onAddToTimeline={(currentTime) =>
useTimelineStore.getState().addTextAtTime(textData, currentTime)
}
showLabel={false}
/>
</div>

View File

@ -24,6 +24,7 @@ import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
import {
getTrackElementClasses,
TIMELINE_CONSTANTS,
getTrackHeight,
} from "@/constants/timeline-constants";
import {
DropdownMenu,
@ -267,35 +268,99 @@ export function TimelineElement({
);
}
const TILE_ASPECT_RATIO = 16 / 9;
if (mediaItem.type === "image") {
// Calculate tile size based on 16:9 aspect ratio
const trackHeight = getTrackHeight(track.type);
const tileHeight = trackHeight - 8; // Account for padding
const tileWidth = tileHeight * TILE_ASPECT_RATIO;
return (
<div className="w-full h-full flex items-center justify-center">
<div className="bg-[#004D52] py-3 w-full h-full">
<img
src={mediaItem.url}
alt={mediaItem.name}
className="w-full h-full object-cover"
draggable={false}
<div className="bg-[#004D52] py-3 w-full h-full relative">
{/* Background with tiled images */}
<div
className="absolute top-3 bottom-3 left-0 right-0"
style={{
backgroundImage: mediaItem.url
? `url(${mediaItem.url})`
: "none",
backgroundRepeat: "repeat-x",
backgroundSize: `${tileWidth}px ${tileHeight}px`,
backgroundPosition: "left center",
pointerEvents: "none",
}}
aria-label={`Tiled background of ${mediaItem.name}`}
/>
{/* Overlay with vertical borders */}
<div
className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none"
style={{
backgroundImage: `repeating-linear-gradient(
to right,
transparent 0px,
transparent ${tileWidth - 1}px,
rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
rgba(255, 255, 255, 0.6) ${tileWidth}px
)`,
backgroundPosition: "left center",
}}
/>
</div>
</div>
);
}
const VIDEO_TILE_PADDING = 16;
const OVERLAY_SPACE_MULTIPLIER = 1.5;
if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
const trackHeight = getTrackHeight(track.type);
const tileHeight = trackHeight - VIDEO_TILE_PADDING;
const tileWidth = tileHeight * TILE_ASPECT_RATIO;
return (
<div className="w-full h-full flex items-center gap-2">
<div className="w-8 h-8 flex-shrink-0">
<img
src={mediaItem.thumbnailUrl}
alt={mediaItem.name}
className="w-full h-full object-cover rounded-sm"
draggable={false}
<div className="flex-1 h-full relative overflow-hidden">
{/* Background with tiled thumbnails */}
<div
className="absolute inset-0"
style={{
backgroundImage: mediaItem.thumbnailUrl
? `url(${mediaItem.thumbnailUrl})`
: "none",
backgroundRepeat: "repeat-x",
backgroundSize: `${tileWidth}px ${tileHeight}px`,
backgroundPosition: "left center",
pointerEvents: "none",
}}
aria-label={`Tiled thumbnail of ${mediaItem.name}`}
/>
{/* Overlay with vertical borders */}
<div
className="absolute inset-0 pointer-events-none"
style={{
backgroundImage: `repeating-linear-gradient(
to right,
transparent 0px,
transparent ${tileWidth - 1}px,
rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
rgba(255, 255, 255, 0.6) ${tileWidth}px
)`,
backgroundPosition: "left center",
}}
/>
</div>
<span className="text-xs text-foreground/80 truncate flex-1">
{element.name}
</span>
{elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER ? (
<div className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/70 text-white text-xs px-2 py-1 rounded pointer-events-none max-w-[40%] truncate">
{element.name}
</div>
) : (
<span className="text-xs text-foreground/80 truncate flex-shrink-0 max-w-[120px]">
{element.name}
</span>
)}
</div>
);
}

View File

@ -17,6 +17,7 @@ import {
TypeIcon,
Magnet,
Lock,
LockOpen,
} from "lucide-react";
import {
Tooltip,
@ -376,29 +377,7 @@ export function Timeline() {
if (dragData.type === "text") {
// Always create new text track to avoid overlaps
const newTrackId = addTrack("text");
addElementToTrack(newTrackId, {
type: "text",
name: dragData.name || "Text",
content: dragData.content || "Default Text",
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
startTime: 0,
trimStart: 0,
trimEnd: 0,
fontSize: 48,
fontFamily: "Arial",
color: "#ffffff",
backgroundColor: "transparent",
textAlign: "center",
fontWeight: "normal",
fontStyle: "normal",
textDecoration: "none",
x: 0,
y: 0,
rotation: 0,
opacity: 1,
});
useTimelineStore.getState().addTextToNewTrack(dragData);
} else {
// Handle media items
const mediaItem = mediaItems.find((item) => item.id === dragData.id);
@ -407,19 +386,7 @@ export function Timeline() {
return;
}
const trackType = dragData.type === "audio" ? "audio" : "media";
let targetTrack = tracks.find((t) => t.type === trackType);
const newTrackId = targetTrack ? targetTrack.id : addTrack(trackType);
addElementToTrack(newTrackId, {
type: "media",
mediaId: mediaItem.id,
name: mediaItem.name,
duration: mediaItem.duration || 5,
startTime: 0,
trimStart: 0,
trimEnd: 0,
});
useTimelineStore.getState().addMediaToNewTrack(mediaItem);
}
} catch (error) {
console.error("Error parsing dropped item data:", error);
@ -447,18 +414,7 @@ export function Timeline() {
item.name === processedItem.name && item.url === processedItem.url
);
if (addedItem) {
const trackType =
processedItem.type === "audio" ? "audio" : "media";
const newTrackId = addTrack(trackType);
addElementToTrack(newTrackId, {
type: "media",
mediaId: addedItem.id,
name: addedItem.name,
duration: addedItem.duration || 5,
startTime: 0,
trimStart: 0,
trimEnd: 0,
});
useTimelineStore.getState().addMediaToNewTrack(addedItem);
}
}
} catch (error) {
@ -853,7 +809,11 @@ export function Timeline() {
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={toggleSnapping}>
<Lock className="h-4 w-4" />
{snappingEnabled ? (
<Lock className="h-4 w-4" />
) : (
<LockOpen className="h-4 w-4 text-primary" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Auto snapping</TooltipContent>

View File

@ -10,11 +10,7 @@ import { toast } from "sonner";
import Image from "next/image";
import { Handlebars } from "./handlebars";
interface HeroProps {
signupCount: number;
}
export function Hero({ signupCount }: HeroProps) {
export function Hero() {
const [email, setEmail] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
@ -131,18 +127,6 @@ export function Hero({ signupCount }: HeroProps) {
</Button>
</form>
</motion.div>
{signupCount > 0 && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.8, duration: 0.6 }}
className="mt-8 inline-flex items-center gap-2 text-sm text-muted-foreground justify-center"
>
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
<span>{signupCount.toLocaleString()} people already joined</span>
</motion.div>
)}
</motion.div>
</div>
);

View File

@ -6,12 +6,14 @@ import { ReactNode, useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { usePlaybackStore } from "@/stores/playback-store";
export interface DraggableMediaItemProps {
name: string;
preview: ReactNode;
dragData: Record<string, any>;
onDragStart?: (e: React.DragEvent) => void;
onAddToTimeline?: (currentTime: number) => void;
aspectRatio?: number;
className?: string;
showPlusOnDrag?: boolean;
@ -24,6 +26,7 @@ export function DraggableMediaItem({
preview,
dragData,
onDragStart,
onAddToTimeline,
aspectRatio = 16 / 9,
className = "",
showPlusOnDrag = true,
@ -33,6 +36,11 @@ export function DraggableMediaItem({
const [isDragging, setIsDragging] = useState(false);
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
const dragRef = useRef<HTMLDivElement>(null);
const currentTime = usePlaybackStore((state) => state.currentTime);
const handleAddToTimeline = () => {
onAddToTimeline?.(currentTime);
};
const emptyImg = new window.Image();
emptyImg.src =
@ -92,7 +100,10 @@ export function DraggableMediaItem({
>
{preview}
{!isDragging && (
<PlusButton className="opacity-0 group-hover:opacity-100" />
<PlusButton
className="opacity-0 group-hover:opacity-100"
onClick={handleAddToTimeline}
/>
)}
</AspectRatio>
{showLabel && (
@ -128,7 +139,7 @@ export function DraggableMediaItem({
<div className="w-full h-full [&_img]:w-full [&_img]:h-full [&_img]:object-cover [&_img]:rounded-none">
{preview}
</div>
{showPlusOnDrag && <PlusButton />}
{showPlusOnDrag && <PlusButton onClick={handleAddToTimeline} tooltipText="Add to timeline or drag to position" />}
</AspectRatio>
</div>
</div>,
@ -138,11 +149,16 @@ export function DraggableMediaItem({
);
}
function PlusButton({ className }: { className?: string }) {
function PlusButton({ className, onClick }: { className?: string; onClick?: () => void }) {
return (
<Button
size="icon"
className={cn("absolute bottom-2 right-2 size-4", className)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClick?.();
}}
>
<Plus className="!size-3" />
</Button>

View File

@ -20,7 +20,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-foreground/10 px-3 py-1.5 text-xs text-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"z-50 overflow-hidden rounded-md bg-foreground px-3 py-1.5 text-xs text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}

View File

@ -74,6 +74,7 @@ export const TIMELINE_CONSTANTS = {
PIXELS_PER_SECOND: 50,
TRACK_HEIGHT: 60, // Default fallback
DEFAULT_TEXT_DURATION: 5,
DEFAULT_IMAGE_DURATION: 5,
ZOOM_LEVELS: [0.25, 0.5, 1, 1.5, 2, 3, 4],
} as const;

View File

@ -5,15 +5,22 @@ import {
CreateTimelineElement,
TimelineTrack,
TextElement,
DragData,
sortTracksByOrder,
ensureMainTrack,
validateElementTrackCompatibility,
} from "@/types/timeline";
import { useEditorStore } from "./editor-store";
import { useMediaStore, getMediaAspectRatio } from "./media-store";
import {
useMediaStore,
getMediaAspectRatio,
type MediaItem,
} from "./media-store";
import { storageService } from "@/lib/storage/storage-service";
import { useProjectStore } from "./project-store";
import { generateUUID } from "@/lib/utils";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { toast } from "sonner";
// Helper function to manage element naming with suffixes
const getElementNameWithSuffix = (
@ -166,6 +173,17 @@ interface TimelineStore {
>
>
) => void;
checkElementOverlap: (
trackId: string,
startTime: number,
duration: number,
excludeElementId?: string
) => boolean;
findOrCreateTrack: (trackType: TrackType) => string;
addMediaAtTime: (item: MediaItem, currentTime?: number) => boolean;
addTextAtTime: (item: TextElement, currentTime?: number) => boolean;
addMediaToNewTrack: (item: MediaItem) => boolean;
addTextToNewTrack: (item: TextElement | DragData) => boolean;
}
export const useTimelineStore = create<TimelineStore>((set, get) => {
@ -963,5 +981,149 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
toggleSnapping: () => {
set((state) => ({ snappingEnabled: !state.snappingEnabled }));
},
checkElementOverlap: (trackId, startTime, duration, excludeElementId) => {
const track = get()._tracks.find((t) => t.id === trackId);
if (!track) return false;
const overlap = track.elements.some((element) => {
const elementEnd =
element.startTime +
element.duration -
element.trimStart -
element.trimEnd;
if (element.id === excludeElementId) {
return false;
}
return (
(startTime >= element.startTime && startTime < elementEnd) ||
(startTime + duration > element.startTime &&
startTime + duration <= elementEnd) ||
(startTime < element.startTime && startTime + duration > elementEnd)
);
});
return overlap;
},
findOrCreateTrack: (trackType) => {
// Always create new text track to allow multiple text elements
if (trackType === "text") {
return get().addTrack(trackType);
}
const existingTrack = get()._tracks.find((t) => t.type === trackType);
if (existingTrack) {
return existingTrack.id;
}
return get().addTrack(trackType);
},
addMediaAtTime: (item, currentTime = 0) => {
const trackType = item.type === "audio" ? "audio" : "media";
const targetTrackId = get().findOrCreateTrack(trackType);
const duration =
item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION;
if (get().checkElementOverlap(targetTrackId, currentTime, duration)) {
toast.error(
"Cannot place element here - it would overlap with existing elements"
);
return false;
}
get().addElementToTrack(targetTrackId, {
type: "media",
mediaId: item.id,
name: item.name,
duration,
startTime: currentTime,
trimStart: 0,
trimEnd: 0,
});
return true;
},
addTextAtTime: (item, currentTime = 0) => {
const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements
get().addElementToTrack(targetTrackId, {
type: "text",
name: item.name || "Text",
content: item.content || "Default Text",
duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
startTime: currentTime,
trimStart: 0,
trimEnd: 0,
fontSize: item.fontSize || 48,
fontFamily: item.fontFamily || "Arial",
color: item.color || "#ffffff",
backgroundColor: item.backgroundColor || "transparent",
textAlign: item.textAlign || "center",
fontWeight: item.fontWeight || "normal",
fontStyle: item.fontStyle || "normal",
textDecoration: item.textDecoration || "none",
x: item.x || 0,
y: item.y || 0,
rotation: item.rotation || 0,
opacity: item.opacity !== undefined ? item.opacity : 1,
});
return true;
},
addMediaToNewTrack: (item) => {
const trackType = item.type === "audio" ? "audio" : "media";
const targetTrackId = get().findOrCreateTrack(trackType);
get().addElementToTrack(targetTrackId, {
type: "media",
mediaId: item.id,
name: item.name,
duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
startTime: 0,
trimStart: 0,
trimEnd: 0,
});
return true;
},
addTextToNewTrack: (item) => {
const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements
get().addElementToTrack(targetTrackId, {
type: "text",
name: item.name || "Text",
content:
("content" in item ? item.content : "Default Text") || "Default Text",
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
startTime: 0,
trimStart: 0,
trimEnd: 0,
fontSize: ("fontSize" in item ? item.fontSize : 48) || 48,
fontFamily:
("fontFamily" in item ? item.fontFamily : "Arial") || "Arial",
color: ("color" in item ? item.color : "#ffffff") || "#ffffff",
backgroundColor:
("backgroundColor" in item ? item.backgroundColor : "transparent") ||
"transparent",
textAlign:
("textAlign" in item ? item.textAlign : "center") || "center",
fontWeight:
("fontWeight" in item ? item.fontWeight : "normal") || "normal",
fontStyle:
("fontStyle" in item ? item.fontStyle : "normal") || "normal",
textDecoration:
("textDecoration" in item ? item.textDecoration : "none") || "none",
x: ("x" in item ? item.x : 0) || 0,
y: ("y" in item ? item.y : 0) || 0,
rotation: ("rotation" in item ? item.rotation : 0) || 0,
opacity:
"opacity" in item && item.opacity !== undefined ? item.opacity : 1,
});
return true;
},
};
});