summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/media-panel/views
diff options
context:
space:
mode:
authorMaze <167211895+mazeincoding@users.noreply.github.com>2026-01-31 00:20:04 +0100
committerGitHub <noreply@github.com>2026-01-31 00:20:04 +0100
commit7bf0984698e43b9aa2dd879f65951318ed410390 (patch)
treeccf2cd590a47d550152afc07129cb55c71a9ec35 /apps/web/src/components/editor/media-panel/views
parent0173db99448d1c4c6415dcf15272e17551b48b04 (diff)
downloadopencut-7bf0984698e43b9aa2dd879f65951318ed410390.tar.gz
codebase overhaul (#697)
Diffstat (limited to 'apps/web/src/components/editor/media-panel/views')
-rw-r--r--apps/web/src/components/editor/media-panel/views/captions.tsx303
-rw-r--r--apps/web/src/components/editor/media-panel/views/media.tsx545
-rw-r--r--apps/web/src/components/editor/media-panel/views/settings.tsx332
-rw-r--r--apps/web/src/components/editor/media-panel/views/sounds.tsx562
-rw-r--r--apps/web/src/components/editor/media-panel/views/stickers.tsx612
-rw-r--r--apps/web/src/components/editor/media-panel/views/text.tsx36
6 files changed, 0 insertions, 2390 deletions
diff --git a/apps/web/src/components/editor/media-panel/views/captions.tsx b/apps/web/src/components/editor/media-panel/views/captions.tsx
deleted file mode 100644
index fbe5b418..00000000
--- a/apps/web/src/components/editor/media-panel/views/captions.tsx
+++ /dev/null
@@ -1,303 +0,0 @@
-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>
- );
-}
diff --git a/apps/web/src/components/editor/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx
deleted file mode 100644
index 07a44a7c..00000000
--- a/apps/web/src/components/editor/media-panel/views/media.tsx
+++ /dev/null
@@ -1,545 +0,0 @@
-"use client";
-
-import { useDragDrop } from "@/hooks/use-drag-drop";
-import { processMediaFiles } from "@/lib/media-processing";
-import { useMediaStore } from "@/stores/media-store";
-import { MediaFile } from "@/types/media";
-import {
- ArrowDown01,
- CloudUpload,
- Grid2X2,
- Image,
- List,
- Loader2,
- Music,
- Video,
-} from "lucide-react";
-import { useRef, useState, useMemo } from "react";
-import { useHighlightScroll } from "@/hooks/use-highlight-scroll";
-import { toast } from "sonner";
-import { Button } from "@/components/ui/button";
-import { MediaDragOverlay } from "@/components/editor/media-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 { useMediaPanelStore } from "../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 fileInputRef = useRef<HTMLInputElement>(null);
- 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 } = useMediaPanelStore();
- const { highlightedId, registerElement } = useHighlightScroll(
- 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, (p) =>
- setProgress(p)
- );
- 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 } = useDragDrop({
- // When files are dropped, process them
- onDrop: processFiles,
- });
-
- const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker
-
- const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
- if (e.target.files) processFiles(e.target.files);
- e.target.value = ""; // Reset input
- };
-
- 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="w-full h-full flex items-center justify-center">
- <img
- src={item.url}
- alt={item.name}
- className="w-full max-h-full object-cover"
- loading="lazy"
- />
- </div>
- );
- } else if (item.type === "video") {
- if (item.thumbnailUrl) {
- preview = (
- <div className="relative w-full h-full">
- <img
- src={item.thumbnailUrl}
- alt={item.name}
- className="w-full h-full object-cover rounded"
- loading="lazy"
- />
- <div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded">
- <Video className="h-6 w-6 text-white drop-shadow-md" />
- </div>
- {item.duration && (
- <div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
- {formatDuration(item.duration)}
- </div>
- )}
- </div>
- );
- } else {
- preview = (
- <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
- <Video className="h-6 w-6 mb-1" />
- <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="w-full h-full bg-linear-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20">
- <Music className="h-6 w-6 mb-1" />
- <span className="text-xs">Audio</span>
- {item.duration && (
- <span className="text-xs opacity-70">
- {formatDuration(item.duration)}
- </span>
- )}
- </div>
- );
- } else {
- preview = (
- <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
- <Image className="h-6 w-6" />
- <span className="text-xs mt-1">Unknown</span>
- </div>
- );
- }
-
- previews.set(item.id, preview);
- });
-
- return previews;
- }, [filteredMediaItems]);
-
- const renderPreview = (item: MediaFile) => previewComponents.get(item.id);
-
- return (
- <>
- {/* Hidden file input for uploading media */}
- <input
- ref={fileInputRef}
- type="file"
- accept="image/*,video/*,audio/*"
- multiple
- className="hidden"
- onChange={handleFileChange}
- />
-
- <div
- className={`h-full flex flex-col gap-1 transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`}
- {...dragProps}
- >
- <div className="p-3 pb-2 bg-panel">
- {/* Search and filter controls */}
- <div className="flex items-center gap-2">
- <Button
- variant="outline"
- size="lg"
- onClick={handleFileSelect}
- disabled={isProcessing}
- className="!bg-background px-4 flex-1 justify-center items-center h-9 opacity-100 hover:opacity-75 transition-opacity"
- >
- {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="justify-center items-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="justify-center items-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="h-full w-full overflow-y-auto scrollbar-thin pt-1">
- <div className="flex-1 p-3 pt-0 w-full">
- {isDragOver || filteredMediaItems.length === 0 ? (
- <MediaDragOverlay
- isVisible={true}
- isProcessing={isProcessing}
- progress={progress}
- onClick={handleFileSelect}
- 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>
- );
-}
diff --git a/apps/web/src/components/editor/media-panel/views/settings.tsx b/apps/web/src/components/editor/media-panel/views/settings.tsx
deleted file mode 100644
index be54a9c4..00000000
--- a/apps/web/src/components/editor/media-panel/views/settings.tsx
+++ /dev/null
@@ -1,332 +0,0 @@
-"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 { FPS_PRESETS } from "@/constants/timeline-constants";
-import { useProjectStore } from "@/stores/project-store";
-import type { BlurIntensity } from "@/types/project";
-import { useEditorStore } from "@/stores/editor-store";
-import { useAspectRatio } from "@/hooks/use-aspect-ratio";
-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 flex-col justify-between h-full">
- <div className="flex-1 p-5">
- <BackgroundView />
- </div>
- <div className="flex flex-col sticky -bottom-0 bg-panel/85 backdrop-blur-lg">
- <Separator />
- <Button className="w-fit h-auto p-5 py-4 !bg-transparent shadow-none text-muted-foreground hover:text-foreground/85 text-xs">
- 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 flex-col justify-between h-full p-0"
- />
- );
-}
-
-function ProjectInfoView() {
- const { activeProject, updateProjectFps, updateCanvasSize } =
- useProjectStore();
- const { canvasPresets } = useEditorStore();
- const { getDisplayName } = useAspectRatio();
-
- const handleAspectRatioChange = (value: string) => {
- const preset = canvasPresets.find((p) => p.name === value);
- if (preset) {
- updateCanvasSize(
- { width: preset.width, height: preset.height },
- "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={getDisplayName()}
- onValueChange={handleAspectRatioChange}
- >
- <SelectTrigger className="bg-panel-accent">
- <SelectValue placeholder="Select an aspect ratio" />
- </SelectTrigger>
- <SelectContent>
- {canvasPresets.map((preset) => (
- <SelectItem key={preset.name} value={preset.name}>
- {preset.name}
- </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(
- "w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary relative overflow-hidden",
- isSelected && "border-2 border-primary"
- )}
- 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="text-xs text-white bg-black/50 px-1 rounded">
- {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(
- "w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary",
- isColorBackground &&
- bg === currentBackgroundColor &&
- "border-2 border-primary"
- )}
- 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<Array<{ label: string; value: BlurIntensity }>>(
- () => [
- { label: "Light", value: 4 },
- { label: "Medium", value: 8 },
- { label: "Heavy", value: 18 },
- ],
- []
- );
-
- const handleBlurSelect = useCallback(
- async (blurIntensity: BlurIntensity) => {
- 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(blur.value)}
- />
- )),
- [blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect]
- );
-
- return (
- <div className="flex flex-col gap-4 h-full">
- <PropertyGroup title="Blur" defaultExpanded={false}>
- <div className="grid grid-cols-4 gap-2 w-full">{blurPreviews}</div>
- </PropertyGroup>
-
- <PropertyGroup title="Colors" defaultExpanded={false}>
- <div className="grid grid-cols-4 gap-2 w-full">
- <div className="w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary flex items-center justify-center">
- <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 grid-cols-4 gap-2 w-full">
- <BackgroundPreviews
- backgrounds={patternCraftGradients}
- currentBackgroundColor={currentBackgroundColor}
- isColorBackground={isColorBackground}
- handleColorSelect={handleColorSelect}
- />
- </div>
- </PropertyGroup>
-
- <PropertyGroup title="Syntax UI" defaultExpanded={false}>
- <div className="grid grid-cols-4 gap-2 w-full">
- <BackgroundPreviews
- backgrounds={syntaxUIGradients}
- currentBackgroundColor={currentBackgroundColor}
- isColorBackground={isColorBackground}
- handleColorSelect={handleColorSelect}
- />
- </div>
- </PropertyGroup>
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/media-panel/views/sounds.tsx b/apps/web/src/components/editor/media-panel/views/sounds.tsx
deleted file mode 100644
index 700eaea7..00000000
--- a/apps/web/src/components/editor/media-panel/views/sounds.tsx
+++ /dev/null
@@ -1,562 +0,0 @@
-"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>
- );
-}
diff --git a/apps/web/src/components/editor/media-panel/views/stickers.tsx b/apps/web/src/components/editor/media-panel/views/stickers.tsx
deleted file mode 100644
index f67450cc..00000000
--- a/apps/web/src/components/editor/media-panel/views/stickers.tsx
+++ /dev/null
@@ -1,612 +0,0 @@
-"use client";
-
-import { useEffect, useState, useMemo } from "react";
-import { useStickersStore } from "@/stores/stickers-store";
-import { useMediaStore } from "@/stores/media-store";
-import { useProjectStore } from "@/stores/project-store";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { usePlaybackStore } from "@/stores/playback-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 { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import Image from "next/image";
-import type { MediaFile } from "@/types/media";
-import { DraggableMediaItem } from "@/components/ui/draggable-item";
-import { InputWithBack } from "@/components/ui/input-with-back";
-import { StickerCategory } from "@/stores/stickers-store";
-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 { activeProject } = useProjectStore();
- const { addElementAtTime } = useTimelineStore();
- const { currentTime } = usePlaybackStore();
- const { addMediaFile } = useMediaStore();
- 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>
- );
-}
diff --git a/apps/web/src/components/editor/media-panel/views/text.tsx b/apps/web/src/components/editor/media-panel/views/text.tsx
deleted file mode 100644
index 22fc2413..00000000
--- a/apps/web/src/components/editor/media-panel/views/text.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-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>
- );
-}