This commit is contained in:
Maze Winther
2026-01-15 11:05:17 +01:00
parent c19f085e48
commit deef784b76
182 changed files with 7787 additions and 18046 deletions
@@ -1,12 +1,13 @@
import { Button } from "@/components/ui/button";
import { PropertyGroup } from "../../properties-panel/property-item";
import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
import { Language, LanguageSelect } from "@/components/language-select";
import { LanguageSelect } from "@/components/language-select";
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 { useEditor } from "@/hooks/use-editor";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import { LANGUAGES } from "@/constants/captions-constants";
import { Loader2, Shield, Trash2, Upload } from "lucide-react";
import {
Dialog,
@@ -18,17 +19,11 @@ import {
} from "@/components/ui/dialog";
import { TextElement } from "@/types/timeline";
export const languages: Language[] = [
{ code: "US", name: "English" },
{ code: "ES", name: "Spanish" },
{ code: "IT", name: "Italian" },
{ code: "FR", name: "French" },
{ code: "DE", name: "German" },
{ code: "PT", name: "Portuguese" },
{ code: "RU", name: "Russian" },
{ code: "JP", name: "Japanese" },
{ code: "CN", name: "Chinese" },
];
interface TranscriptionSegment {
text: string;
start: number;
end: number;
}
const PRIVACY_DIALOG_KEY = "opencut-transcription-privacy-accepted";
@@ -40,9 +35,8 @@ export function Captions() {
const [showPrivacyDialog, setShowPrivacyDialog] = useState(false);
const [hasAcceptedPrivacy, setHasAcceptedPrivacy] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const { insertTrackAt, addElementToTrack } = useTimelineStore();
const editor = useEditor();
// Check if user has already accepted privacy on mount
useEffect(() => {
const hasAccepted = localStorage.getItem(PRIVACY_DIALOG_KEY) === "true";
setHasAcceptedPrivacy(hasAccepted);
@@ -57,12 +51,8 @@ export function Captions() {
const audioBlob = await extractTimelineAudio();
setProcessingStep("Encrypting audio...");
// Encrypt the audio with a random key (zero-knowledge)
const audioBuffer = await audioBlob.arrayBuffer();
const encryptionResult = await encryptWithRandomKey(audioBuffer);
// Convert encrypted data to blob for upload
const encryptedBlob = new Blob([encryptionResult.encryptedData]);
setProcessingStep("Uploading...");
@@ -79,7 +69,6 @@ export function Captions() {
const { uploadUrl, fileName } = await uploadResponse.json();
// Upload to R2
await fetch(uploadUrl, {
method: "PUT",
body: encryptedBlob,
@@ -87,7 +76,6 @@ export function Captions() {
setProcessingStep("Transcribing...");
// Call Modal transcription API with encryption parameters
const transcriptionResponse = await fetch("/api/transcribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -95,7 +83,6 @@ export function Captions() {
filename: fileName,
language:
selectedCountry === "auto" ? "auto" : selectedCountry.toLowerCase(),
// Send the raw encryption key and IV (zero-knowledge)
decryptionKey: arrayBufferToBase64(encryptionResult.key),
iv: arrayBufferToBase64(encryptionResult.iv),
}),
@@ -116,32 +103,23 @@ export function Captions() {
duration: number;
}> = [];
let globalEndTime = 0; // Track the end time of the last caption globally
let globalEndTime = 0;
segments.forEach((segment: any) => {
segments.forEach((segment: TranscriptionSegment) => {
const words = segment.text.trim().split(/\s+/);
const segmentDuration = segment.end - segment.start;
const wordsPerSecond = words.length / segmentDuration;
// Split into chunks of 2-4 words
const chunks: string[] = [];
for (let i = 0; i < words.length; i += 3) {
chunks.push(words.slice(i, i + 3).join(" "));
}
// Calculate timing for each chunk to place them sequentially
let chunkStartTime = segment.start;
chunks.forEach((chunk) => {
const chunkWords = chunk.split(/\s+/).length;
const chunkDuration = Math.max(0.8, chunkWords / wordsPerSecond); // Minimum 0.8s per chunk
let adjustedStartTime = chunkStartTime;
// Prevent overlapping: if this caption would start before the last one ends,
// start it right after the last one ends
if (adjustedStartTime < globalEndTime) {
adjustedStartTime = globalEndTime;
}
const chunkDuration = Math.max(0.8, chunkWords / wordsPerSecond);
const adjustedStartTime = Math.max(chunkStartTime, globalEndTime);
shortCaptions.push({
text: chunk,
@@ -149,28 +127,26 @@ export function Captions() {
duration: chunkDuration,
});
// Update global end time
globalEndTime = adjustedStartTime + chunkDuration;
// Next chunk starts when this one ends (for within-segment timing)
chunkStartTime += chunkDuration;
});
});
// Create a single track for all captions
const captionTrackId = insertTrackAt("text", 0);
const captionTrackId = editor.timeline.addTrack({ type: "text", index: 0 });
// Add all caption elements to the same track
shortCaptions.forEach((caption, index) => {
addElementToTrack(captionTrackId, {
...DEFAULT_TEXT_ELEMENT,
name: `Caption ${index + 1}`,
content: caption.text,
duration: caption.duration,
startTime: caption.startTime,
fontSize: 65, // Larger for captions
fontWeight: "bold", // Bold for captions
} as TextElement);
editor.timeline.addElementToTrack({
trackId: captionTrackId,
element: {
...DEFAULT_TEXT_ELEMENT,
name: `Caption ${index + 1}`,
content: caption.text,
duration: caption.duration,
startTime: caption.startTime,
fontSize: 65,
fontWeight: "bold",
} as TextElement,
});
});
console.log(
@@ -194,7 +170,7 @@ export function Captions() {
selectedCountry={selectedCountry}
onSelect={setSelectedCountry}
containerRef={containerRef}
languages={languages}
languages={LANGUAGES}
/>
</PropertyGroup>
@@ -216,7 +192,7 @@ export function Captions() {
}}
disabled={isProcessing}
>
{isProcessing && <Loader2 className="mr-1 h-4 w-4 animate-spin" />}
{isProcessing && <Loader2 className="mr-1 size-4 animate-spin" />}
{isProcessing ? processingStep : "Generate transcript"}
</Button>
@@ -224,8 +200,8 @@ export function Captions() {
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Audio Processing Notice
<Shield className="size-5" />
Audio processing notice
</DialogTitle>
<DialogDescription className="space-y-3">
<p>
@@ -235,7 +211,7 @@ export function Captions() {
<div className="space-y-2 pt-2">
<div className="flex items-start gap-2">
<Shield className="h-4 w-4 flex-shrink-0" />
<Shield className="size-4 flex-shrink-0" />
<span className="text-sm">
Zero-knowledge encryption - we cannot decrypt your files
even if we wanted to
@@ -243,7 +219,7 @@ export function Captions() {
</div>
<div className="flex items-start gap-2">
<Shield className="h-4 w-4 flex-shrink-0" />
<Shield className="size-4 flex-shrink-0" />
<span className="text-sm">
Encryption keys generated randomly in your browser, never
stored anywhere
@@ -251,7 +227,7 @@ export function Captions() {
</div>
<div className="flex items-start gap-2">
<Upload className="h-4 w-4 flex-shrink-0" />
<Upload className="size-4 flex-shrink-0" />
<span className="text-sm">
Audio encrypted before upload - raw audio never leaves
your device
@@ -259,7 +235,7 @@ export function Captions() {
</div>
<div className="flex items-start gap-2">
<Trash2 className="h-4 w-4 flex-shrink-0" />
<Trash2 className="size-4 flex-shrink-0" />
<span className="text-sm">
Everything permanently deleted within seconds after
transcription
@@ -292,7 +268,7 @@ export function Captions() {
}}
disabled={isProcessing}
>
Continue & Generate Captions
Continue & generate captions
</Button>
</DialogFooter>
</DialogContent>
@@ -1,9 +1,6 @@
"use client";
import { useFileUpload } from "@/hooks/use-file-upload";
import { processMediaFiles } from "@/lib/media-processing-utils";
import { useMediaStore } from "@/stores/media-store";
import { MediaFile } from "@/types/assets";
import { useState, useMemo } from "react";
import {
ArrowDown01,
CloudUpload,
@@ -14,9 +11,16 @@ import {
Music,
Video,
} from "lucide-react";
import { useState, useMemo } from "react";
import { useRevealItem } from "@/hooks/use-reveal-item";
import { toast } from "sonner";
import { useEditor } from "@/hooks/use-editor";
import { useFileUpload } from "@/hooks/use-file-upload";
import { useRevealItem } from "@/hooks/use-reveal-item";
import { processMediaAssets } from "@/lib/media-processing-utils";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { canElementGoOnTrack } from "@/lib/timeline/track-utils";
import { wouldElementOverlap } from "@/lib/timeline/element-utils";
import type { MediaAsset } from "@/types/assets";
import type { CreateTimelineElement, TrackType } from "@/types/timeline";
import { Button } from "@/components/ui/button";
import { MediaDragOverlay } from "@/components/editor/assets-panel/drag-overlay";
import {
@@ -31,9 +35,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { DraggableItem } from "@/components/ui/draggable-item";
import {
Tooltip,
TooltipContent,
@@ -41,50 +43,28 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { usePanelStore } from "@/stores/panel-store";
import { useAssetsPanelStore } from "../../../../stores/assets-panel-store";
function MediaItemWithContextMenu({
item,
children,
onRemove,
}: {
item: MediaFile;
children: React.ReactNode;
onRemove: (e: React.MouseEvent, id: string) => Promise<void>;
}) {
return (
<ContextMenu>
<ContextMenuTrigger>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>Export clips</ContextMenuItem>
<ContextMenuItem
variant="destructive"
onClick={(e) => onRemove(e, item.id)}
>
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
export function MediaView() {
const { mediaFiles, addMediaFile, removeMediaFile } = useMediaStore();
const { activeProject } = useProjectStore();
const editor = useEditor();
const mediaFiles = editor.media.getAssets();
const activeProject = editor.project.getActive();
const { mediaViewMode, setMediaViewMode } = usePanelStore();
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">(
"name",
);
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const { highlightMediaId, clearHighlight } = useAssetsPanelStore();
const { highlightedId, registerElement } = useRevealItem(
highlightMediaId,
clearHighlight,
);
const processFiles = async (files: FileList | File[]) => {
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">(
"name",
);
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const processFiles = async ({ files }: { files: FileList }) => {
if (!files || files.length === 0) return;
if (!activeProject) {
toast.error("No active project");
@@ -94,12 +74,16 @@ export function MediaView() {
setIsProcessing(true);
setProgress(0);
try {
const processedItems = await processMediaFiles({
files: files as FileList,
onProgress: (p: { progress: number }) => setProgress(p.progress),
const processedAssets = await processMediaAssets({
files,
onProgress: (progress: { progress: number }) =>
setProgress(progress.progress),
});
for (const item of processedItems) {
await addMediaFile(activeProject.id, item);
for (const asset of processedAssets) {
await editor.media.addMediaAsset({
projectId: activeProject.metadata.id,
asset,
});
}
} catch (error) {
console.error("Error processing files:", error);
@@ -114,32 +98,76 @@ export function MediaView() {
useFileUpload({
accept: "image/*,video/*,audio/*",
multiple: true,
onFilesSelected: processFiles,
onFilesSelected: (files) => processFiles({ files }),
});
const handleRemove = async (e: React.MouseEvent, id: string) => {
e.stopPropagation();
const handleRemove = async ({
event,
id,
}: {
event: React.MouseEvent;
id: string;
}) => {
event.stopPropagation();
if (!activeProject) {
toast.error("No active project");
return;
}
await removeMediaFile(activeProject.id, id);
await editor.media.removeMediaAsset({
projectId: activeProject.metadata.id,
id,
});
};
const formatDuration = (duration: number) => {
// Format seconds as mm:ss
const min = Math.floor(duration / 60);
const sec = Math.floor(duration % 60);
return `${min}:${sec.toString().padStart(2, "0")}`;
const addElementAtTime = ({
asset,
startTime,
}: {
asset: MediaAsset;
startTime: number;
}): boolean => {
const element = createElementFromMedia({ asset, startTime });
const trackType = getTrackTypeForMedia({ mediaType: asset.type });
const tracks = editor.timeline.getTracks();
const duration =
asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
const existingTrack = tracks.find((track) => {
if (
!canElementGoOnTrack({
elementType: element.type,
trackType: track.type,
})
) {
return false;
}
return !wouldElementOverlap({
elements: track.elements,
startTime,
endTime: startTime + duration,
});
});
if (existingTrack) {
editor.timeline.addElementToTrack({
trackId: existingTrack.id,
element,
});
return true;
}
const newTrackId = editor.timeline.addTrack({ type: trackType });
editor.timeline.addElementToTrack({
trackId: newTrackId,
element,
});
return true;
};
const filteredMediaItems = useMemo(() => {
let filtered = mediaFiles.filter((item) => {
if (item.ephemeral) return false;
return true;
});
const filtered = mediaFiles.filter((item) => !item.ephemeral);
filtered.sort((a, b) => {
let valueA: string | number;
@@ -178,84 +206,16 @@ export function MediaView() {
const previews = new Map<string, React.ReactNode>();
filteredMediaItems.forEach((item) => {
let preview: React.ReactNode;
if (item.type === "image") {
preview = (
<div className="flex h-full w-full items-center justify-center">
<img
src={item.url}
alt={item.name}
className="max-h-full w-full object-cover"
loading="lazy"
/>
</div>
);
} else if (item.type === "video") {
if (item.thumbnailUrl) {
preview = (
<div className="relative h-full w-full">
<img
src={item.thumbnailUrl}
alt={item.name}
className="h-full w-full rounded object-cover"
loading="lazy"
/>
<div className="absolute inset-0 flex items-center justify-center rounded bg-black/20">
<Video className="h-6 w-6 text-white drop-shadow-md" />
</div>
{item.duration && (
<div className="absolute bottom-1 right-1 rounded bg-black/70 px-1 text-xs text-white">
{formatDuration(item.duration)}
</div>
)}
</div>
);
} else {
preview = (
<div className="bg-muted/30 text-muted-foreground flex h-full w-full flex-col items-center justify-center rounded">
<Video className="mb-1 h-6 w-6" />
<span className="text-xs">Video</span>
{item.duration && (
<span className="text-xs opacity-70">
{formatDuration(item.duration)}
</span>
)}
</div>
);
}
} else if (item.type === "audio") {
preview = (
<div className="bg-linear-to-br text-muted-foreground flex h-full w-full flex-col items-center justify-center rounded border border-green-500/20 from-green-500/20 to-emerald-500/20">
<Music className="mb-1 h-6 w-6" />
<span className="text-xs">Audio</span>
{item.duration && (
<span className="text-xs opacity-70">
{formatDuration(item.duration)}
</span>
)}
</div>
);
} else {
preview = (
<div className="bg-muted/30 text-muted-foreground flex h-full w-full flex-col items-center justify-center rounded">
<Image className="h-6 w-6" />
<span className="mt-1 text-xs">Unknown</span>
</div>
);
}
previews.set(item.id, preview);
previews.set(item.id, <MediaPreview item={item} />);
});
return previews;
}, [filteredMediaItems]);
const renderPreview = (item: MediaFile) => previewComponents.get(item.id);
const renderPreview = (item: MediaAsset) => previewComponents.get(item.id);
return (
<>
{/* native file picker, visually hidden */}
<input {...fileInputProps} />
<div
@@ -265,16 +225,15 @@ export function MediaView() {
<div className="bg-panel p-3 pb-2">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="lg"
variant="foreground"
onClick={openFilePicker}
disabled={isProcessing}
className="!bg-background h-9 flex-1 items-center justify-center px-4 opacity-100 transition-opacity hover:opacity-75"
className="w-full"
>
{isProcessing ? (
<Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="size-4 animate-spin" />
) : (
<CloudUpload className="h-4 w-4" />
<CloudUpload className="size-4" />
)}
<span>Upload</span>
</Button>
@@ -328,70 +287,70 @@ export function MediaView() {
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
if (sortBy === "name") {
<SortMenuItem
label="Name"
sortKey="name"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={({ key }) => {
if (sortBy === key) {
setSortOrder(
sortOrder === "asc" ? "desc" : "asc",
);
} else {
setSortBy("name");
setSortBy(key);
setSortOrder("asc");
}
}}
>
Name{" "}
{sortBy === "name" &&
(sortOrder === "asc" ? "↑" : "↓")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
if (sortBy === "type") {
/>
<SortMenuItem
label="Type"
sortKey="type"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={({ key }) => {
if (sortBy === key) {
setSortOrder(
sortOrder === "asc" ? "desc" : "asc",
);
} else {
setSortBy("type");
setSortBy(key);
setSortOrder("asc");
}
}}
>
Type{" "}
{sortBy === "type" &&
(sortOrder === "asc" ? "↑" : "↓")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
if (sortBy === "duration") {
/>
<SortMenuItem
label="Duration"
sortKey="duration"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={({ key }) => {
if (sortBy === key) {
setSortOrder(
sortOrder === "asc" ? "desc" : "asc",
);
} else {
setSortBy("duration");
setSortBy(key);
setSortOrder("asc");
}
}}
>
Duration{" "}
{sortBy === "duration" &&
(sortOrder === "asc" ? "↑" : "↓")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
if (sortBy === "size") {
/>
<SortMenuItem
label="File size"
sortKey="size"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={({ key }) => {
if (sortBy === key) {
setSortOrder(
sortOrder === "asc" ? "desc" : "asc",
);
} else {
setSortBy("size");
setSortBy(key);
setSortOrder("asc");
}
}}
>
File Size{" "}
{sortBy === "size" &&
(sortOrder === "asc" ? "↑" : "↓")}
</DropdownMenuItem>
/>
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent>
@@ -419,17 +378,19 @@ export function MediaView() {
/>
) : mediaViewMode === "grid" ? (
<GridView
filteredMediaItems={filteredMediaItems}
items={filteredMediaItems}
renderPreview={renderPreview}
handleRemove={handleRemove}
onRemove={handleRemove}
onAddToTimeline={addElementAtTime}
highlightedId={highlightedId}
registerElement={registerElement}
/>
) : (
<ListView
filteredMediaItems={filteredMediaItems}
items={filteredMediaItems}
renderPreview={renderPreview}
handleRemove={handleRemove}
onRemove={handleRemove}
onAddToTimeline={addElementAtTime}
highlightedId={highlightedId}
registerElement={registerElement}
/>
@@ -441,21 +402,52 @@ export function MediaView() {
);
}
function MediaItemWithContextMenu({
item,
children,
onRemove,
}: {
item: MediaAsset;
children: React.ReactNode;
onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
}) {
return (
<ContextMenu>
<ContextMenuTrigger>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>Export clips</ContextMenuItem>
<ContextMenuItem
variant="destructive"
onClick={(event) => onRemove({ event, id: item.id })}
>
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
function GridView({
filteredMediaItems,
items,
renderPreview,
handleRemove,
onRemove,
onAddToTimeline,
highlightedId,
registerElement,
}: {
filteredMediaItems: MediaFile[];
renderPreview: (item: MediaFile) => React.ReactNode;
handleRemove: (e: React.MouseEvent, id: string) => Promise<void>;
items: MediaAsset[];
renderPreview: (item: MediaAsset) => React.ReactNode;
onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
onAddToTimeline: ({
asset,
startTime,
}: {
asset: MediaAsset;
startTime: number;
}) => boolean;
highlightedId: string | null;
registerElement: (id: string, element: HTMLElement | null) => void;
}) {
const { addElementAtTime } = useTimelineStore();
return (
<div
className="grid gap-2"
@@ -463,22 +455,23 @@ function GridView({
gridTemplateColumns: "repeat(auto-fill, 160px)",
}}
>
{filteredMediaItems.map((item) => (
{items.map((item) => (
<div key={item.id} ref={(el) => registerElement(item.id, el)}>
<MediaItemWithContextMenu item={item} onRemove={handleRemove}>
<DraggableMediaItem
<MediaItemWithContextMenu item={item} onRemove={onRemove}>
<DraggableItem
name={item.name}
preview={renderPreview(item)}
dragData={{
id: item.id,
type: item.type,
type: "media",
mediaType: item.type,
name: item.name,
}}
showPlusOnDrag={false}
onAddToTimeline={(currentTime) =>
addElementAtTime(item, currentTime)
shouldShowPlusOnDrag={false}
onAddToTimeline={({ currentTime }) =>
onAddToTimeline({ asset: item, startTime: currentTime })
}
rounded={false}
isRounded={false}
variant="card"
isHighlighted={highlightedId === item.id}
/>
@@ -490,36 +483,43 @@ function GridView({
}
function ListView({
filteredMediaItems,
items,
renderPreview,
handleRemove,
onRemove,
onAddToTimeline,
highlightedId,
registerElement,
}: {
filteredMediaItems: MediaFile[];
renderPreview: (item: MediaFile) => React.ReactNode;
handleRemove: (e: React.MouseEvent, id: string) => Promise<void>;
items: MediaAsset[];
renderPreview: (item: MediaAsset) => React.ReactNode;
onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
onAddToTimeline: ({
asset,
startTime,
}: {
asset: MediaAsset;
startTime: number;
}) => boolean;
highlightedId: string | null;
registerElement: (id: string, element: HTMLElement | null) => void;
}) {
const { addElementAtTime } = useTimelineStore();
return (
<div className="space-y-1">
{filteredMediaItems.map((item) => (
{items.map((item) => (
<div key={item.id} ref={(el) => registerElement(item.id, el)}>
<MediaItemWithContextMenu item={item} onRemove={handleRemove}>
<DraggableMediaItem
<MediaItemWithContextMenu item={item} onRemove={onRemove}>
<DraggableItem
name={item.name}
preview={renderPreview(item)}
dragData={{
id: item.id,
type: item.type,
type: "media",
mediaType: item.type,
name: item.name,
}}
showPlusOnDrag={false}
onAddToTimeline={(currentTime) =>
addElementAtTime(item, currentTime)
shouldShowPlusOnDrag={false}
onAddToTimeline={({ currentTime }) =>
onAddToTimeline({ asset: item, startTime: currentTime })
}
variant="compact"
isHighlighted={highlightedId === item.id}
@@ -530,3 +530,176 @@ function ListView({
</div>
);
}
function MediaPreview({ item }: { item: MediaAsset }) {
const formatDuration = (duration: number) => {
const min = Math.floor(duration / 60);
const sec = Math.floor(duration % 60);
return `${min}:${sec.toString().padStart(2, "0")}`;
};
if (item.type === "image") {
return (
<div className="flex size-full items-center justify-center">
<img
src={item.url}
alt={item.name}
className="max-h-full w-full object-cover"
loading="lazy"
/>
</div>
);
}
if (item.type === "video") {
if (item.thumbnailUrl) {
return (
<div className="relative size-full">
<img
src={item.thumbnailUrl}
alt={item.name}
className="size-full rounded object-cover"
loading="lazy"
/>
<div className="absolute inset-0 flex items-center justify-center rounded bg-black/20">
<Video className="size-6 text-white drop-shadow-md" />
</div>
{item.duration && (
<div className="absolute bottom-1 right-1 rounded bg-black/70 px-1 text-xs text-white">
{formatDuration(item.duration)}
</div>
)}
</div>
);
}
return (
<div className="bg-muted/30 text-muted-foreground flex size-full flex-col items-center justify-center rounded">
<Video className="mb-1 size-6" />
<span className="text-xs">Video</span>
{item.duration && (
<span className="text-xs opacity-70">
{formatDuration(item.duration)}
</span>
)}
</div>
);
}
if (item.type === "audio") {
return (
<div className="bg-linear-to-br text-muted-foreground flex size-full flex-col items-center justify-center rounded border border-green-500/20 from-green-500/20 to-emerald-500/20">
<Music className="mb-1 size-6" />
<span className="text-xs">Audio</span>
{item.duration && (
<span className="text-xs opacity-70">
{formatDuration(item.duration)}
</span>
)}
</div>
);
}
return (
<div className="bg-muted/30 text-muted-foreground flex size-full flex-col items-center justify-center rounded">
<Image className="size-6" />
<span className="mt-1 text-xs">Unknown</span>
</div>
);
}
function SortMenuItem({
label,
sortKey,
currentSortBy,
currentSortOrder,
onSort,
}: {
label: string;
sortKey: "name" | "type" | "duration" | "size";
currentSortBy: string;
currentSortOrder: "asc" | "desc";
onSort: ({ key }: { key: "name" | "type" | "duration" | "size" }) => void;
}) {
const isActive = currentSortBy === sortKey;
const arrow = isActive ? (currentSortOrder === "asc" ? "↑" : "↓") : "";
return (
<DropdownMenuItem onClick={() => onSort({ key: sortKey })}>
{label} {arrow}
</DropdownMenuItem>
);
}
function getTrackTypeForMedia({
mediaType,
}: {
mediaType: MediaAsset["type"];
}): TrackType {
switch (mediaType) {
case "video":
case "image":
return "video";
case "audio":
return "audio";
default:
return "video";
}
}
function createElementFromMedia({
asset,
startTime,
}: {
asset: MediaAsset;
startTime: number;
}): CreateTimelineElement {
const duration =
asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
switch (asset.type) {
case "video":
return {
type: "video",
name: asset.name,
mediaId: asset.id,
startTime,
duration,
trimStart: 0,
trimEnd: 0,
muted: false,
hidden: false,
transform: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 },
opacity: 1,
};
case "image":
return {
type: "image",
name: asset.name,
mediaId: asset.id,
startTime,
duration,
trimStart: 0,
trimEnd: 0,
hidden: false,
transform: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 },
opacity: 1,
};
case "audio":
return {
type: "audio",
sourceType: "upload",
name: asset.name,
mediaId: asset.id,
startTime,
duration,
trimStart: 0,
trimEnd: 0,
volume: 1,
muted: false,
buffer: new AudioBuffer({ length: 1, sampleRate: 44100 }),
};
default:
throw new Error(`Unsupported media type: ${asset.type}`);
}
}
@@ -15,11 +15,11 @@ import {
PropertyGroup,
} from "../../properties-panel/property-item";
import {
DEFAULT_CANVAS_SIZE,
FPS_PRESETS,
BLUR_INTENSITY_PRESETS,
} from "@/constants/editor-constants";
import { useProjectStore } from "@/stores/project-store";
DEFAULT_BLUR_INTENSITY,
DEFAULT_COLOR,
} from "@/constants/project-constants";
import { useEditorStore } from "@/stores/editor-store";
import { dimensionToAspectRatio } from "@/lib/editor-utils";
import Image from "next/image";
@@ -31,6 +31,8 @@ import { useMemo, memo, useCallback } from "react";
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { useEditor } from "@/hooks/use-editor";
import type { TProject } from "@/types/project";
export function SettingsView() {
return <ProjectSettingsTabs />;
@@ -66,7 +68,7 @@ function ProjectSettingsTabs() {
</Button>
</div>
{/* Another UI, looks so beautiful I don't wanna remove it */}
{/* another ui, looks so beautiful I don't wanna remove it */}
{/* <div className="flex flex-col justify-center items-center pb-5 sticky bottom-0">
<Button className="w-fit h-auto gap-1.5 px-3.5 py-1.5 bg-foreground hover:bg-foreground/85 text-background rounded-full">
<span className="text-sm">Custom</span>
@@ -85,17 +87,19 @@ function ProjectSettingsTabs() {
function getCurrentCanvasSize({
activeProject,
}: {
activeProject: { canvasSize: { width: number; height: number } } | null;
activeProject: TProject;
}) {
const { canvasSize } = activeProject.settings;
return {
width: activeProject?.canvasSize.width || DEFAULT_CANVAS_SIZE.width,
height: activeProject?.canvasSize.height || DEFAULT_CANVAS_SIZE.height,
width: canvasSize.width,
height: canvasSize.height,
};
}
function ProjectInfoView() {
const { activeProject, updateProjectFps, updateCanvasSize } =
useProjectStore();
const editor = useEditor();
const activeProject = editor.project.getActive();
const { canvasPresets } = useEditorStore();
const findPresetIndexByAspectRatio = ({
@@ -131,15 +135,13 @@ function ProjectInfoView() {
const index = parseInt(value, 10);
const preset = canvasPresets[index];
if (preset) {
updateCanvasSize({
size: preset,
});
editor.project.updateSettings({ settings: { canvasSize: preset } });
}
};
const handleFpsChange = (value: string) => {
const fps = parseFloat(value);
updateProjectFps(fps);
editor.project.updateSettings({ settings: { fps } });
};
return (
@@ -147,7 +149,7 @@ function ProjectInfoView() {
<PropertyItem direction="column">
<PropertyItemLabel>Name</PropertyItemLabel>
<PropertyItemValue>
{activeProject?.name || "Untitled project"}
{activeProject.metadata.name}
</PropertyItemValue>
</PropertyItem>
@@ -182,7 +184,7 @@ function ProjectInfoView() {
<PropertyItemLabel>Frame rate</PropertyItemLabel>
<PropertyItemValue>
<Select
value={(activeProject?.fps || 30).toString()}
value={activeProject.settings.fps.toString()}
onValueChange={handleFpsChange}
>
<SelectTrigger className="bg-panel-accent">
@@ -249,7 +251,7 @@ const BackgroundPreviews = memo(
backgrounds: string[];
currentBackgroundColor: string;
isColorBackground: boolean;
handleColorSelect: (bg: string) => void;
handleColorSelect: ({ bg }: { bg: string }) => void;
useBackgroundColor?: boolean;
}) => {
return useMemo(
@@ -273,7 +275,7 @@ const BackgroundPreviews = memo(
backgroundRepeat: "no-repeat",
}
}
onClick={() => handleColorSelect(bg)}
onClick={() => handleColorSelect({ bg })}
/>
)),
[
@@ -290,28 +292,40 @@ const BackgroundPreviews = memo(
BackgroundPreviews.displayName = "BackgroundPreviews";
function BackgroundView() {
const { activeProject, updateBackgroundType } = useProjectStore();
const editor = useEditor();
const activeProject = editor.project.getActive();
const blurLevels = useMemo(() => BLUR_INTENSITY_PRESETS, []);
const handleBlurSelect = useCallback(
async ({ blurIntensity }: { blurIntensity: number }) => {
await updateBackgroundType("blur", { blurIntensity });
await editor.project.updateSettings({
settings: { background: { type: "blur", blurIntensity } },
});
},
[updateBackgroundType],
[editor.project],
);
const handleColorSelect = useCallback(
async (color: string) => {
await updateBackgroundType("color", { backgroundColor: color });
async ({ color }: { color: string }) => {
await editor.project.updateSettings({
settings: { background: { type: "color", color } },
});
},
[updateBackgroundType],
[editor.project],
);
const currentBlurIntensity = activeProject?.blurIntensity || 8;
const isBlurBackground = activeProject?.backgroundType === "blur";
const currentBackgroundColor = activeProject?.backgroundColor || "#000000";
const isColorBackground = activeProject?.backgroundType === "color";
const currentBlurIntensity =
activeProject.settings.background.type === "blur"
? activeProject.settings.background.blurIntensity
: DEFAULT_BLUR_INTENSITY;
const currentBackgroundColor =
activeProject.settings.background.type === "color"
? activeProject.settings.background.color
: DEFAULT_COLOR;
const isBlurBackground = activeProject.settings.background.type === "blur";
const isColorBackground = activeProject.settings.background.type === "color";
const blurPreviews = useMemo(
() =>
@@ -341,7 +355,7 @@ function BackgroundView() {
backgrounds={colors}
currentBackgroundColor={currentBackgroundColor}
isColorBackground={isColorBackground}
handleColorSelect={handleColorSelect}
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
useBackgroundColor={true}
/>
</div>
@@ -353,7 +367,7 @@ function BackgroundView() {
backgrounds={patternCraftGradients}
currentBackgroundColor={currentBackgroundColor}
isColorBackground={isColorBackground}
handleColorSelect={handleColorSelect}
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
/>
</div>
</PropertyGroup>
@@ -364,7 +378,7 @@ function BackgroundView() {
backgrounds={syntaxUIGradients}
currentBackgroundColor={currentBackgroundColor}
isColorBackground={isColorBackground}
handleColorSelect={handleColorSelect}
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
/>
</div>
</PropertyGroup>
@@ -97,7 +97,7 @@ function SoundEffectsView() {
loadMore,
hasNextPage,
isLoadingMore,
} = useSoundSearch(searchQuery, showCommercialOnly);
} = useSoundSearch({ query: searchQuery, commercialOnly: showCommercialOnly });
// Audio playback state
const [playingId, setPlayingId] = useState<number | null>(null);
@@ -120,8 +120,8 @@ function SoundEffectsView() {
const fetchTopSounds = async () => {
try {
if (!ignore) {
setLoading(true);
setError(null);
setLoading({ loading: true });
setError({ error: null });
}
const response = await fetch(
@@ -134,23 +134,24 @@ function SoundEffectsView() {
}
const data = await response.json();
setTopSoundEffects(data.results);
setHasLoaded(true);
setTopSoundEffects({ sounds: data.results });
setHasLoaded({ loaded: true });
setCurrentPage(1);
setHasNextPage(!!data.next);
setTotalCount(data.count);
setCurrentPage({ page: 1 });
setHasNextPage({ hasNext: !!data.next });
setTotalCount({ count: data.count });
}
} catch (error) {
if (!ignore) {
console.error("Failed to fetch top sounds:", error);
setError(
error instanceof Error ? error.message : "Failed to load sounds"
);
setError({
error:
error instanceof Error ? error.message : "Failed to load sounds",
});
}
} finally {
if (!ignore) {
setLoading(false);
setLoading({ loading: false });
}
}
};
@@ -183,7 +184,7 @@ function SoundEffectsView() {
const handleScrollWithPosition = (event: React.UIEvent<HTMLDivElement>) => {
const { scrollTop } = event.currentTarget;
setScrollPosition(scrollTop);
setScrollPosition({ position: scrollTop });
handleScroll(event);
};
@@ -227,9 +228,9 @@ function SoundEffectsView() {
className="bg-panel-accent w-full"
containerClassName="w-full"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onChange={(e) => setSearchQuery({ query: e.target.value })}
showClearIcon
onClear={() => setSearchQuery("")}
onClear={() => setSearchQuery({ query: "" })}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -244,7 +245,7 @@ function SoundEffectsView() {
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuCheckboxItem
checked={showCommercialOnly}
onCheckedChange={toggleCommercialFilter}
onCheckedChange={() => toggleCommercialFilter()}
>
Show only commercially licensed
</DropdownMenuCheckboxItem>
@@ -278,8 +279,8 @@ function SoundEffectsView() {
sound={sound}
isPlaying={playingId === sound.id}
onPlay={() => playSound(sound)}
isSaved={isSoundSaved(sound.id)}
onToggleSaved={() => toggleSavedSound(sound)}
isSaved={isSoundSaved({ soundId: sound.id })}
onToggleSaved={() => toggleSavedSound({ soundEffect: sound })}
/>
))}
{!isLoading && !isSearching && displayedSounds.length === 0 && (
@@ -464,9 +465,9 @@ function SavedSoundsView() {
sound={convertToSoundEffect(sound)}
isPlaying={playingId === sound.id}
onPlay={() => playSound(sound)}
isSaved={isSoundSaved(sound.id)}
isSaved={isSoundSaved({ soundId: sound.id })}
onToggleSaved={() =>
toggleSavedSound(convertToSoundEffect(sound))
toggleSavedSound({ soundEffect: convertToSoundEffect(sound) })
}
/>
))}
@@ -509,7 +510,7 @@ function AudioItem({
const handleAddToTimeline = async (e: React.MouseEvent) => {
e.stopPropagation();
await addSoundToTimeline(sound);
await addSoundToTimeline({ sound });
};
return (
@@ -32,9 +32,10 @@ import {
} from "@/lib/iconify-api";
import { cn } from "@/lib/utils";
import Image from "next/image";
import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { DraggableItem } from "@/components/ui/draggable-item";
import { InputWithBack } from "@/components/ui/input-with-back";
import { StickerCategory } from "@/stores/stickers-store";
import type { StickerCategory } from "@/types/stickers";
import { STICKER_CATEGORIES } from "@/constants/stickers-constants";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
export function StickersView() {
@@ -44,8 +45,8 @@ export function StickersView() {
<BaseView
value={selectedCategory}
onValueChange={(v) => {
if (["all", "general", "brands", "emoji"].includes(v)) {
setSelectedCategory(v as StickerCategory);
if (STICKER_CATEGORIES.includes(v as StickerCategory)) {
setSelectedCategory({ category: v as StickerCategory });
}
}}
tabs={[
@@ -74,7 +75,7 @@ export function StickersView() {
content: <StickersContentView category="emoji" />,
},
]}
className="flex flex-col h-full p-0 overflow-hidden"
className="flex h-full flex-col overflow-hidden p-0"
/>
);
}
@@ -124,7 +125,7 @@ function CollectionGrid({
total: number;
category?: string;
}>;
onSelectCollection: (prefix: string) => void;
onSelectCollection: ({ prefix }: { prefix: string }) => void;
}) {
return (
<div className="grid grid-cols-1 gap-2">
@@ -133,7 +134,7 @@ function CollectionGrid({
key={collection.prefix}
title={collection.name}
subtitle={`${collection.total.toLocaleString()} icons${collection.category ? `${collection.category}` : ""}`}
onClick={() => onSelectCollection(collection.prefix)}
onClick={() => onSelectCollection({ prefix: collection.prefix })}
/>
))}
</div>
@@ -142,14 +143,14 @@ function CollectionGrid({
function EmptyView({ message }: { message: string }) {
return (
<div className="bg-panel h-full p-4 flex flex-col items-center justify-center gap-3">
<div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
<StickerIcon
className="w-10 h-10 text-muted-foreground"
className="text-muted-foreground h-10 w-10"
strokeWidth={1.5}
/>
<div className="flex flex-col gap-2 text-center">
<p className="text-lg font-medium">No stickers found</p>
<p className="text-sm text-muted-foreground text-balance">{message}</p>
<p className="text-muted-foreground text-balance text-sm">{message}</p>
</div>
</div>
);
@@ -229,9 +230,9 @@ function StickersContentView({ category }: { category: StickerCategory }) {
useEffect(() => {
const timer = setTimeout(() => {
if (localSearchQuery !== searchQuery) {
setSearchQuery(localSearchQuery);
setSearchQuery({ query: localSearchQuery });
if (localSearchQuery.trim()) {
searchStickers(localSearchQuery);
searchStickers({ query: localSearchQuery });
}
}
}, 500);
@@ -241,7 +242,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
const handleAddSticker = async (iconName: string) => {
try {
await addStickerToTimeline(iconName);
await addStickerToTimeline({ iconName });
} catch (error) {
console.error("Failed to add sticker:", error);
toast.error("Failed to add sticker to timeline");
@@ -259,8 +260,8 @@ function StickersContentView({ category }: { category: StickerCategory }) {
if (currentCollection.uncategorized) {
icons.push(
...currentCollection.uncategorized.map(
(name) => `${currentCollection.prefix}:${name}`
)
(name) => `${currentCollection.prefix}:${name}`,
),
);
}
@@ -268,8 +269,8 @@ function StickersContentView({ category }: { category: StickerCategory }) {
Object.values(currentCollection.categories).forEach((categoryIcons) => {
icons.push(
...categoryIcons.map(
(name) => `${currentCollection.prefix}:${name}`
)
(name) => `${currentCollection.prefix}:${name}`,
),
);
});
}
@@ -293,13 +294,13 @@ function StickersContentView({ category }: { category: StickerCategory }) {
}, [isInCollection]);
return (
<div className="flex flex-col gap-5 mt-1 h-full p-4">
<div className="mt-1 flex h-full flex-col gap-5 p-4">
<div className="space-y-3">
<InputWithBack
isExpanded={isInCollection}
setIsExpanded={(expanded) => {
if (!expanded && isInCollection) {
setSelectedCollection(null);
setSelectedCollection({ collection: null });
}
}}
placeholder={
@@ -319,24 +320,24 @@ function StickersContentView({ category }: { category: StickerCategory }) {
<div className="relative h-full overflow-hidden">
<ScrollArea
className="flex-1 h-full"
className="h-full flex-1"
ref={scrollAreaRef}
onScrollCapture={handleScroll}
>
<div className="flex flex-col gap-4 h-full">
<div className="flex h-full flex-col gap-4">
{recentStickers.length > 0 && viewMode === "browse" && (
<div className="h-full">
<div className="flex items-center gap-2 mb-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<div className="mb-2 flex items-center gap-2">
<Clock className="text-muted-foreground h-4 w-4" />
<span className="text-sm font-medium">Recent</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={clearRecentStickers}
className="ml-auto h-5 w-5 p-0 rounded hover:bg-accent flex items-center justify-center"
className="hover:bg-accent ml-auto flex h-5 w-5 items-center justify-center rounded p-0"
>
<X className="h-3 w-3 text-muted-foreground" />
<X className="text-muted-foreground h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent>
@@ -358,7 +359,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
<div className="h-full">
{isLoadingCollection ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
) : showCollectionItems ? (
<StickerGrid
@@ -368,7 +369,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
/>
) : (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
)}
</div>
@@ -378,12 +379,12 @@ function StickersContentView({ category }: { category: StickerCategory }) {
<div className="h-full">
{isSearching ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
) : searchResults?.icons.length ? (
<>
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-muted-foreground">
<div className="mb-3 flex items-center justify-between">
<span className="text-muted-foreground text-sm">
{searchResults.total} results
</span>
</div>
@@ -395,7 +396,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
/>
</>
) : searchQuery ? (
<div className="flex flex-col items-center justify-center py-8 gap-3">
<div className="flex flex-col items-center justify-center gap-3 py-8">
<EmptyView
message={`No stickers found for "${searchQuery}"`}
/>
@@ -405,11 +406,11 @@ function StickersContentView({ category }: { category: StickerCategory }) {
onClick={() => {
const q = localSearchQuery || searchQuery;
if (q) {
setSearchQuery(q);
setSearchQuery({ query: q });
}
setSelectedCategory("all");
setSelectedCategory({ category: "all" });
if (q) {
searchStickers(q);
searchStickers({ query: q });
}
}}
>
@@ -422,26 +423,20 @@ function StickersContentView({ category }: { category: StickerCategory }) {
)}
{viewMode === "browse" && !selectedCollection && (
<div className="space-y-4 h-full">
<div className="h-full space-y-4">
{isLoadingCollections ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
) : (
<>
{category !== "all" && (
<div className="h-full">
<h3 className="text-sm font-medium mb-2">
Popular{" "}
{category === "general"
? "Icon Sets"
: category === "brands"
? "Brand Icons"
: "Emoji Sets"}
</h3>
<CollectionGrid
collections={filteredCollections}
onSelectCollection={setSelectedCollection}
onSelectCollection={({ prefix }) =>
setSelectedCollection({ collection: prefix })
}
/>
</div>
)}
@@ -451,9 +446,11 @@ function StickersContentView({ category }: { category: StickerCategory }) {
<CollectionGrid
collections={filteredCollections.slice(
0,
collectionsToShow
collectionsToShow,
)}
onSelectCollection={setSelectedCollection}
onSelectCollection={({ prefix }) =>
setSelectedCollection({ collection: prefix })
}
/>
</div>
)}
@@ -478,12 +475,12 @@ function CollectionItem({ title, subtitle, onClick }: CollectionItemProps) {
return (
<Button
variant="outline"
className="justify-between h-auto py-2 "
className="h-auto justify-between py-2 rounded-md"
onClick={onClick}
>
<div className="text-left">
<p className="font-medium">{title}</p>
<p className="text-xs text-muted-foreground">{subtitle}</p>
<p className="text-muted-foreground text-xs">{subtitle}</p>
</div>
<ArrowRight className="h-4 w-4" />
</Button>
@@ -515,13 +512,13 @@ function StickerItem({
const collectionPrefix = iconName.split(":")[0];
const preview = imageError ? (
<div className="w-full h-full flex items-center justify-center p-2">
<span className="text-xs text-muted-foreground text-center break-all">
<div className="flex h-full w-full items-center justify-center p-2">
<span className="text-muted-foreground break-all text-center text-xs">
{displayName}
</span>
</div>
) : (
<div className="w-full h-full p-4 flex items-center justify-center">
<div className="flex h-full w-full items-center justify-center p-4">
<Image
src={
hostIndex === 0
@@ -529,13 +526,13 @@ function StickerItem({
: buildIconSvgUrl(
ICONIFY_HOSTS[Math.min(hostIndex, ICONIFY_HOSTS.length - 1)],
iconName,
{ width: 64, height: 64 }
{ width: 64, height: 64 },
)
}
alt={displayName}
width={64}
height={64}
className="w-full h-full object-contain"
className="h-full w-full object-contain"
style={
capSize
? {
@@ -564,28 +561,28 @@ function StickerItem({
<div
className={cn(
"relative",
isAdding && "opacity-50 pointer-events-none"
isAdding && "pointer-events-none opacity-50",
)}
>
<DraggableMediaItem
<DraggableItem
name={displayName}
preview={preview}
dragData={{
id: "sticker-placeholder",
type: "image",
id: iconName,
type: "sticker",
name: displayName,
iconName,
}}
onAddToTimeline={() => onAdd(iconName)}
aspectRatio={1}
showLabel={false}
rounded={true}
shouldShowLabel={false}
isRounded={true}
variant="card"
className=""
containerClassName="w-full"
isDraggable={false}
/>
{isAdding && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center rounded-md z-10">
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60">
<Loader2 className="h-6 w-6 animate-spin text-white" />
</div>
)}
@@ -594,7 +591,7 @@ function StickerItem({
<TooltipContent>
<div className="space-y-1">
<p className="font-medium">{displayName}</p>
<p className="text-xs text-muted-foreground">{collectionPrefix}</p>
<p className="text-muted-foreground text-xs">{collectionPrefix}</p>
</div>
</TooltipContent>
</Tooltip>
@@ -1,16 +1,36 @@
import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { DraggableItem } from "@/components/ui/draggable-item";
import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
import { useTimelineStore } from "@/stores/timeline-store";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import { buildTextElement } from "@/lib/timeline/element-utils";
export function TextView() {
const editor = useEditor();
const handleAddToTimeline = ({ currentTime }: { currentTime: number }) => {
const activeScene = editor.scenes.getActiveScene();
if (!activeScene) return;
const element = buildTextElement({
raw: DEFAULT_TEXT_ELEMENT,
startTime: currentTime,
});
const textTrack = activeScene.tracks.find((t) => t.type === "text");
if (textTrack) {
editor.timeline.addElementToTrack({
trackId: textTrack.id,
element,
});
}
};
return (
<BaseView>
<DraggableMediaItem
<DraggableItem
name="Default text"
preview={
<div className="flex items-center justify-center w-full h-full bg-panel-accent rounded">
<span className="text-xs select-none">Default text</span>
<div className="bg-panel-accent flex size-full items-center justify-center rounded">
<span className="select-none text-xs">Default text</span>
</div>
}
dragData={{
@@ -20,16 +40,8 @@ export function TextView() {
content: DEFAULT_TEXT_ELEMENT.content,
}}
aspectRatio={1}
onAddToTimeline={(currentTime) =>
useTimelineStore.getState().addElementAtTime(
{
...DEFAULT_TEXT_ELEMENT,
id: "temp-text-id",
},
currentTime
)
}
showLabel={false}
onAddToTimeline={handleAddToTimeline}
shouldShowLabel={false}
/>
</BaseView>
);