This commit is contained in:
Maze Winther
2025-12-25 21:15:36 +01:00
parent 2da84e7132
commit d5ca991501
102 changed files with 16028 additions and 6675 deletions
@@ -0,0 +1,303 @@
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 { useState, useRef, useEffect } from "react";
import { extractTimelineAudio } from "@/lib/mediabunny-utils";
import { encryptWithRandomKey, arrayBufferToBase64 } from "@/lib/zk-encryption";
import { useTimelineStore } from "@/stores/timeline-store";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import { Loader2, Shield, Trash2, Upload } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} 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" },
];
const PRIVACY_DIALOG_KEY = "opencut-transcription-privacy-accepted";
export function Captions() {
const [selectedCountry, setSelectedCountry] = useState("auto");
const [isProcessing, setIsProcessing] = useState(false);
const [processingStep, setProcessingStep] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const [showPrivacyDialog, setShowPrivacyDialog] = useState(false);
const [hasAcceptedPrivacy, setHasAcceptedPrivacy] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const { insertTrackAt, addElementToTrack } = useTimelineStore();
// Check if user has already accepted privacy on mount
useEffect(() => {
const hasAccepted = localStorage.getItem(PRIVACY_DIALOG_KEY) === "true";
setHasAcceptedPrivacy(hasAccepted);
}, []);
const handleGenerateTranscript = async () => {
try {
setIsProcessing(true);
setError(null);
setProcessingStep("Extracting audio...");
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...");
const uploadResponse = await fetch("/api/get-upload-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fileExtension: "wav" }),
});
if (!uploadResponse.ok) {
const error = await uploadResponse.json();
throw new Error(error.message || "Failed to get upload URL");
}
const { uploadUrl, fileName } = await uploadResponse.json();
// Upload to R2
await fetch(uploadUrl, {
method: "PUT",
body: encryptedBlob,
});
setProcessingStep("Transcribing...");
// Call Modal transcription API with encryption parameters
const transcriptionResponse = await fetch("/api/transcribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
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),
}),
});
if (!transcriptionResponse.ok) {
const error = await transcriptionResponse.json();
throw new Error(error.message || "Transcription failed");
}
const { text, segments } = await transcriptionResponse.json();
console.log("Transcription completed:", { text, segments });
const shortCaptions: Array<{
text: string;
startTime: number;
duration: number;
}> = [];
let globalEndTime = 0; // Track the end time of the last caption globally
segments.forEach((segment: any) => {
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;
}
shortCaptions.push({
text: chunk,
startTime: adjustedStartTime,
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);
// 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);
});
console.log(
`${shortCaptions.length} short-form caption chunks added to timeline!`
);
} catch (error) {
console.error("Transcription failed:", error);
setError(
error instanceof Error ? error.message : "An unexpected error occurred"
);
} finally {
setIsProcessing(false);
setProcessingStep("");
}
};
return (
<BaseView ref={containerRef} className="flex flex-col justify-between h-full">
<PropertyGroup title="Language">
<LanguageSelect
selectedCountry={selectedCountry}
onSelect={setSelectedCountry}
containerRef={containerRef}
languages={languages}
/>
</PropertyGroup>
<div className="flex flex-col gap-4">
{error && (
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
<Button
className="w-full"
onClick={() => {
if (hasAcceptedPrivacy) {
handleGenerateTranscript();
} else {
setShowPrivacyDialog(true);
}
}}
disabled={isProcessing}
>
{isProcessing && <Loader2 className="mr-1 h-4 w-4 animate-spin" />}
{isProcessing ? processingStep : "Generate transcript"}
</Button>
<Dialog open={showPrivacyDialog} onOpenChange={setShowPrivacyDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Audio Processing Notice
</DialogTitle>
<DialogDescription className="space-y-3">
<p>
To generate captions, we need to process your timeline audio
using speech-to-text technology.
</p>
<div className="space-y-2 pt-2">
<div className="flex items-start gap-2">
<Shield className="h-4 w-4 flex-shrink-0" />
<span className="text-sm">
Zero-knowledge encryption - we cannot decrypt your files
even if we wanted to
</span>
</div>
<div className="flex items-start gap-2">
<Shield className="h-4 w-4 flex-shrink-0" />
<span className="text-sm">
Encryption keys generated randomly in your browser, never
stored anywhere
</span>
</div>
<div className="flex items-start gap-2">
<Upload className="h-4 w-4 flex-shrink-0" />
<span className="text-sm">
Audio encrypted before upload - raw audio never leaves
your device
</span>
</div>
<div className="flex items-start gap-2">
<Trash2 className="h-4 w-4 flex-shrink-0" />
<span className="text-sm">
Everything permanently deleted within seconds after
transcription
</span>
</div>
</div>
<p className="text-xs text-muted-foreground">
<strong>True zero-knowledge privacy:</strong> Encryption keys
are generated randomly in your browser and never stored
anywhere. It's cryptographically impossible for us, our cloud
providers, or anyone else to decrypt your audio files.
</p>
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => setShowPrivacyDialog(false)}
disabled={isProcessing}
>
Cancel
</Button>
<Button
onClick={() => {
localStorage.setItem(PRIVACY_DIALOG_KEY, "true");
setHasAcceptedPrivacy(true);
setShowPrivacyDialog(false);
handleGenerateTranscript();
}}
disabled={isProcessing}
>
Continue & Generate Captions
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</BaseView>
);
}
@@ -0,0 +1,532 @@
"use client";
import { useFileUpload } from "@opencut/hooks/use-file-upload";
import { processMediaFiles } from "@/lib/media-processing-utils";
import { useMediaStore } from "@/stores/media-store";
import { MediaFile } from "@/types/media";
import {
ArrowDown01,
CloudUpload,
Grid2X2,
Image,
List,
Loader2,
Music,
Video,
} from "lucide-react";
import { useState, useMemo } from "react";
import { useRevealItem } from "@/hooks/use-reveal-item";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { MediaDragOverlay } from "@/components/editor/assets-panel/drag-overlay";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
DropdownMenu,
DropdownMenuContent,
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 {
Tooltip,
TooltipContent,
TooltipProvider,
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>
);
}
export function MediaView() {
const { mediaFiles, addMediaFile, removeMediaFile } = useMediaStore();
const { activeProject } = useProjectStore();
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[]) => {
if (!files || files.length === 0) return;
if (!activeProject) {
toast.error("No active project");
return;
}
setIsProcessing(true);
setProgress(0);
try {
const processedItems = await processMediaFiles({
files: files as FileList,
onProgress: (p: { progress: number }) => setProgress(p.progress),
});
for (const item of processedItems) {
await addMediaFile(activeProject.id, item);
}
} catch (error) {
console.error("Error processing files:", error);
toast.error("Failed to process files");
} finally {
setIsProcessing(false);
setProgress(0);
}
};
const { isDragOver, dragProps, openFilePicker, fileInputProps } =
useFileUpload({
accept: "image/*,video/*,audio/*",
multiple: true,
onFilesSelected: processFiles,
});
const handleRemove = async (e: React.MouseEvent, id: string) => {
e.stopPropagation();
if (!activeProject) {
toast.error("No active project");
return;
}
await removeMediaFile(activeProject.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 filteredMediaItems = useMemo(() => {
let filtered = mediaFiles.filter((item) => {
if (item.ephemeral) return false;
return true;
});
filtered.sort((a, b) => {
let valueA: string | number;
let valueB: string | number;
switch (sortBy) {
case "name":
valueA = a.name.toLowerCase();
valueB = b.name.toLowerCase();
break;
case "type":
valueA = a.type;
valueB = b.type;
break;
case "duration":
valueA = a.duration || 0;
valueB = b.duration || 0;
break;
case "size":
valueA = a.file.size;
valueB = b.file.size;
break;
default:
return 0;
}
if (valueA < valueB) return sortOrder === "asc" ? -1 : 1;
if (valueA > valueB) return sortOrder === "asc" ? 1 : -1;
return 0;
});
return filtered;
}, [mediaFiles, sortBy, sortOrder]);
const previewComponents = useMemo(() => {
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);
});
return previews;
}, [filteredMediaItems]);
const renderPreview = (item: MediaFile) => previewComponents.get(item.id);
return (
<>
{/* native file picker, visually hidden */}
<input {...fileInputProps} />
<div
className={`relative flex h-full flex-col gap-1 transition-colors ${isDragOver ? "bg-accent/30" : ""}`}
{...dragProps}
>
<div className="bg-panel p-3 pb-2">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="lg"
onClick={openFilePicker}
disabled={isProcessing}
className="!bg-background h-9 flex-1 items-center justify-center px-4 opacity-100 transition-opacity hover:opacity-75"
>
{isProcessing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CloudUpload className="h-4 w-4" />
)}
<span>Upload</span>
</Button>
<div className="flex items-center gap-0">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="text"
onClick={() =>
setMediaViewMode(
mediaViewMode === "grid" ? "list" : "grid",
)
}
disabled={isProcessing}
className="items-center justify-center"
>
{mediaViewMode === "grid" ? (
<List strokeWidth={1.5} className="!size-[1.05rem]" />
) : (
<Grid2X2
strokeWidth={1.5}
className="!size-[1.05rem]"
/>
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{mediaViewMode === "grid"
? "Switch to list view"
: "Switch to grid view"}
</p>
</TooltipContent>
<Tooltip>
<DropdownMenu>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="text"
disabled={isProcessing}
className="items-center justify-center"
>
<ArrowDown01
strokeWidth={1.5}
className="!size-[1.05rem]"
/>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
if (sortBy === "name") {
setSortOrder(
sortOrder === "asc" ? "desc" : "asc",
);
} else {
setSortBy("name");
setSortOrder("asc");
}
}}
>
Name{" "}
{sortBy === "name" &&
(sortOrder === "asc" ? "↑" : "↓")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
if (sortBy === "type") {
setSortOrder(
sortOrder === "asc" ? "desc" : "asc",
);
} else {
setSortBy("type");
setSortOrder("asc");
}
}}
>
Type{" "}
{sortBy === "type" &&
(sortOrder === "asc" ? "↑" : "↓")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
if (sortBy === "duration") {
setSortOrder(
sortOrder === "asc" ? "desc" : "asc",
);
} else {
setSortBy("duration");
setSortOrder("asc");
}
}}
>
Duration{" "}
{sortBy === "duration" &&
(sortOrder === "asc" ? "↑" : "↓")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
if (sortBy === "size") {
setSortOrder(
sortOrder === "asc" ? "desc" : "asc",
);
} else {
setSortBy("size");
setSortOrder("asc");
}
}}
>
File Size{" "}
{sortBy === "size" &&
(sortOrder === "asc" ? "↑" : "↓")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent>
<p>
Sort by {sortBy} (
{sortOrder === "asc" ? "ascending" : "descending"})
</p>
</TooltipContent>
</Tooltip>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
<div className="scrollbar-thin h-full w-full overflow-y-auto pt-1">
<div className="w-full flex-1 p-3 pt-0">
{isDragOver || filteredMediaItems.length === 0 ? (
<MediaDragOverlay
isVisible={true}
isProcessing={isProcessing}
progress={progress}
onClick={openFilePicker}
isEmptyState={filteredMediaItems.length === 0 && !isDragOver}
/>
) : mediaViewMode === "grid" ? (
<GridView
filteredMediaItems={filteredMediaItems}
renderPreview={renderPreview}
handleRemove={handleRemove}
highlightedId={highlightedId}
registerElement={registerElement}
/>
) : (
<ListView
filteredMediaItems={filteredMediaItems}
renderPreview={renderPreview}
handleRemove={handleRemove}
highlightedId={highlightedId}
registerElement={registerElement}
/>
)}
</div>
</div>
</div>
</>
);
}
function GridView({
filteredMediaItems,
renderPreview,
handleRemove,
highlightedId,
registerElement,
}: {
filteredMediaItems: MediaFile[];
renderPreview: (item: MediaFile) => React.ReactNode;
handleRemove: (e: React.MouseEvent, id: string) => Promise<void>;
highlightedId: string | null;
registerElement: (id: string, element: HTMLElement | null) => void;
}) {
const { addElementAtTime } = useTimelineStore();
return (
<div
className="grid gap-2"
style={{
gridTemplateColumns: "repeat(auto-fill, 160px)",
}}
>
{filteredMediaItems.map((item) => (
<div key={item.id} ref={(el) => registerElement(item.id, el)}>
<MediaItemWithContextMenu item={item} onRemove={handleRemove}>
<DraggableMediaItem
name={item.name}
preview={renderPreview(item)}
dragData={{
id: item.id,
type: item.type,
name: item.name,
}}
showPlusOnDrag={false}
onAddToTimeline={(currentTime) =>
addElementAtTime(item, currentTime)
}
rounded={false}
variant="card"
isHighlighted={highlightedId === item.id}
/>
</MediaItemWithContextMenu>
</div>
))}
</div>
);
}
function ListView({
filteredMediaItems,
renderPreview,
handleRemove,
highlightedId,
registerElement,
}: {
filteredMediaItems: MediaFile[];
renderPreview: (item: MediaFile) => React.ReactNode;
handleRemove: (e: React.MouseEvent, id: string) => Promise<void>;
highlightedId: string | null;
registerElement: (id: string, element: HTMLElement | null) => void;
}) {
const { addElementAtTime } = useTimelineStore();
return (
<div className="space-y-1">
{filteredMediaItems.map((item) => (
<div key={item.id} ref={(el) => registerElement(item.id, el)}>
<MediaItemWithContextMenu item={item} onRemove={handleRemove}>
<DraggableMediaItem
name={item.name}
preview={renderPreview(item)}
dragData={{
id: item.id,
type: item.type,
name: item.name,
}}
showPlusOnDrag={false}
onAddToTimeline={(currentTime) =>
addElementAtTime(item, currentTime)
}
variant="compact"
isHighlighted={highlightedId === item.id}
/>
</MediaItemWithContextMenu>
</div>
))}
</div>
);
}
@@ -0,0 +1,373 @@
"use client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
import {
PropertyItem,
PropertyItemLabel,
PropertyItemValue,
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";
import { useEditorStore } from "@/stores/editor-store";
import { dimensionToAspectRatio } from "@/lib/editor-utils";
import Image from "next/image";
import { cn } from "@/lib/utils";
import { colors } from "@/data/colors/solid";
import { patternCraftGradients } from "@/data/colors/pattern-craft";
import { PipetteIcon, PlusIcon } from "lucide-react";
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";
export function SettingsView() {
return <ProjectSettingsTabs />;
}
function ProjectSettingsTabs() {
return (
<BaseView
defaultTab="project-info"
tabs={[
{
value: "project-info",
label: "Project info",
content: (
<div className="p-5">
<ProjectInfoView />
</div>
),
},
{
value: "background",
label: "Background",
content: (
<div className="flex h-full flex-col justify-between">
<div className="flex-1 p-5">
<BackgroundView />
</div>
<div className="bg-panel/85 sticky -bottom-0 flex flex-col backdrop-blur-lg">
<Separator />
<Button className="text-muted-foreground hover:text-foreground/85 h-auto w-fit !bg-transparent p-5 py-4 text-xs shadow-none">
Custom background
<PlusIcon />
</Button>
</div>
{/* 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>
<PlusIcon className="" />
</Button>
</div> */}
</div>
),
},
]}
className="flex h-full flex-col justify-between p-0"
/>
);
}
function getCurrentCanvasSize({
activeProject,
}: {
activeProject: { canvasSize: { width: number; height: number } } | null;
}) {
return {
width: activeProject?.canvasSize.width || DEFAULT_CANVAS_SIZE.width,
height: activeProject?.canvasSize.height || DEFAULT_CANVAS_SIZE.height,
};
}
function ProjectInfoView() {
const { activeProject, updateProjectFps, updateCanvasSize } =
useProjectStore();
const { canvasPresets } = useEditorStore();
const findPresetIndexByAspectRatio = ({
presets,
targetAspectRatio,
}: {
presets: Array<{ width: number; height: number }>;
targetAspectRatio: string;
}) => {
for (let index = 0; index < presets.length; index++) {
const preset = presets[index];
const presetAspectRatio = dimensionToAspectRatio({
width: preset.width,
height: preset.height,
});
if (presetAspectRatio === targetAspectRatio) {
return index;
}
}
return -1;
};
const currentCanvasSize = getCurrentCanvasSize({ activeProject });
const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize);
const presetIndex = findPresetIndexByAspectRatio({
presets: canvasPresets,
targetAspectRatio: currentAspectRatio,
});
const selectedPresetIndex =
presetIndex !== -1 ? presetIndex.toString() : undefined;
const handleAspectRatioChange = ({ value }: { value: string }) => {
const index = parseInt(value, 10);
const preset = canvasPresets[index];
if (preset) {
updateCanvasSize({
size: preset,
});
}
};
const handleFpsChange = (value: string) => {
const fps = parseFloat(value);
updateProjectFps(fps);
};
return (
<div className="flex flex-col gap-4">
<PropertyItem direction="column">
<PropertyItemLabel>Name</PropertyItemLabel>
<PropertyItemValue>
{activeProject?.name || "Untitled project"}
</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="column">
<PropertyItemLabel>Aspect ratio</PropertyItemLabel>
<PropertyItemValue>
<Select
value={selectedPresetIndex}
onValueChange={(value) => handleAspectRatioChange({ value })}
>
<SelectTrigger className="bg-panel-accent">
<SelectValue placeholder="Select an aspect ratio" />
</SelectTrigger>
<SelectContent>
{canvasPresets.map((preset, index) => {
const label = dimensionToAspectRatio({
width: preset.width,
height: preset.height,
});
return (
<SelectItem key={label} value={index.toString()}>
{label}
</SelectItem>
);
})}
</SelectContent>
</Select>
</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="column">
<PropertyItemLabel>Frame rate</PropertyItemLabel>
<PropertyItemValue>
<Select
value={(activeProject?.fps || 30).toString()}
onValueChange={handleFpsChange}
>
<SelectTrigger className="bg-panel-accent">
<SelectValue placeholder="Select a frame rate" />
</SelectTrigger>
<SelectContent>
{FPS_PRESETS.map((preset) => (
<SelectItem key={preset.value} value={preset.value}>
{preset.label}
</SelectItem>
))}
</SelectContent>
</Select>
</PropertyItemValue>
</PropertyItem>
</div>
);
}
const BlurPreview = memo(
({
blur,
isSelected,
onSelect,
}: {
blur: { label: string; value: number };
isSelected: boolean;
onSelect: () => void;
}) => (
<div
className={cn(
"border-foreground/15 hover:border-primary relative aspect-square w-full cursor-pointer overflow-hidden rounded-sm border",
isSelected && "border-primary border-2",
)}
onClick={onSelect}
>
<Image
src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
alt={`Blur preview ${blur.label}`}
fill
className="object-cover"
style={{ filter: `blur(${blur.value}px)` }}
loading="eager"
/>
<div className="absolute bottom-1 left-1 right-1 text-center">
<span className="rounded bg-black/50 px-1 text-xs text-white">
{blur.label}
</span>
</div>
</div>
),
);
BlurPreview.displayName = "BlurPreview";
const BackgroundPreviews = memo(
({
backgrounds,
currentBackgroundColor,
isColorBackground,
handleColorSelect,
useBackgroundColor = false,
}: {
backgrounds: string[];
currentBackgroundColor: string;
isColorBackground: boolean;
handleColorSelect: (bg: string) => void;
useBackgroundColor?: boolean;
}) => {
return useMemo(
() =>
backgrounds.map((bg, index) => (
<div
key={`${index}-${bg}`}
className={cn(
"border-foreground/15 hover:border-primary aspect-square w-full cursor-pointer rounded-sm border",
isColorBackground &&
bg === currentBackgroundColor &&
"border-primary border-2",
)}
style={
useBackgroundColor
? { backgroundColor: bg }
: {
background: bg,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}
}
onClick={() => handleColorSelect(bg)}
/>
)),
[
backgrounds,
isColorBackground,
currentBackgroundColor,
handleColorSelect,
useBackgroundColor,
],
);
},
);
BackgroundPreviews.displayName = "BackgroundPreviews";
function BackgroundView() {
const { activeProject, updateBackgroundType } = useProjectStore();
const blurLevels = useMemo(() => BLUR_INTENSITY_PRESETS, []);
const handleBlurSelect = useCallback(
async ({ blurIntensity }: { blurIntensity: number }) => {
await updateBackgroundType("blur", { blurIntensity });
},
[updateBackgroundType],
);
const handleColorSelect = useCallback(
async (color: string) => {
await updateBackgroundType("color", { backgroundColor: color });
},
[updateBackgroundType],
);
const currentBlurIntensity = activeProject?.blurIntensity || 8;
const isBlurBackground = activeProject?.backgroundType === "blur";
const currentBackgroundColor = activeProject?.backgroundColor || "#000000";
const isColorBackground = activeProject?.backgroundType === "color";
const blurPreviews = useMemo(
() =>
blurLevels.map((blur) => (
<BlurPreview
key={blur.value}
blur={blur}
isSelected={isBlurBackground && currentBlurIntensity === blur.value}
onSelect={() => handleBlurSelect({ blurIntensity: blur.value })}
/>
)),
[blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect],
);
return (
<div className="flex h-full flex-col gap-4">
<PropertyGroup title="Blur" defaultExpanded={false}>
<div className="grid w-full grid-cols-4 gap-2">{blurPreviews}</div>
</PropertyGroup>
<PropertyGroup title="Colors" defaultExpanded={false}>
<div className="grid w-full grid-cols-4 gap-2">
<div className="border-foreground/15 hover:border-primary flex aspect-square w-full cursor-pointer items-center justify-center rounded-sm border">
<PipetteIcon className="size-4" />
</div>
<BackgroundPreviews
backgrounds={colors}
currentBackgroundColor={currentBackgroundColor}
isColorBackground={isColorBackground}
handleColorSelect={handleColorSelect}
useBackgroundColor={true}
/>
</div>
</PropertyGroup>
<PropertyGroup title="Pattern craft" defaultExpanded={false}>
<div className="grid w-full grid-cols-4 gap-2">
<BackgroundPreviews
backgrounds={patternCraftGradients}
currentBackgroundColor={currentBackgroundColor}
isColorBackground={isColorBackground}
handleColorSelect={handleColorSelect}
/>
</div>
</PropertyGroup>
<PropertyGroup title="Syntax UI" defaultExpanded={false}>
<div className="grid w-full grid-cols-4 gap-2">
<BackgroundPreviews
backgrounds={syntaxUIGradients}
currentBackgroundColor={currentBackgroundColor}
isColorBackground={isColorBackground}
handleColorSelect={handleColorSelect}
/>
</div>
</PropertyGroup>
</div>
);
}
@@ -0,0 +1,562 @@
"use client";
import { Input } from "@/components/ui/input";
import { useState, useMemo, useEffect } from "react";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
PlayIcon,
PauseIcon,
HeartIcon,
PlusIcon,
ListFilter,
} from "lucide-react";
import { useSoundsStore } from "@/stores/sounds-store";
import { useSoundSearch } from "@/hooks/use-sound-search";
import type { SoundEffect, SavedSound } from "@/types/sounds";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuCheckboxItem,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
export function SoundsView() {
return (
<div className="h-full flex flex-col">
<Tabs defaultValue="sound-effects" className="flex flex-col h-full">
<div className="px-3 pt-4 pb-0">
<TabsList>
<TabsTrigger value="sound-effects">Sound effects</TabsTrigger>
<TabsTrigger value="songs">Songs</TabsTrigger>
<TabsTrigger value="saved">Saved</TabsTrigger>
</TabsList>
</div>
<Separator className="my-4" />
<TabsContent
value="sound-effects"
className="p-5 pt-0 mt-0 flex-1 flex flex-col min-h-0"
>
<SoundEffectsView />
</TabsContent>
<TabsContent
value="saved"
className="p-5 pt-0 mt-0 flex-1 flex flex-col min-h-0"
>
<SavedSoundsView />
</TabsContent>
<TabsContent
value="songs"
className="p-5 pt-0 mt-0 flex-1 flex flex-col min-h-0"
>
<SongsView />
</TabsContent>
</Tabs>
</div>
);
}
function SoundEffectsView() {
const {
topSoundEffects,
isLoading,
searchQuery,
setSearchQuery,
scrollPosition,
setScrollPosition,
loadSavedSounds,
isSoundSaved,
toggleSavedSound,
showCommercialOnly,
toggleCommercialFilter,
hasLoaded,
setTopSoundEffects,
setLoading,
setError,
setHasLoaded,
setCurrentPage,
setHasNextPage,
setTotalCount,
} = useSoundsStore();
const {
results: searchResults,
isLoading: isSearching,
loadMore,
hasNextPage,
isLoadingMore,
} = useSoundSearch(searchQuery, showCommercialOnly);
// Audio playback state
const [playingId, setPlayingId] = useState<number | null>(null);
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(
null
);
const { scrollAreaRef, handleScroll } = useInfiniteScroll({
onLoadMore: loadMore,
hasMore: hasNextPage,
isLoading: isLoadingMore || isSearching,
});
useEffect(() => {
loadSavedSounds();
if (!hasLoaded) {
let ignore = false;
const fetchTopSounds = async () => {
try {
if (!ignore) {
setLoading(true);
setError(null);
}
const response = await fetch(
"/api/sounds/search?page_size=50&sort=downloads"
);
if (!ignore) {
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`);
}
const data = await response.json();
setTopSoundEffects(data.results);
setHasLoaded(true);
setCurrentPage(1);
setHasNextPage(!!data.next);
setTotalCount(data.count);
}
} catch (error) {
if (!ignore) {
console.error("Failed to fetch top sounds:", error);
setError(
error instanceof Error ? error.message : "Failed to load sounds"
);
}
} finally {
if (!ignore) {
setLoading(false);
}
}
};
const timeoutId = setTimeout(fetchTopSounds, 100);
return () => {
clearTimeout(timeoutId);
ignore = true;
};
}
if (scrollAreaRef.current && scrollPosition > 0) {
const timeoutId = setTimeout(() => {
scrollAreaRef.current?.scrollTo({ top: scrollPosition });
}, 100);
return () => clearTimeout(timeoutId);
}
}, [
hasLoaded,
setTopSoundEffects,
setLoading,
setError,
setHasLoaded,
setCurrentPage,
setHasNextPage,
setTotalCount,
]);
const handleScrollWithPosition = (event: React.UIEvent<HTMLDivElement>) => {
const { scrollTop } = event.currentTarget;
setScrollPosition(scrollTop);
handleScroll(event);
};
const displayedSounds = useMemo(() => {
const sounds = searchQuery ? searchResults : topSoundEffects;
return sounds;
}, [searchQuery, searchResults, topSoundEffects]);
const playSound = (sound: SoundEffect) => {
if (playingId === sound.id) {
audioElement?.pause();
setPlayingId(null);
return;
}
// Stop previous sound
audioElement?.pause();
if (sound.previewUrl) {
const audio = new Audio(sound.previewUrl);
audio.addEventListener("ended", () => {
setPlayingId(null);
});
audio.addEventListener("error", (e) => {
setPlayingId(null);
});
audio.play().catch((error) => {
setPlayingId(null);
});
setAudioElement(audio);
setPlayingId(sound.id);
}
};
return (
<div className="flex flex-col gap-5 mt-1 h-full">
<div className="flex items-center gap-3">
<Input
placeholder="Search sound effects"
className="bg-panel-accent w-full"
containerClassName="w-full"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
showClearIcon
onClear={() => setSearchQuery("")}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="text"
size="icon"
className={cn(showCommercialOnly && "text-primary")}
>
<ListFilter className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuCheckboxItem
checked={showCommercialOnly}
onCheckedChange={toggleCommercialFilter}
>
Show only commercially licensed
</DropdownMenuCheckboxItem>
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{showCommercialOnly
? "Only showing sounds licensed for commercial use"
: "Showing all sounds regardless of license"}
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="relative h-full overflow-hidden">
<ScrollArea
className="flex-1 h-full"
ref={scrollAreaRef}
onScrollCapture={handleScrollWithPosition}
>
<div className="flex flex-col gap-4">
{isLoading && !searchQuery && (
<div className="text-muted-foreground text-sm">
Loading sounds...
</div>
)}
{isSearching && searchQuery && (
<div className="text-muted-foreground text-sm">Searching...</div>
)}
{displayedSounds.map((sound) => (
<AudioItem
key={sound.id}
sound={sound}
isPlaying={playingId === sound.id}
onPlay={() => playSound(sound)}
isSaved={isSoundSaved(sound.id)}
onToggleSaved={() => toggleSavedSound(sound)}
/>
))}
{!isLoading && !isSearching && displayedSounds.length === 0 && (
<div className="text-muted-foreground text-sm">
{searchQuery ? "No sounds found" : "No sounds available"}
</div>
)}
{isLoadingMore && (
<div className="text-muted-foreground text-sm text-center py-4">
Loading more sounds...
</div>
)}
</div>
</ScrollArea>
</div>
</div>
);
}
function SavedSoundsView() {
const {
savedSounds,
isLoadingSavedSounds,
savedSoundsError,
loadSavedSounds,
isSoundSaved,
toggleSavedSound,
clearSavedSounds,
} = useSoundsStore();
// Audio playback state
const [playingId, setPlayingId] = useState<number | null>(null);
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(
null
);
// Clear confirmation dialog state
const [showClearDialog, setShowClearDialog] = useState(false);
// Load saved sounds when tab becomes active
useEffect(() => {
loadSavedSounds();
}, [loadSavedSounds]);
const playSound = (sound: SavedSound) => {
if (playingId === sound.id) {
audioElement?.pause();
setPlayingId(null);
return;
}
// Stop previous sound
audioElement?.pause();
if (sound.previewUrl) {
const audio = new Audio(sound.previewUrl);
audio.addEventListener("ended", () => {
setPlayingId(null);
});
audio.addEventListener("error", (e) => {
setPlayingId(null);
});
audio.play().catch((error) => {
setPlayingId(null);
});
setAudioElement(audio);
setPlayingId(sound.id);
}
};
// Convert SavedSound to SoundEffect for compatibility with AudioItem
const convertToSoundEffect = (savedSound: SavedSound): SoundEffect => ({
id: savedSound.id,
name: savedSound.name,
description: "",
url: "",
previewUrl: savedSound.previewUrl,
downloadUrl: savedSound.downloadUrl,
duration: savedSound.duration,
filesize: 0,
type: "audio",
channels: 0,
bitrate: 0,
bitdepth: 0,
samplerate: 0,
username: savedSound.username,
tags: savedSound.tags,
license: savedSound.license,
created: savedSound.savedAt,
downloads: 0,
rating: 0,
ratingCount: 0,
});
if (isLoadingSavedSounds) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-muted-foreground text-sm">
Loading saved sounds...
</div>
</div>
);
}
if (savedSoundsError) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-destructive text-sm">
Error: {savedSoundsError}
</div>
</div>
);
}
if (savedSounds.length === 0) {
return (
<div className="bg-panel h-full p-4 flex flex-col items-center justify-center gap-3">
<HeartIcon
className="w-10 h-10 text-muted-foreground"
strokeWidth={1.5}
/>
<div className="flex flex-col gap-2 text-center">
<p className="text-lg font-medium">No saved sounds</p>
<p className="text-sm text-muted-foreground text-balance">
Click the heart icon on any sound to save it here
</p>
</div>
</div>
);
}
return (
<div className="flex flex-col gap-5 mt-1 h-full">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{savedSounds.length} saved{" "}
{savedSounds.length === 1 ? "sound" : "sounds"}
</p>
<Dialog open={showClearDialog} onOpenChange={setShowClearDialog}>
<DialogTrigger asChild>
<Button
variant="text"
size="sm"
className="h-auto text-muted-foreground hover:text-destructive !opacity-100"
>
Clear all
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Clear all saved sounds?</DialogTitle>
<DialogDescription>
This will permanently remove all {savedSounds.length} saved
sounds from your collection. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="text" onClick={() => setShowClearDialog(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={async () => {
await clearSavedSounds();
setShowClearDialog(false);
}}
>
Clear all sounds
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="relative h-full overflow-hidden">
<ScrollArea className="flex-1 h-full">
<div className="flex flex-col gap-4">
{savedSounds.map((sound) => (
<AudioItem
key={sound.id}
sound={convertToSoundEffect(sound)}
isPlaying={playingId === sound.id}
onPlay={() => playSound(sound)}
isSaved={isSoundSaved(sound.id)}
onToggleSaved={() =>
toggleSavedSound(convertToSoundEffect(sound))
}
/>
))}
</div>
</ScrollArea>
</div>
</div>
);
}
function SongsView() {
return <div>Songs</div>;
}
interface AudioItemProps {
sound: SoundEffect;
isPlaying: boolean;
onPlay: () => void;
isSaved: boolean;
onToggleSaved: () => void;
}
function AudioItem({
sound,
isPlaying,
onPlay,
isSaved,
onToggleSaved,
}: AudioItemProps) {
const { addSoundToTimeline } = useSoundsStore();
const handleClick = () => {
onPlay();
};
const handleSaveClick = (e: React.MouseEvent) => {
e.stopPropagation();
onToggleSaved();
};
const handleAddToTimeline = async (e: React.MouseEvent) => {
e.stopPropagation();
await addSoundToTimeline(sound);
};
return (
<div
className="group flex items-center gap-3 opacity-100 hover:opacity-75 transition-opacity cursor-pointer"
onClick={handleClick}
>
<div className="relative w-12 h-12 bg-accent rounded-md flex items-center justify-center overflow-hidden shrink-0">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-transparent" />
{isPlaying ? (
<PauseIcon className="w-5 h-5" />
) : (
<PlayIcon className="w-5 h-5" />
)}
</div>
<div className="flex-1 min-w-0 overflow-hidden">
<p className="font-medium truncate text-sm">{sound.name}</p>
<span className="text-xs text-muted-foreground truncate block">
{sound.username}
</span>
</div>
<div className="flex items-center gap-3 pr-2">
<Button
variant="text"
size="icon"
className="text-muted-foreground hover:text-foreground !opacity-100 w-auto"
onClick={handleAddToTimeline}
title="Add to timeline"
>
<PlusIcon className="w-4 h-4" />
</Button>
<Button
variant="text"
size="icon"
className={`hover:text-foreground !opacity-100 w-auto ${
isSaved
? "text-red-500 hover:text-red-600"
: "text-muted-foreground"
}`}
onClick={handleSaveClick}
title={isSaved ? "Remove from saved" : "Save sound"}
>
<HeartIcon className={`w-4 h-4 ${isSaved ? "fill-current" : ""}`} />
</Button>
</div>
</div>
);
}
@@ -0,0 +1,602 @@
"use client";
import { useEffect, useState, useMemo } from "react";
import { useStickersStore } from "@/stores/stickers-store";
import {
Loader2,
Grid3X3,
Hash,
Smile,
Clock,
X,
Sparkles,
ArrowRight,
StickerIcon,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
getIconSvgUrl,
buildIconSvgUrl,
ICONIFY_HOSTS,
POPULAR_COLLECTIONS,
} from "@/lib/iconify-api";
import { cn } from "@/lib/utils";
import Image from "next/image";
import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { InputWithBack } from "@/components/ui/input-with-back";
import { StickerCategory } from "@/stores/stickers-store";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
export function StickersView() {
const { selectedCategory, setSelectedCategory } = useStickersStore();
return (
<BaseView
value={selectedCategory}
onValueChange={(v) => {
if (["all", "general", "brands", "emoji"].includes(v)) {
setSelectedCategory(v as StickerCategory);
}
}}
tabs={[
{
value: "all",
label: "All",
icon: <Grid3X3 className="h-3 w-3" />,
content: <StickersContentView category="all" />,
},
{
value: "general",
label: "Icons",
icon: <Sparkles className="h-3 w-3" />,
content: <StickersContentView category="general" />,
},
{
value: "brands",
label: "Brands",
icon: <Hash className="h-3 w-3" />,
content: <StickersContentView category="brands" />,
},
{
value: "emoji",
label: "Emoji",
icon: <Smile className="h-3 w-3" />,
content: <StickersContentView category="emoji" />,
},
]}
className="flex flex-col h-full p-0 overflow-hidden"
/>
);
}
function StickerGrid({
icons,
onAdd,
addingSticker,
capSize = false,
}: {
icons: string[];
onAdd: (iconName: string) => void;
addingSticker: string | null;
capSize?: boolean;
}) {
return (
<div
className="grid gap-2"
style={{
gridTemplateColumns: capSize
? "repeat(auto-fill, minmax(var(--sticker-min, 96px), var(--sticker-max, 160px)))"
: "repeat(auto-fit, minmax(var(--sticker-min, 96px), 1fr))",
["--sticker-min" as any]: "96px",
...(capSize ? ({ ["--sticker-max"]: "160px" } as any) : {}),
}}
>
{icons.map((iconName) => (
<StickerItem
key={iconName}
iconName={iconName}
onAdd={onAdd}
isAdding={addingSticker === iconName}
capSize={capSize}
/>
))}
</div>
);
}
function CollectionGrid({
collections,
onSelectCollection,
}: {
collections: Array<{
prefix: string;
name: string;
total: number;
category?: string;
}>;
onSelectCollection: (prefix: string) => void;
}) {
return (
<div className="grid grid-cols-1 gap-2">
{collections.map((collection) => (
<CollectionItem
key={collection.prefix}
title={collection.name}
subtitle={`${collection.total.toLocaleString()} icons${collection.category ? `${collection.category}` : ""}`}
onClick={() => onSelectCollection(collection.prefix)}
/>
))}
</div>
);
}
function EmptyView({ message }: { message: string }) {
return (
<div className="bg-panel h-full p-4 flex flex-col items-center justify-center gap-3">
<StickerIcon
className="w-10 h-10 text-muted-foreground"
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>
</div>
</div>
);
}
function StickersContentView({ category }: { category: StickerCategory }) {
const {
searchQuery,
selectedCollection,
viewMode,
collections,
currentCollection,
searchResults,
recentStickers,
isLoadingCollections,
isLoadingCollection,
isSearching,
setSearchQuery,
setSelectedCollection,
loadCollections,
searchStickers,
addStickerToTimeline,
clearRecentStickers,
setSelectedCategory,
addingSticker,
} = useStickersStore();
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
const [collectionsToShow, setCollectionsToShow] = useState(20);
const [showCollectionItems, setShowCollectionItems] = useState(false);
const filteredCollections = useMemo(() => {
if (category === "all") {
return Object.entries(collections).map(([prefix, collection]) => ({
prefix,
name: collection.name,
total: collection.total,
category: collection.category,
}));
}
const collectionList =
POPULAR_COLLECTIONS[category as keyof typeof POPULAR_COLLECTIONS];
if (!collectionList) return [];
return collectionList
.map((c) => {
const collection = collections[c.prefix];
return collection
? {
prefix: c.prefix,
name: c.name,
total: collection.total,
}
: null;
})
.filter(Boolean) as Array<{
prefix: string;
name: string;
total: number;
}>;
}, [collections, category]);
const { scrollAreaRef, handleScroll } = useInfiniteScroll({
onLoadMore: () => setCollectionsToShow((prev) => prev + 20),
hasMore: filteredCollections.length > collectionsToShow,
isLoading: isLoadingCollections,
enabled: viewMode === "browse" && !selectedCollection && category === "all",
});
useEffect(() => {
if (Object.keys(collections).length === 0) {
loadCollections();
}
}, []);
useEffect(() => {
const timer = setTimeout(() => {
if (localSearchQuery !== searchQuery) {
setSearchQuery(localSearchQuery);
if (localSearchQuery.trim()) {
searchStickers(localSearchQuery);
}
}
}, 500);
return () => clearTimeout(timer);
}, [localSearchQuery]);
const handleAddSticker = async (iconName: string) => {
try {
await addStickerToTimeline(iconName);
} catch (error) {
console.error("Failed to add sticker:", error);
toast.error("Failed to add sticker to timeline");
}
};
const iconsToDisplay = useMemo(() => {
if (viewMode === "search" && searchResults) {
return searchResults.icons;
}
if (viewMode === "collection" && currentCollection) {
const icons: string[] = [];
if (currentCollection.uncategorized) {
icons.push(
...currentCollection.uncategorized.map(
(name) => `${currentCollection.prefix}:${name}`
)
);
}
if (currentCollection.categories) {
Object.values(currentCollection.categories).forEach((categoryIcons) => {
icons.push(
...categoryIcons.map(
(name) => `${currentCollection.prefix}:${name}`
)
);
});
}
return icons.slice(0, 100);
}
return [];
}, [viewMode, searchResults, currentCollection]);
const isInCollection = viewMode === "collection" && !!selectedCollection;
useEffect(() => {
if (isInCollection) {
setShowCollectionItems(false);
const timer = setTimeout(() => setShowCollectionItems(true), 350);
return () => clearTimeout(timer);
} else {
setShowCollectionItems(false);
}
}, [isInCollection]);
return (
<div className="flex flex-col gap-5 mt-1 h-full p-4">
<div className="space-y-3">
<InputWithBack
isExpanded={isInCollection}
setIsExpanded={(expanded) => {
if (!expanded && isInCollection) {
setSelectedCollection(null);
}
}}
placeholder={
category === "all"
? "Search all stickers"
: category === "general"
? "Search icons"
: category === "brands"
? "Search brands"
: "Search Emojis"
}
value={localSearchQuery}
onChange={setLocalSearchQuery}
disableAnimation={true}
/>
</div>
<div className="relative h-full overflow-hidden">
<ScrollArea
className="flex-1 h-full"
ref={scrollAreaRef}
onScrollCapture={handleScroll}
>
<div className="flex flex-col gap-4 h-full">
{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" />
<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"
>
<X className="h-3 w-3 text-muted-foreground" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Clear recent stickers</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<StickerGrid
icons={recentStickers.slice(0, 12)}
onAdd={handleAddSticker}
addingSticker={addingSticker}
capSize
/>
</div>
)}
{viewMode === "collection" && selectedCollection && (
<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" />
</div>
) : showCollectionItems ? (
<StickerGrid
icons={iconsToDisplay}
onAdd={handleAddSticker}
addingSticker={addingSticker}
/>
) : (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
{viewMode === "search" && (
<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" />
</div>
) : searchResults?.icons.length ? (
<>
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-muted-foreground">
{searchResults.total} results
</span>
</div>
<StickerGrid
icons={iconsToDisplay}
onAdd={handleAddSticker}
addingSticker={addingSticker}
capSize
/>
</>
) : searchQuery ? (
<div className="flex flex-col items-center justify-center py-8 gap-3">
<EmptyView
message={`No stickers found for "${searchQuery}"`}
/>
{category !== "all" && (
<Button
variant="outline"
onClick={() => {
const q = localSearchQuery || searchQuery;
if (q) {
setSearchQuery(q);
}
setSelectedCategory("all");
if (q) {
searchStickers(q);
}
}}
>
Search in all icons
</Button>
)}
</div>
) : null}
</div>
)}
{viewMode === "browse" && !selectedCollection && (
<div className="space-y-4 h-full">
{isLoadingCollections ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</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}
/>
</div>
)}
{category === "all" && filteredCollections.length > 0 && (
<div className="h-full">
<CollectionGrid
collections={filteredCollections.slice(
0,
collectionsToShow
)}
onSelectCollection={setSelectedCollection}
/>
</div>
)}
</>
)}
</div>
)}
</div>
</ScrollArea>
</div>
</div>
);
}
interface CollectionItemProps {
title: string;
subtitle: string;
onClick: () => void;
}
function CollectionItem({ title, subtitle, onClick }: CollectionItemProps) {
return (
<Button
variant="outline"
className="justify-between h-auto py-2 "
onClick={onClick}
>
<div className="text-left">
<p className="font-medium">{title}</p>
<p className="text-xs text-muted-foreground">{subtitle}</p>
</div>
<ArrowRight className="h-4 w-4" />
</Button>
);
}
interface StickerItemProps {
iconName: string;
onAdd: (iconName: string) => void;
isAdding?: boolean;
capSize?: boolean;
}
function StickerItem({
iconName,
onAdd,
isAdding,
capSize = false,
}: StickerItemProps) {
const [imageError, setImageError] = useState(false);
const [hostIndex, setHostIndex] = useState(0);
useEffect(() => {
setImageError(false);
setHostIndex(0);
}, [iconName]);
const displayName = iconName.split(":")[1] || iconName;
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">
{displayName}
</span>
</div>
) : (
<div className="w-full h-full p-4 flex items-center justify-center">
<Image
src={
hostIndex === 0
? getIconSvgUrl(iconName, { width: 64, height: 64 })
: buildIconSvgUrl(
ICONIFY_HOSTS[Math.min(hostIndex, ICONIFY_HOSTS.length - 1)],
iconName,
{ width: 64, height: 64 }
)
}
alt={displayName}
width={64}
height={64}
className="w-full h-full object-contain"
style={
capSize
? {
maxWidth: "var(--sticker-max, 160px)",
maxHeight: "var(--sticker-max, 160px)",
}
: undefined
}
onError={() => {
const next = hostIndex + 1;
if (next < ICONIFY_HOSTS.length) {
setHostIndex(next);
} else {
setImageError(true);
}
}}
loading="lazy"
unoptimized
/>
</div>
);
return (
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"relative",
isAdding && "opacity-50 pointer-events-none"
)}
>
<DraggableMediaItem
name={displayName}
preview={preview}
dragData={{
id: "sticker-placeholder",
type: "image",
name: displayName,
}}
onAddToTimeline={() => onAdd(iconName)}
aspectRatio={1}
showLabel={false}
rounded={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">
<Loader2 className="h-6 w-6 animate-spin text-white" />
</div>
)}
</div>
</TooltipTrigger>
<TooltipContent>
<div className="space-y-1">
<p className="font-medium">{displayName}</p>
<p className="text-xs text-muted-foreground">{collectionPrefix}</p>
</div>
</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,36 @@
import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
import { useTimelineStore } from "@/stores/timeline-store";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
export function TextView() {
return (
<BaseView>
<DraggableMediaItem
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>
}
dragData={{
id: "temp-text-id",
type: DEFAULT_TEXT_ELEMENT.type,
name: DEFAULT_TEXT_ELEMENT.name,
content: DEFAULT_TEXT_ELEMENT.content,
}}
aspectRatio={1}
onAddToTimeline={(currentTime) =>
useTimelineStore.getState().addElementAtTime(
{
...DEFAULT_TEXT_ELEMENT,
id: "temp-text-id",
},
currentTime
)
}
showLabel={false}
/>
</BaseView>
);
}