mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
stuff
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user