refactor: type-safety & separate concerns

This commit is contained in:
Maze Winther
2025-08-26 15:04:25 +02:00
parent 2f7cd11ca1
commit b459b46802
22 changed files with 430 additions and 542 deletions
@@ -6,6 +6,7 @@ import { useState, useRef, useEffect } from "react";
import { extractTimelineAudio } from "@/lib/mediabunny-utils";
import { encryptWithRandomKey, arrayBufferToBase64 } from "@/lib/zk-encryption";
import { useTimelineStore } from "@/stores/timeline-store";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import { Loader2, Shield, Trash2, Upload } from "lucide-react";
import {
Dialog,
@@ -162,24 +163,13 @@ export function Captions() {
// Add all caption elements to the same track
shortCaptions.forEach((caption, index) => {
addElementToTrack(captionTrackId, {
type: "text",
...DEFAULT_TEXT_ELEMENT,
name: `Caption ${index + 1}`,
content: caption.text,
duration: caption.duration,
startTime: caption.startTime,
trimStart: 0,
trimEnd: 0,
fontSize: 65,
fontFamily: "Arial",
color: "#ffffff",
textAlign: "center",
fontWeight: "bold",
fontStyle: "normal",
textDecoration: "none",
x: 0,
y: 0,
rotation: 0,
opacity: 1,
fontSize: 65, // Larger for captions
fontWeight: "bold", // Bold for captions
} as TextElement);
});
@@ -2,7 +2,8 @@
import { useDragDrop } from "@/hooks/use-drag-drop";
import { processMediaFiles } from "@/lib/media-processing";
import { useMediaStore, type MediaItem } from "@/stores/media-store";
import { useMediaStore } from "@/stores/media-store";
import { MediaFile } from "@/types/media";
import {
ArrowDown01,
CloudUpload,
@@ -13,7 +14,7 @@ import {
Music,
Video,
} from "lucide-react";
import { useRef, useState, useMemo, useEffect } from "react";
import { useRef, useState, useMemo } from "react";
import { useHighlightScroll } from "@/hooks/use-highlight-scroll";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -47,7 +48,7 @@ function MediaItemWithContextMenu({
children,
onRemove,
}: {
item: MediaItem;
item: MediaFile;
children: React.ReactNode;
onRemove: (e: React.MouseEvent, id: string) => Promise<void>;
}) {
@@ -68,14 +69,12 @@ function MediaItemWithContextMenu({
}
export function MediaView() {
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
const { mediaFiles, addMediaFile, removeMediaFile } = useMediaStore();
const { activeProject } = useProjectStore();
const { mediaViewMode, setMediaViewMode } = usePanelStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
const [mediaFilter, setMediaFilter] = useState("all");
const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">(
"name"
);
@@ -96,16 +95,13 @@ export function MediaView() {
setIsProcessing(true);
setProgress(0);
try {
// Process files (extract metadata, generate thumbnails, etc.)
const processedItems = await processMediaFiles(files, (p) =>
setProgress(p)
);
// Add each processed media item to the store
for (const item of processedItems) {
await addMediaItem(activeProject.id, item);
await addMediaFile(activeProject.id, item);
}
} catch (error) {
// Show error toast if processing fails
console.error("Error processing files:", error);
toast.error("Failed to process files");
} finally {
@@ -122,13 +118,11 @@ export function MediaView() {
const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// When files are selected via file picker, process them
if (e.target.files) processFiles(e.target.files);
e.target.value = ""; // Reset input
};
const handleRemove = async (e: React.MouseEvent, id: string) => {
// Remove a media item from the store
e.stopPropagation();
if (!activeProject) {
@@ -136,8 +130,7 @@ export function MediaView() {
return;
}
// Media store now handles cascade deletion automatically
await removeMediaItem(activeProject.id, id);
await removeMediaFile(activeProject.id, id);
};
const formatDuration = (duration: number) => {
@@ -147,28 +140,15 @@ export function MediaView() {
return `${min}:${sec.toString().padStart(2, "0")}`;
};
const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems);
useEffect(() => {
let filtered = mediaItems.filter((item) => {
const filteredMediaItems = useMemo(() => {
let filtered = mediaFiles.filter((item) => {
if (item.ephemeral) return false;
if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
return false;
}
if (
searchQuery &&
!item.name.toLowerCase().includes(searchQuery.toLowerCase())
) {
return false;
}
return true;
});
filtered.sort((a, b) => {
let valueA: any;
let valueB: any;
let valueA: string | number;
let valueB: string | number;
switch (sortBy) {
case "name":
@@ -196,8 +176,8 @@ export function MediaView() {
return 0;
});
setFilteredMediaItems(filtered);
}, [mediaItems, mediaFilter, searchQuery, sortBy, sortOrder]);
return filtered;
}, [mediaFiles, sortBy, sortOrder]);
const previewComponents = useMemo(() => {
const previews = new Map<string, React.ReactNode>();
@@ -276,7 +256,7 @@ export function MediaView() {
return previews;
}, [filteredMediaItems]);
const renderPreview = (item: MediaItem) => previewComponents.get(item.id);
const renderPreview = (item: MediaFile) => previewComponents.get(item.id);
return (
<>
@@ -481,12 +461,14 @@ function GridView({
highlightedId,
registerElement,
}: {
filteredMediaItems: MediaItem[];
renderPreview: (item: MediaItem) => React.ReactNode;
filteredMediaItems: MediaFile[];
renderPreview: (item: MediaFile) => React.ReactNode;
handleRemove: (e: React.MouseEvent, id: string) => Promise<void>;
highlightedId: string | null;
registerElement: (id: string, element: HTMLElement | null) => void;
}) {
const { addElementAtTime } = useTimelineStore();
return (
<div
className="grid gap-2"
@@ -507,7 +489,7 @@ function GridView({
}}
showPlusOnDrag={false}
onAddToTimeline={(currentTime) =>
useTimelineStore.getState().addMediaAtTime(item, currentTime)
addElementAtTime(item, currentTime)
}
rounded={false}
variant="card"
@@ -527,12 +509,14 @@ function ListView({
highlightedId,
registerElement,
}: {
filteredMediaItems: MediaItem[];
renderPreview: (item: MediaItem) => React.ReactNode;
filteredMediaItems: MediaFile[];
renderPreview: (item: MediaFile) => React.ReactNode;
handleRemove: (e: React.MouseEvent, id: string) => Promise<void>;
highlightedId: string | null;
registerElement: (id: string, element: HTMLElement | null) => void;
}) {
const { addElementAtTime } = useTimelineStore();
return (
<div className="space-y-1">
{filteredMediaItems.map((item) => (
@@ -548,7 +532,7 @@ function ListView({
}}
showPlusOnDrag={false}
onAddToTimeline={(currentTime) =>
useTimelineStore.getState().addMediaAtTime(item, currentTime)
addElementAtTime(item, currentTime)
}
variant="compact"
isHighlighted={highlightedId === item.id}
@@ -35,10 +35,10 @@ import {
ICONIFY_HOSTS,
POPULAR_COLLECTIONS,
} from "@/lib/iconify-api";
import { cn, generateUUID } from "@/lib/utils";
import { cn } from "@/lib/utils";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import Image from "next/image";
import type { MediaItem } from "@/stores/media-store";
import type { MediaFile } from "@/types/media";
import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { InputWithBack } from "@/components/ui/input-with-back";
import { StickerCategory } from "@/stores/stickers-store";
@@ -186,9 +186,9 @@ function EmptyView({ message }: { message: string }) {
function StickersContentView({ category }: { category: StickerCategory }) {
const { activeProject } = useProjectStore();
const { addMediaAtTime } = useTimelineStore();
const { addElementAtTime } = useTimelineStore();
const { currentTime } = usePlaybackStore();
const { addMediaItem } = useMediaStore();
const { addMediaFile } = useMediaStore();
const {
searchQuery,
selectedCollection,
@@ -287,7 +287,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
throw new Error("Failed to download sticker");
}
const mediaItem: Omit<MediaItem, "id"> = {
const mediaItem: Omit<MediaFile, "id"> = {
name: iconName.replace(":", "-"),
type: "image",
file,
@@ -298,16 +298,16 @@ function StickersContentView({ category }: { category: StickerCategory }) {
ephemeral: false,
};
await addMediaItem(activeProject.id, mediaItem);
await addMediaFile(activeProject.id, mediaItem);
const added = useMediaStore
.getState()
.mediaItems.find(
.mediaFiles.find(
(m) => m.url === mediaItem.url && m.name === mediaItem.name
);
if (!added) throw new Error("Sticker not in media store");
addMediaAtTime(added, currentTime);
addElementAtTime(added, currentTime);
toast.success(`Added "${iconName}" to timeline`);
} catch (error) {
@@ -640,8 +640,8 @@ function StickerItem({
name={displayName}
preview={preview}
dragData={{
type: "sticker",
iconName: iconName,
id: "sticker-placeholder",
type: "image",
name: displayName,
}}
onAddToTimeline={() => onAdd(iconName)}
@@ -1,31 +1,7 @@
import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { BaseView } from "./base-view";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useTimelineStore } from "@/stores/timeline-store";
import { type TextElement } from "@/types/timeline";
const 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,
};
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
export function TextView() {
return (
@@ -38,14 +14,20 @@ export function TextView() {
</div>
}
dragData={{
id: textData.id,
type: textData.type,
name: textData.name,
content: textData.content,
id: "temp-text-id",
type: DEFAULT_TEXT_ELEMENT.type,
name: DEFAULT_TEXT_ELEMENT.name,
content: DEFAULT_TEXT_ELEMENT.content,
}}
aspectRatio={1}
onAddToTimeline={(currentTime) =>
useTimelineStore.getState().addTextAtTime(textData, currentTime)
useTimelineStore.getState().addElementAtTime(
{
...DEFAULT_TEXT_ELEMENT,
id: "temp-text-id",
},
currentTime
)
}
showLabel={false}
/>
@@ -2,13 +2,13 @@
import { useTimelineStore } from "@/stores/timeline-store";
import { TimelineElement, TimelineTrack } from "@/types/timeline";
import { useMediaStore, type MediaItem } from "@/stores/media-store";
import { useMediaStore } from "@/stores/media-store";
import { MediaFile } from "@/types/media";
import { usePlaybackStore } from "@/stores/playback-store";
import { useEditorStore } from "@/stores/editor-store";
import { Button } from "@/components/ui/button";
import { Play, Pause, Expand, SkipBack, SkipForward } from "lucide-react";
import { useState, useRef, useEffect, useCallback } from "react";
import { VideoSampleSink } from "mediabunny";
import { renderTimelineFrame } from "@/lib/timeline-renderer";
import { cn } from "@/lib/utils";
import { formatTimeCode } from "@/lib/time";
@@ -33,12 +33,12 @@ import { PLATFORM_LAYOUTS, type PlatformLayout } from "@/stores/editor-store";
interface ActiveElement {
element: TimelineElement;
track: TimelineTrack;
mediaItem: MediaItem | null;
mediaItem: MediaFile | null;
}
export function PreviewPanel() {
const { tracks, getTotalDuration, updateTextElement } = useTimelineStore();
const { mediaItems } = useMediaStore();
const { mediaFiles } = useMediaStore();
const { currentTime, toggle, setCurrentTime } = usePlaybackStore();
const { isPlaying, volume, muted } = usePlaybackStore();
const { activeProject } = useProjectStore();
@@ -264,7 +264,7 @@ export function PreviewPanel() {
mediaItem =
element.mediaId === "test"
? null
: mediaItems.find((item) => item.id === element.mediaId) ||
: mediaFiles.find((item) => item.id === element.mediaId) ||
null;
}
activeElements.push({ element, track, mediaItem });
@@ -334,7 +334,7 @@ export function PreviewPanel() {
const gain = audioGainRef.current!;
const tracksSnapshot = useTimelineStore.getState().tracks;
const mediaList = useMediaStore.getState().mediaItems;
const mediaList = mediaFiles;
const idToMedia = new Map(mediaList.map((m) => [m.id, m] as const));
const playbackNow = usePlaybackStore.getState().currentTime;
@@ -447,7 +447,7 @@ export function PreviewPanel() {
}
playingSourcesRef.current.clear();
};
}, [isPlaying, volume, muted, mediaItems]);
}, [isPlaying, volume, muted, mediaFiles]);
// Canvas: draw current frame for visible elements using offscreen compositing
useEffect(() => {
@@ -533,13 +533,11 @@ export function PreviewPanel() {
canvasWidth: displayWidth,
canvasHeight: displayHeight,
tracks,
mediaItems,
mediaFiles,
backgroundColor:
activeProject?.backgroundType === "blur"
? "transparent"
: activeProject?.backgroundColor || "#000000",
useCache: false,
fps: activeProject?.fps || DEFAULT_FPS,
});
// Blit offscreen to visible canvas
@@ -10,7 +10,7 @@ import { SquareSlashIcon } from "lucide-react";
export function PropertiesPanel() {
const { selectedElements, tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
const { mediaFiles } = useMediaStore();
return (
<>
@@ -28,11 +28,11 @@ export function PropertiesPanel() {
);
}
if (element?.type === "media") {
const mediaItem = mediaItems.find(
(item) => item.id === element.mediaId
const mediaFile = mediaFiles.find(
(file) => file.id === element.mediaId
);
if (mediaItem?.type === "audio") {
if (mediaFile?.type === "audio") {
return <AudioProperties key={elementId} element={element} />;
}
@@ -82,13 +82,11 @@ export function Timeline() {
toggleTrackMute,
dragState,
} = useTimelineStore();
const { mediaItems, addMediaItem } = useMediaStore();
const { mediaFiles, addMediaFile } = useMediaStore();
const { activeProject } = useProjectStore();
const { currentTime, duration, seek, setDuration, isPlaying, toggle } =
usePlaybackStore();
const { currentTime, duration, seek, setDuration } = usePlaybackStore();
const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const { addElementToNewTrack } = useTimelineStore();
const dragCounterRef = useRef(0);
const timelineRef = useRef<HTMLDivElement>(null);
const rulerRef = useRef<HTMLDivElement>(null);
@@ -456,10 +454,10 @@ export function Timeline() {
if (dragData.type === "text") {
// Always create new text track to avoid overlaps
useTimelineStore.getState().addTextToNewTrack(dragData);
addElementToNewTrack(dragData);
} else {
// Handle media items
const mediaItem = mediaItems.find(
const mediaItem = mediaFiles.find(
(item: any) => item.id === dragData.id
);
if (!mediaItem) {
@@ -467,7 +465,7 @@ export function Timeline() {
return;
}
useTimelineStore.getState().addMediaToNewTrack(mediaItem);
addElementToNewTrack(mediaItem);
}
} catch (error) {
console.error("Error parsing dropped item data:", error);
@@ -480,17 +478,12 @@ export function Timeline() {
return;
}
setIsProcessing(true);
setProgress(0);
try {
const processedItems = await processMediaFiles(
e.dataTransfer.files,
(p) => setProgress(p)
);
const processedItems = await processMediaFiles(e.dataTransfer.files);
for (const processedItem of processedItems) {
await addMediaItem(activeProject.id, processedItem);
const currentMediaItems = useMediaStore.getState().mediaItems;
const addedItem = currentMediaItems.find(
await addMediaFile(activeProject.id, processedItem);
const currentMediaFiles = mediaFiles;
const addedItem = currentMediaFiles.find(
(item) =>
item.name === processedItem.name && item.url === processedItem.url
);
@@ -516,9 +509,6 @@ export function Timeline() {
// Show error if file processing fails
console.error("Error processing external files:", error);
toast.error("Failed to process dropped files");
} finally {
setIsProcessing(false);
setProgress(0);
}
}
};
@@ -40,10 +40,8 @@ export function TimelineElement({
onElementMouseDown,
onElementClick,
}: TimelineElementProps) {
const { mediaItems } = useMediaStore();
const { mediaFiles } = useMediaStore();
const {
updateElementTrim,
updateElementDuration,
removeElementFromTrack,
removeElementFromTrackWithRipple,
dragState,
@@ -58,7 +56,7 @@ export function TimelineElement({
const mediaItem =
element.type === "media"
? mediaItems.find((item) => item.id === element.mediaId)
? mediaFiles.find((file) => file.id === element.mediaId)
: null;
const hasAudio = mediaItem?.type === "audio" || mediaItem?.type === "video";
@@ -67,8 +65,6 @@ export function TimelineElement({
element,
track,
zoomLevel,
onUpdateTrim: updateElementTrim,
onUpdateDuration: updateElementDuration,
});
const { requestRevealMedia } = useMediaPanelStore.getState();
@@ -190,7 +186,7 @@ export function TimelineElement({
}
// Render media element ->
const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
const mediaItem = mediaFiles.find((file) => file.id === element.mediaId);
if (!mediaItem) {
return (
<span className="text-xs text-foreground/80 truncate">
@@ -12,6 +12,7 @@ import {
canElementGoOnTrack,
} from "@/types/timeline";
import { usePlaybackStore } from "@/stores/playback-store";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import type {
TimelineElement as TimelineElementType,
DragData,
@@ -33,7 +34,7 @@ export function TimelineTrackContent({
zoomLevel: number;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
}) {
const { mediaItems } = useMediaStore();
const { mediaFiles } = useMediaStore();
const {
tracks,
addTrack,
@@ -537,7 +538,7 @@ export function TimelineTrackContent({
});
} else {
// Media elements
const mediaItem = mediaItems.find(
const mediaItem = mediaFiles.find(
(item) => item.id === dragData.id
);
if (mediaItem) {
@@ -890,29 +891,14 @@ export function TimelineTrackContent({
}
addElementToTrack(targetTrackId, {
type: "text",
name: dragData.name || "Text",
content: dragData.content || "Default Text",
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
...DEFAULT_TEXT_ELEMENT,
name: dragData.name || DEFAULT_TEXT_ELEMENT.name,
content: dragData.content || DEFAULT_TEXT_ELEMENT.content,
startTime: textSnappedTime,
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,
});
} else {
// Handle media items
const mediaItem = mediaItems.find((item) => item.id === dragData.id);
const mediaItem = mediaFiles.find((item) => item.id === dragData.id);
if (!mediaItem) {
toast.error("Media item not found");
@@ -1054,7 +1040,7 @@ export function TimelineTrackContent({
} else if (hasFiles) {
// External file drops
const { activeProject } = useProjectStore.getState();
const { addMediaItem } = useMediaStore.getState();
const { addMediaFile } = useMediaStore.getState();
const { addElementToTrack } = useTimelineStore.getState();
if (!activeProject) {
@@ -1066,9 +1052,9 @@ export function TimelineTrackContent({
processMediaFiles(e.dataTransfer.files)
.then(async (processedItems) => {
for (const processedItem of processedItems) {
await addMediaItem(activeProject.id, processedItem);
const currentMediaItems = useMediaStore.getState().mediaItems;
const addedItem = currentMediaItems.find(
await addMediaFile(activeProject.id, processedItem);
const currentMediaFiles = mediaFiles;
const addedItem = currentMediaFiles.find(
(item) =>
item.name === processedItem.name &&
item.url === processedItem.url
@@ -12,11 +12,12 @@ import { createPortal } from "react-dom";
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { usePlaybackStore } from "@/stores/playback-store";
import { DragData } from "@/types/timeline";
export interface DraggableMediaItemProps {
name: string;
preview: ReactNode;
dragData: Record<string, any>;
dragData: DragData;
onDragStart?: (e: React.DragEvent) => void;
onAddToTimeline?: (currentTime: number) => void;
aspectRatio?: number;