summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components/editor/panels')
-rw-r--r--apps/web/src/components/editor/panels/assets/drag-overlay.tsx61
-rw-r--r--apps/web/src/components/editor/panels/assets/draggable-item.tsx244
-rw-r--r--apps/web/src/components/editor/panels/assets/index.tsx52
-rw-r--r--apps/web/src/components/editor/panels/assets/tabbar.tsx116
-rw-r--r--apps/web/src/components/editor/panels/assets/views/captions.tsx152
-rw-r--r--apps/web/src/components/editor/panels/assets/views/media.tsx675
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings.tsx394
-rw-r--r--apps/web/src/components/editor/panels/assets/views/sounds.tsx574
-rw-r--r--apps/web/src/components/editor/panels/assets/views/stickers.tsx615
-rw-r--r--apps/web/src/components/editor/panels/assets/views/text.tsx46
-rw-r--r--apps/web/src/components/editor/panels/panel-base-view.tsx85
-rw-r--r--apps/web/src/components/editor/panels/preview/index.tsx124
-rw-r--r--apps/web/src/components/editor/panels/properties/audio-properties.tsx5
-rw-r--r--apps/web/src/components/editor/panels/properties/index.tsx68
-rw-r--r--apps/web/src/components/editor/panels/properties/property-item.tsx91
-rw-r--r--apps/web/src/components/editor/panels/properties/text-properties.tsx410
-rw-r--r--apps/web/src/components/editor/panels/properties/video-properties.tsx9
17 files changed, 3721 insertions, 0 deletions
diff --git a/apps/web/src/components/editor/panels/assets/drag-overlay.tsx b/apps/web/src/components/editor/panels/assets/drag-overlay.tsx
new file mode 100644
index 00000000..c6b0861c
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/drag-overlay.tsx
@@ -0,0 +1,61 @@
+import { HugeiconsIcon } from "@hugeicons/react";
+import { UploadIcon } from "@hugeicons/core-free-icons";
+
+interface MediaDragOverlayProps {
+ isVisible: boolean;
+ isProcessing?: boolean;
+ progress?: number;
+ onClick?: () => void;
+}
+
+export function MediaDragOverlay({
+ isVisible,
+ isProcessing = false,
+ progress = 0,
+ onClick,
+}: MediaDragOverlayProps) {
+ if (!isVisible) return null;
+
+ const handleClick = ({
+ event,
+ }: {
+ event: React.MouseEvent<HTMLButtonElement>;
+ }) => {
+ if (isProcessing || !onClick) return;
+ event.preventDefault();
+ event.stopPropagation();
+ onClick();
+ };
+
+ return (
+ <button
+ className="bg-foreground/5 hover:bg-foreground/10 flex size-full flex-col items-center justify-center gap-4 rounded-lg p-8 text-center"
+ type="button"
+ disabled={isProcessing || !onClick}
+ onClick={(event) => handleClick({ event })}
+ >
+ <div className="flex items-center justify-center">
+ <HugeiconsIcon icon={UploadIcon} className="text-foreground size-10" />
+ </div>
+
+ <div className="space-y-2">
+ <p className="text-muted-foreground max-w-sm text-xs">
+ {isProcessing
+ ? `Processing your files (${progress}%)`
+ : "Drag and drop videos, photos, and audio files here"}
+ </p>
+ </div>
+
+ {isProcessing && (
+ <div className="w-full max-w-xs">
+ <div className="bg-muted/50 h-2 w-full rounded-full">
+ <div
+ className="bg-primary h-2 rounded-full"
+ style={{ width: `${progress}%` }}
+ />
+ </div>
+ </div>
+ )}
+ </button>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx
new file mode 100644
index 00000000..10cb1f8c
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx
@@ -0,0 +1,244 @@
+"use client";
+
+import { Plus } from "lucide-react";
+import { type ReactNode, useEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { AspectRatio } from "@/components/ui/aspect-ratio";
+import { Button } from "@/components/ui/button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { useEditor } from "@/hooks/use-editor";
+import { clearDragData, setDragData } from "@/lib/drag-data";
+import type { TimelineDragData } from "@/types/drag";
+import { cn } from "@/utils/ui";
+
+export interface DraggableItemProps {
+ name: string;
+ preview: ReactNode;
+ dragData: TimelineDragData;
+ onDragStart?: ({ e }: { e: React.DragEvent }) => void;
+ onAddToTimeline?: ({ currentTime }: { currentTime: number }) => void;
+ aspectRatio?: number;
+ className?: string;
+ containerClassName?: string;
+ shouldShowPlusOnDrag?: boolean;
+ shouldShowLabel?: boolean;
+ isRounded?: boolean;
+ variant?: "card" | "compact";
+ isDraggable?: boolean;
+ isHighlighted?: boolean;
+}
+
+export function DraggableItem({
+ name,
+ preview,
+ dragData,
+ onDragStart,
+ onAddToTimeline,
+ aspectRatio = 16 / 9,
+ className = "",
+ containerClassName,
+ shouldShowPlusOnDrag = true,
+ shouldShowLabel = true,
+ isRounded = true,
+ variant = "card",
+ isDraggable = true,
+ isHighlighted = false,
+}: DraggableItemProps) {
+ const [isDragging, setIsDragging] = useState(false);
+ const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
+ const dragRef = useRef<HTMLDivElement>(null);
+ const editor = useEditor();
+ const highlightClassName = `ring-2 ring-primary bg-primary/10 ${isRounded ? "rounded-sm" : ""}`;
+
+ const handleAddToTimeline = () => {
+ onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() });
+ };
+
+ const emptyImg = new window.Image();
+ emptyImg.src =
+ "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
+
+ useEffect(() => {
+ if (!isDragging) return;
+
+ const handleDragOver = (e: DragEvent) => {
+ setDragPosition({ x: e.clientX, y: e.clientY });
+ };
+
+ document.addEventListener("dragover", handleDragOver);
+
+ return () => {
+ document.removeEventListener("dragover", handleDragOver);
+ };
+ }, [isDragging]);
+
+ const handleDragStart = (e: React.DragEvent) => {
+ e.dataTransfer.setDragImage(emptyImg, 0, 0);
+
+ setDragData({ dataTransfer: e.dataTransfer, dragData });
+ e.dataTransfer.effectAllowed = "copy";
+
+ setDragPosition({ x: e.clientX, y: e.clientY });
+ setIsDragging(true);
+
+ onDragStart?.({ e });
+ };
+
+ const handleDragEnd = () => {
+ setIsDragging(false);
+ clearDragData();
+ };
+
+ return (
+ <>
+ {variant === "card" ? (
+ <div
+ ref={dragRef}
+ className={cn("group relative", containerClassName ?? "size-28")}
+ >
+ <div
+ className={cn(
+ "relative flex h-auto w-full cursor-default flex-col gap-1 p-1",
+ className,
+ isHighlighted && highlightClassName,
+ )}
+ >
+ <AspectRatio
+ ratio={aspectRatio}
+ className={cn(
+ "bg-panel-accent relative overflow-hidden",
+ isRounded && "rounded-sm",
+ isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
+ )}
+ draggable={isDraggable}
+ onDragStart={isDraggable ? handleDragStart : undefined}
+ onDragEnd={isDraggable ? handleDragEnd : undefined}
+ >
+ {preview}
+ {!isDragging && (
+ <PlusButton
+ className="opacity-0 group-hover:opacity-100"
+ onClick={handleAddToTimeline}
+ />
+ )}
+ </AspectRatio>
+ {shouldShowLabel && (
+ <span
+ className="text-muted-foreground w-full truncate text-left text-[0.7rem]"
+ title={name}
+ >
+ <span className="sr-only">{name}</span>
+ <span aria-hidden="true">
+ {name.length > 8
+ ? `${name.slice(0, 16)}...${name.slice(-3)}`
+ : name}
+ </span>
+ </span>
+ )}
+ </div>
+ </div>
+ ) : (
+ <div
+ ref={dragRef}
+ className={cn(
+ "group relative w-full",
+ isHighlighted && highlightClassName,
+ )}
+ >
+ <button
+ type="button"
+ className={cn(
+ "flex h-8 w-full cursor-default items-center gap-3 px-1",
+ isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
+ className,
+ )}
+ draggable={isDraggable}
+ onDragStart={isDraggable ? handleDragStart : undefined}
+ onDragEnd={isDraggable ? handleDragEnd : undefined}
+ >
+ <div className="size-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]">
+ {preview}
+ </div>
+ <span className="w-full flex-1 truncate text-sm text-left">{name}</span>
+ </button>
+ </div>
+ )}
+
+ {isDraggable &&
+ isDragging &&
+ typeof document !== "undefined" &&
+ createPortal(
+ <div
+ className="pointer-events-none fixed z-9999"
+ style={{
+ left: dragPosition.x - 40,
+ top: dragPosition.y - 40,
+ }}
+ >
+ <div className="w-[80px]">
+ <AspectRatio
+ ratio={1}
+ className="ring-primary relative overflow-hidden rounded-md shadow-2xl ring-3"
+ >
+ <div className="size-full [&_img]:size-full [&_img]:rounded-none [&_img]:object-cover">
+ {preview}
+ </div>
+ {shouldShowPlusOnDrag && (
+ <PlusButton
+ onClick={handleAddToTimeline}
+ tooltipText="Add to timeline or drag to position"
+ />
+ )}
+ </AspectRatio>
+ </div>
+ </div>,
+ document.body,
+ )}
+ </>
+ );
+}
+
+function PlusButton({
+ className,
+ onClick,
+ tooltipText,
+}: {
+ className?: string;
+ onClick?: () => void;
+ tooltipText?: string;
+}) {
+ const button = (
+ <Button
+ size="icon"
+ className={cn(
+ "bg-background hover:bg-panel text-foreground absolute right-2 bottom-2 size-5",
+ className,
+ )}
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onClick?.();
+ }}
+ title={tooltipText}
+ >
+ <Plus />
+ </Button>
+ );
+
+ if (tooltipText) {
+ return (
+ <Tooltip>
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
+ <TooltipContent>
+ <p>{tooltipText}</p>
+ </TooltipContent>
+ </Tooltip>
+ );
+ }
+
+ return button;
+}
diff --git a/apps/web/src/components/editor/panels/assets/index.tsx b/apps/web/src/components/editor/panels/assets/index.tsx
new file mode 100644
index 00000000..9161cb1d
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/index.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import { Separator } from "@/components/ui/separator";
+import { type Tab, useAssetsPanelStore } from "@/stores/assets-panel-store";
+import { TabBar } from "./tabbar";
+import { Captions } from "./views/captions";
+import { MediaView } from "./views/media";
+import { SettingsView } from "./views/settings";
+import { SoundsView } from "./views/sounds";
+import { StickersView } from "./views/stickers";
+import { TextView } from "./views/text";
+
+export function AssetsPanel() {
+ const { activeTab } = useAssetsPanelStore();
+
+ const viewMap: Record<Tab, React.ReactNode> = {
+ media: <MediaView />,
+ sounds: <SoundsView />,
+ text: <TextView />,
+ stickers: <StickersView />,
+ effects: (
+ <div className="text-muted-foreground p-4">
+ Effects view coming soon...
+ </div>
+ ),
+ transitions: (
+ <div className="text-muted-foreground p-4">
+ Transitions view coming soon...
+ </div>
+ ),
+ captions: <Captions />,
+ filters: (
+ <div className="text-muted-foreground p-4">
+ Filters view coming soon...
+ </div>
+ ),
+ adjustment: (
+ <div className="text-muted-foreground p-4">
+ Adjustment view coming soon...
+ </div>
+ ),
+ settings: <SettingsView />,
+ };
+
+ return (
+ <div className="bg-panel flex h-full">
+ <TabBar />
+ <Separator orientation="vertical" />
+ <div className="flex-1 overflow-hidden">{viewMap[activeTab]}</div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/tabbar.tsx b/apps/web/src/components/editor/panels/assets/tabbar.tsx
new file mode 100644
index 00000000..2c950fdc
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/tabbar.tsx
@@ -0,0 +1,116 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { cn } from "@/utils/ui";
+import {
+ TAB_KEYS,
+ tabs,
+ useAssetsPanelStore,
+} from "@/stores/assets-panel-store";
+
+export function TabBar() {
+ const { activeTab, setActiveTab } = useAssetsPanelStore();
+ const scrollRef = useRef<HTMLDivElement>(null);
+ const [showTopFade, setShowTopFade] = useState(false);
+ const [showBottomFade, setShowBottomFade] = useState(false);
+
+ const checkScrollPosition = useCallback(() => {
+ const element = scrollRef.current;
+ if (!element) return;
+
+ const { scrollTop, scrollHeight, clientHeight } = element;
+ setShowTopFade(scrollTop > 0);
+ setShowBottomFade(scrollTop < scrollHeight - clientHeight - 1);
+ }, []);
+
+ useEffect(() => {
+ const element = scrollRef.current;
+ if (!element) return;
+
+ checkScrollPosition();
+ element.addEventListener("scroll", checkScrollPosition);
+
+ const resizeObserver = new ResizeObserver(checkScrollPosition);
+ resizeObserver.observe(element);
+
+ return () => {
+ element.removeEventListener("scroll", checkScrollPosition);
+ resizeObserver.disconnect();
+ };
+ }, [checkScrollPosition]);
+
+ return (
+ <div className="relative flex">
+ <div
+ ref={scrollRef}
+ className="scrollbar-hidden relative flex size-full flex-col items-center justify-start gap-5 overflow-y-auto px-4 py-4"
+ >
+ {TAB_KEYS.map((tabKey) => {
+ const tab = tabs[tabKey];
+ return (
+ <Tooltip key={tabKey} delayDuration={10}>
+ <TooltipTrigger asChild>
+ <button
+ type="button"
+ aria-label={tab.label}
+ className={cn(
+ "flex cursor-pointer flex-col items-center gap-0.5 [&>svg]:size-4.5! opacity-100 hover:opacity-75",
+ activeTab === tabKey
+ ? "text-primary !opacity-100"
+ : "text-muted-foreground",
+ )}
+ onClick={() => setActiveTab(tabKey)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ setActiveTab(tabKey);
+ }
+ }}
+ >
+ <tab.icon className=" " />
+ </button>
+ </TooltipTrigger>
+ <TooltipContent
+ side="right"
+ align="center"
+ variant="sidebar"
+ sideOffset={8}
+ >
+ <div className="text-foreground text-sm leading-none font-medium">
+ {tab.label}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ );
+ })}
+ </div>
+
+ <FadeOverlay direction="top" show={showTopFade} />
+ <FadeOverlay direction="bottom" show={showBottomFade} />
+ </div>
+ );
+}
+
+function FadeOverlay({
+ direction,
+ show,
+}: {
+ direction: "top" | "bottom";
+ show: boolean;
+}) {
+ return (
+ <div
+ className={cn(
+ "pointer-events-none absolute right-0 left-0 h-6",
+ direction === "top" && show
+ ? "from-panel top-0 bg-gradient-to-b to-transparent"
+ : "from-panel bottom-0 bg-gradient-to-t to-transparent",
+ )}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/captions.tsx b/apps/web/src/components/editor/panels/assets/views/captions.tsx
new file mode 100644
index 00000000..306bad2a
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/captions.tsx
@@ -0,0 +1,152 @@
+import { Button } from "@/components/ui/button";
+import { PropertyGroup } from "@/components/editor/panels/properties/property-item";
+import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { useState, useRef } from "react";
+import { extractTimelineAudio } from "@/lib/media/mediabunny";
+import { useEditor } from "@/hooks/use-editor";
+import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
+import { TRANSCRIPTION_LANGUAGES } from "@/constants/transcription-constants";
+import type {
+ TranscriptionLanguage,
+ TranscriptionProgress,
+} from "@/types/transcription";
+import { transcriptionService } from "@/services/transcription";
+import { decodeAudioToFloat32 } from "@/lib/media/audio";
+import { buildCaptionChunks } from "@/lib/transcription/caption";
+import { Spinner } from "@/components/ui/spinner";
+
+export function Captions() {
+ const [selectedLanguage, setSelectedLanguage] =
+ useState<TranscriptionLanguage>("auto");
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [processingStep, setProcessingStep] = useState("");
+ const [error, setError] = useState<string | null>(null);
+ const containerRef = useRef<HTMLDivElement>(null);
+ const editor = useEditor();
+
+ const handleProgress = (progress: TranscriptionProgress) => {
+ if (progress.status === "loading-model") {
+ setProcessingStep(`Loading model ${Math.round(progress.progress)}%`);
+ } else if (progress.status === "transcribing") {
+ setProcessingStep("Transcribing...");
+ }
+ };
+
+ const handleGenerateTranscript = async () => {
+ try {
+ setIsProcessing(true);
+ setError(null);
+ setProcessingStep("Extracting audio...");
+
+ const audioBlob = await extractTimelineAudio({
+ tracks: editor.timeline.getTracks(),
+ mediaAssets: editor.media.getAssets(),
+ totalDuration: editor.timeline.getTotalDuration(),
+ });
+
+ setProcessingStep("Preparing audio...");
+ const { samples } = await decodeAudioToFloat32({ audioBlob });
+
+ const result = await transcriptionService.transcribe({
+ audioData: samples,
+ language: selectedLanguage === "auto" ? undefined : selectedLanguage,
+ onProgress: handleProgress,
+ });
+
+ setProcessingStep("Generating captions...");
+ const captionChunks = buildCaptionChunks({ segments: result.segments });
+
+ const captionTrackId = editor.timeline.addTrack({
+ type: "text",
+ index: 0,
+ });
+
+ for (let i = 0; i < captionChunks.length; i++) {
+ const caption = captionChunks[i];
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId: captionTrackId },
+ element: {
+ ...DEFAULT_TEXT_ELEMENT,
+ name: `Caption ${i + 1}`,
+ content: caption.text,
+ duration: caption.duration,
+ startTime: caption.startTime,
+ fontSize: 65,
+ fontWeight: "bold",
+ },
+ });
+ }
+ } catch (error) {
+ console.error("Transcription failed:", error);
+ setError(
+ error instanceof Error ? error.message : "An unexpected error occurred",
+ );
+ } finally {
+ setIsProcessing(false);
+ setProcessingStep("");
+ }
+ };
+
+ const handleLanguageChange = ({ value }: { value: string }) => {
+ if (value === "auto") {
+ setSelectedLanguage("auto");
+ return;
+ }
+
+ const matchedLanguage = TRANSCRIPTION_LANGUAGES.find(
+ (language) => language.code === value,
+ );
+ if (!matchedLanguage) return;
+ setSelectedLanguage(matchedLanguage.code);
+ };
+
+ return (
+ <BaseView
+ ref={containerRef}
+ className="flex h-full flex-col justify-between"
+ >
+ <PropertyGroup title="Language">
+ <Select
+ value={selectedLanguage}
+ onValueChange={(value) => handleLanguageChange({ value })}
+ >
+ <SelectTrigger className="bg-panel-accent h-8 w-full text-xs">
+ <SelectValue placeholder="Select a language" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="auto">Auto detect</SelectItem>
+ {TRANSCRIPTION_LANGUAGES.map((language) => (
+ <SelectItem key={language.code} value={language.code}>
+ {language.name}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </PropertyGroup>
+
+ <div className="flex flex-col gap-4">
+ {error && (
+ <div className="bg-destructive/10 border-destructive/20 rounded-md border p-3">
+ <p className="text-destructive text-sm">{error}</p>
+ </div>
+ )}
+
+ <Button
+ className="w-full"
+ onClick={handleGenerateTranscript}
+ disabled={isProcessing}
+ >
+ {isProcessing && <Spinner className="mr-1" />}
+ {isProcessing ? processingStep : "Generate transcript"}
+ </Button>
+ </div>
+ </BaseView>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/media.tsx b/apps/web/src/components/editor/panels/assets/views/media.tsx
new file mode 100644
index 00000000..76ae0064
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/media.tsx
@@ -0,0 +1,675 @@
+"use client";
+
+import Image from "next/image";
+import { useMemo, useState } from "react";
+import { toast } from "sonner";
+import { MediaDragOverlay } from "@/components/editor/panels/assets/drag-overlay";
+import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
+import { Button } from "@/components/ui/button";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuTrigger,
+} from "@/components/ui/context-menu";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { useEditor } from "@/hooks/use-editor";
+import { useFileUpload } from "@/hooks/use-file-upload";
+import { useRevealItem } from "@/hooks/use-reveal-item";
+import { processMediaAssets } from "@/lib/media/processing";
+import {
+ buildImageElement,
+ buildUploadAudioElement,
+ buildVideoElement,
+} from "@/lib/timeline/element-utils";
+import { useAssetsPanelStore } from "@/stores/assets-panel-store";
+import type { MediaAsset } from "@/types/assets";
+import type { CreateTimelineElement } from "@/types/timeline";
+import { cn } from "@/utils/ui";
+import {
+ CloudUploadIcon,
+ GridViewIcon,
+ LeftToRightListDashIcon,
+ SortingOneNineIcon,
+ Image02Icon,
+ MusicNote03Icon,
+ Video01Icon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
+
+export function MediaView() {
+ const editor = useEditor();
+ const mediaFiles = editor.media.getAssets();
+ const activeProject = editor.project.getActive();
+
+ const { mediaViewMode, setMediaViewMode, highlightMediaId, clearHighlight } =
+ useAssetsPanelStore();
+ const { highlightedId, registerElement } = useRevealItem(
+ highlightMediaId,
+ clearHighlight,
+ );
+
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [progress, setProgress] = useState(0);
+ const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">(
+ "name",
+ );
+ const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
+
+ const processFiles = async ({ files }: { files: FileList }) => {
+ if (!files || files.length === 0) return;
+ if (!activeProject) {
+ toast.error("No active project");
+ return;
+ }
+
+ setIsProcessing(true);
+ setProgress(0);
+ try {
+ const processedAssets = await processMediaAssets({
+ files,
+ onProgress: (progress: { progress: number }) =>
+ setProgress(progress.progress),
+ });
+ for (const asset of processedAssets) {
+ await editor.media.addMediaAsset({
+ projectId: activeProject.metadata.id,
+ asset,
+ });
+ }
+ } 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: (files) => processFiles({ files }),
+ });
+
+ const handleRemove = async ({
+ event,
+ id,
+ }: {
+ event: React.MouseEvent;
+ id: string;
+ }) => {
+ event.stopPropagation();
+
+ if (!activeProject) {
+ toast.error("No active project");
+ return;
+ }
+
+ await editor.media.removeMediaAsset({
+ projectId: activeProject.metadata.id,
+ id,
+ });
+ };
+
+ const addElementAtTime = ({
+ asset,
+ startTime,
+ }: {
+ asset: MediaAsset;
+ startTime: number;
+ }): boolean => {
+ const element = createElementFromMedia({ asset, startTime });
+ editor.timeline.insertElement({
+ element,
+ placement: { mode: "auto" },
+ });
+ return true;
+ };
+
+ const filteredMediaItems = useMemo(() => {
+ const filtered = mediaFiles.filter((item) => !item.ephemeral);
+
+ 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) => {
+ previews.set(item.id, <MediaPreview item={item} />);
+ previews.set(
+ `compact-${item.id}`,
+ <MediaPreview item={item} variant="compact" />,
+ );
+ });
+
+ return previews;
+ }, [filteredMediaItems]);
+
+ const renderPreview = (item: MediaAsset) => previewComponents.get(item.id);
+ const renderCompactPreview = (item: MediaAsset) =>
+ previewComponents.get(`compact-${item.id}`);
+
+ return (
+ <>
+ <input {...fileInputProps} />
+
+ <div
+ className={`relative flex h-full flex-col gap-1 ${isDragOver ? "bg-accent/30" : ""}`}
+ {...dragProps}
+ >
+ <div className="bg-panel py-2 px-4 flex items-center justify-between border-b">
+ <span className="text-muted-foreground text-sm">Assets</span>
+ <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" ? (
+ <HugeiconsIcon icon={LeftToRightListDashIcon} />
+ ) : (
+ <HugeiconsIcon icon={GridViewIcon} />
+ )}
+ </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"
+ >
+ <HugeiconsIcon icon={SortingOneNineIcon} />
+ </Button>
+ </DropdownMenuTrigger>
+ </TooltipTrigger>
+ <DropdownMenuContent align="end">
+ <SortMenuItem
+ label="Name"
+ sortKey="name"
+ currentSortBy={sortBy}
+ currentSortOrder={sortOrder}
+ onSort={({ key }) => {
+ if (sortBy === key) {
+ setSortOrder(sortOrder === "asc" ? "desc" : "asc");
+ } else {
+ setSortBy(key);
+ setSortOrder("asc");
+ }
+ }}
+ />
+ <SortMenuItem
+ label="Type"
+ sortKey="type"
+ currentSortBy={sortBy}
+ currentSortOrder={sortOrder}
+ onSort={({ key }) => {
+ if (sortBy === key) {
+ setSortOrder(sortOrder === "asc" ? "desc" : "asc");
+ } else {
+ setSortBy(key);
+ setSortOrder("asc");
+ }
+ }}
+ />
+ <SortMenuItem
+ label="Duration"
+ sortKey="duration"
+ currentSortBy={sortBy}
+ currentSortOrder={sortOrder}
+ onSort={({ key }) => {
+ if (sortBy === key) {
+ setSortOrder(sortOrder === "asc" ? "desc" : "asc");
+ } else {
+ setSortBy(key);
+ setSortOrder("asc");
+ }
+ }}
+ />
+ <SortMenuItem
+ label="File size"
+ sortKey="size"
+ currentSortBy={sortBy}
+ currentSortOrder={sortOrder}
+ onSort={({ key }) => {
+ if (sortBy === key) {
+ setSortOrder(sortOrder === "asc" ? "desc" : "asc");
+ } else {
+ setSortBy(key);
+ setSortOrder("asc");
+ }
+ }}
+ />
+ </DropdownMenuContent>
+ </DropdownMenu>
+ <TooltipContent>
+ <p>
+ Sort by {sortBy} (
+ {sortOrder === "asc" ? "ascending" : "descending"})
+ </p>
+ </TooltipContent>
+ </Tooltip>
+ </Tooltip>
+ </TooltipProvider>
+ <Button
+ variant="outline"
+ onClick={openFilePicker}
+ disabled={isProcessing}
+ size="sm"
+ className="items-center justify-center gap-1.5 ml-1.5"
+ >
+ <HugeiconsIcon icon={CloudUploadIcon} />
+ Upload
+ </Button>
+ </div>
+ </div>
+
+ <div className="scrollbar-thin size-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}
+ />
+ ) : mediaViewMode === "grid" ? (
+ <GridView
+ items={filteredMediaItems}
+ renderPreview={renderPreview}
+ onRemove={handleRemove}
+ onAddToTimeline={addElementAtTime}
+ highlightedId={highlightedId}
+ registerElement={registerElement}
+ />
+ ) : (
+ <ListView
+ items={filteredMediaItems}
+ renderPreview={renderCompactPreview}
+ onRemove={handleRemove}
+ onAddToTimeline={addElementAtTime}
+ highlightedId={highlightedId}
+ registerElement={registerElement}
+ />
+ )}
+ </div>
+ </div>
+ </div>
+ </>
+ );
+}
+
+function MediaItemWithContextMenu({
+ item,
+ children,
+ onRemove,
+}: {
+ item: MediaAsset;
+ children: React.ReactNode;
+ onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
+}) {
+ return (
+ <ContextMenu>
+ <ContextMenuTrigger>{children}</ContextMenuTrigger>
+ <ContextMenuContent>
+ <ContextMenuItem>Export clips</ContextMenuItem>
+ <ContextMenuItem
+ variant="destructive"
+ onClick={(event) => onRemove({ event, id: item.id })}
+ >
+ Delete
+ </ContextMenuItem>
+ </ContextMenuContent>
+ </ContextMenu>
+ );
+}
+
+function GridView({
+ items,
+ renderPreview,
+ onRemove,
+ onAddToTimeline,
+ highlightedId,
+ registerElement,
+}: {
+ items: MediaAsset[];
+ renderPreview: (item: MediaAsset) => React.ReactNode;
+ onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
+ onAddToTimeline: ({
+ asset,
+ startTime,
+ }: {
+ asset: MediaAsset;
+ startTime: number;
+ }) => boolean;
+ highlightedId: string | null;
+ registerElement: (id: string, element: HTMLElement | null) => void;
+}) {
+ return (
+ <div
+ className="grid gap-2"
+ style={{
+ gridTemplateColumns: "repeat(auto-fill, 160px)",
+ }}
+ >
+ {items.map((item) => (
+ <div key={item.id} ref={(el) => registerElement(item.id, el)}>
+ <MediaItemWithContextMenu item={item} onRemove={onRemove}>
+ <DraggableItem
+ name={item.name}
+ preview={renderPreview(item)}
+ dragData={{
+ id: item.id,
+ type: "media",
+ mediaType: item.type,
+ name: item.name,
+ }}
+ shouldShowPlusOnDrag={false}
+ onAddToTimeline={({ currentTime }) =>
+ onAddToTimeline({ asset: item, startTime: currentTime })
+ }
+ isRounded={false}
+ variant="card"
+ isHighlighted={highlightedId === item.id}
+ />
+ </MediaItemWithContextMenu>
+ </div>
+ ))}
+ </div>
+ );
+}
+
+function ListView({
+ items,
+ renderPreview,
+ onRemove,
+ onAddToTimeline,
+ highlightedId,
+ registerElement,
+}: {
+ items: MediaAsset[];
+ renderPreview: (item: MediaAsset) => React.ReactNode;
+ onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
+ onAddToTimeline: ({
+ asset,
+ startTime,
+ }: {
+ asset: MediaAsset;
+ startTime: number;
+ }) => boolean;
+ highlightedId: string | null;
+ registerElement: (id: string, element: HTMLElement | null) => void;
+}) {
+ return (
+ <div className="space-y-1">
+ {items.map((item) => (
+ <div key={item.id} ref={(el) => registerElement(item.id, el)}>
+ <MediaItemWithContextMenu item={item} onRemove={onRemove}>
+ <DraggableItem
+ name={item.name}
+ preview={renderPreview(item)}
+ dragData={{
+ id: item.id,
+ type: "media",
+ mediaType: item.type,
+ name: item.name,
+ }}
+ shouldShowPlusOnDrag={false}
+ onAddToTimeline={({ currentTime }) =>
+ onAddToTimeline({ asset: item, startTime: currentTime })
+ }
+ variant="compact"
+ isHighlighted={highlightedId === item.id}
+ />
+ </MediaItemWithContextMenu>
+ </div>
+ ))}
+ </div>
+ );
+}
+
+const formatDuration = ({ duration }: { duration: number }) => {
+ const min = Math.floor(duration / 60);
+ const sec = Math.floor(duration % 60);
+ return `${min}:${sec.toString().padStart(2, "0")}`;
+};
+
+function MediaDurationBadge({ duration }: { duration?: number }) {
+ if (!duration) return null;
+
+ return (
+ <div className="absolute right-1 bottom-1 rounded bg-black/70 px-1 text-xs text-white">
+ {formatDuration({ duration })}
+ </div>
+ );
+}
+
+function MediaDurationLabel({ duration }: { duration?: number }) {
+ if (!duration) return null;
+
+ return (
+ <span className="text-xs opacity-70">{formatDuration({ duration })}</span>
+ );
+}
+
+function MediaTypePlaceholder({
+ icon,
+ label,
+ duration,
+ variant,
+}: {
+ icon: IconSvgElement;
+ label: string;
+ duration?: number;
+ variant: "muted" | "bordered";
+}) {
+ const iconClassName = cn("size-6", variant === "bordered" && "mb-1");
+
+ return (
+ <div
+ className={cn(
+ "text-muted-foreground flex size-full flex-col items-center justify-center rounded",
+ variant === "muted" ? "bg-muted/30" : "border",
+ )}
+ >
+ <HugeiconsIcon icon={icon} className={iconClassName} />
+ <span className="text-xs">{label}</span>
+ <MediaDurationLabel duration={duration} />
+ </div>
+ );
+}
+
+function MediaPreview({
+ item,
+ variant = "grid",
+}: {
+ item: MediaAsset;
+ variant?: "grid" | "compact";
+}) {
+ const shouldShowDurationBadge = variant === "grid";
+
+ if (item.type === "image") {
+ return (
+ <div className="relative flex size-full items-center justify-center">
+ <Image
+ src={item.url ?? ""}
+ alt={item.name}
+ fill
+ sizes="100vw"
+ className="object-cover"
+ loading="lazy"
+ unoptimized
+ />
+ </div>
+ );
+ }
+
+ if (item.type === "video") {
+ if (item.thumbnailUrl) {
+ return (
+ <div className="relative size-full">
+ <Image
+ src={item.thumbnailUrl}
+ alt={item.name}
+ fill
+ sizes="100vw"
+ className="rounded object-cover"
+ loading="lazy"
+ unoptimized
+ />
+ {shouldShowDurationBadge ? (
+ <MediaDurationBadge duration={item.duration} />
+ ) : null}
+ </div>
+ );
+ }
+
+ return (
+ <MediaTypePlaceholder
+ icon={Video01Icon}
+ label="Video"
+ duration={item.duration}
+ variant="muted"
+ />
+ );
+ }
+
+ if (item.type === "audio") {
+ return (
+ <MediaTypePlaceholder
+ icon={MusicNote03Icon}
+ label="Audio"
+ duration={item.duration}
+ variant="bordered"
+ />
+ );
+ }
+
+ return (
+ <MediaTypePlaceholder icon={Image02Icon} label="Unknown" variant="muted" />
+ );
+}
+
+function SortMenuItem({
+ label,
+ sortKey,
+ currentSortBy,
+ currentSortOrder,
+ onSort,
+}: {
+ label: string;
+ sortKey: "name" | "type" | "duration" | "size";
+ currentSortBy: string;
+ currentSortOrder: "asc" | "desc";
+ onSort: ({ key }: { key: "name" | "type" | "duration" | "size" }) => void;
+}) {
+ const isActive = currentSortBy === sortKey;
+ const arrow = isActive ? (currentSortOrder === "asc" ? "↑" : "↓") : "";
+
+ return (
+ <DropdownMenuItem onClick={() => onSort({ key: sortKey })}>
+ {label} {arrow}
+ </DropdownMenuItem>
+ );
+}
+
+function createElementFromMedia({
+ asset,
+ startTime,
+}: {
+ asset: MediaAsset;
+ startTime: number;
+}): CreateTimelineElement {
+ const duration =
+ asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+
+ switch (asset.type) {
+ case "video":
+ return buildVideoElement({
+ mediaId: asset.id,
+ name: asset.name,
+ duration,
+ startTime,
+ });
+ case "image":
+ return buildImageElement({
+ mediaId: asset.id,
+ name: asset.name,
+ duration,
+ startTime,
+ });
+ case "audio":
+ return buildUploadAudioElement({
+ mediaId: asset.id,
+ name: asset.name,
+ duration,
+ startTime,
+ });
+ default:
+ throw new Error(`Unsupported media type: ${asset.type}`);
+ }
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/settings.tsx b/apps/web/src/components/editor/panels/assets/views/settings.tsx
new file mode 100644
index 00000000..b8c54510
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/settings.tsx
@@ -0,0 +1,394 @@
+"use client";
+
+import Image from "next/image";
+import { memo, useCallback, useMemo } from "react";
+import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ BLUR_INTENSITY_PRESETS,
+ DEFAULT_BLUR_INTENSITY,
+ DEFAULT_COLOR,
+ FPS_PRESETS,
+} from "@/constants/project-constants";
+import { patternCraftGradients } from "@/data/colors/pattern-craft";
+import { colors } from "@/data/colors/solid";
+import { syntaxUIGradients } from "@/data/colors/syntax-ui";
+import { useEditor } from "@/hooks/use-editor";
+import { useEditorStore } from "@/stores/editor-store";
+import type { TProject } from "@/types/project";
+import { dimensionToAspectRatio } from "@/utils/geometry";
+import { cn } from "@/utils/ui";
+import {
+ PropertyGroup,
+ PropertyItem,
+ PropertyItemLabel,
+ PropertyItemValue,
+} from "@/components/editor/panels/properties/property-item";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { DropperIcon } from "@hugeicons/core-free-icons";
+
+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 */}
+ {/* <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: TProject }) {
+ const { canvasSize } = activeProject.settings;
+
+ return {
+ width: canvasSize.width,
+ height: canvasSize.height,
+ };
+}
+
+function ProjectInfoView() {
+ const editor = useEditor();
+ const activeProject = editor.project.getActive();
+ 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 originalCanvasSize = activeProject.settings.originalCanvasSize ?? null;
+ const presetIndex = findPresetIndexByAspectRatio({
+ presets: canvasPresets,
+ targetAspectRatio: currentAspectRatio,
+ });
+ const originalPresetValue = "original";
+ const selectedPresetValue =
+ presetIndex !== -1 ? presetIndex.toString() : originalPresetValue;
+
+ const handleAspectRatioChange = ({ value }: { value: string }) => {
+ if (value === originalPresetValue) {
+ const canvasSize = originalCanvasSize ?? currentCanvasSize;
+ editor.project.updateSettings({
+ settings: { canvasSize },
+ });
+ return;
+ }
+ const index = parseInt(value, 10);
+ const preset = canvasPresets[index];
+ if (preset) {
+ editor.project.updateSettings({ settings: { canvasSize: preset } });
+ }
+ };
+
+ const handleFpsChange = (value: string) => {
+ const fps = parseFloat(value);
+ editor.project.updateSettings({ settings: { fps } });
+ };
+
+ return (
+ <div className="flex flex-col gap-4">
+ <PropertyItem direction="column">
+ <PropertyItemLabel>Name</PropertyItemLabel>
+ <PropertyItemValue>{activeProject.metadata.name}</PropertyItemValue>
+ </PropertyItem>
+
+ <PropertyItem direction="column">
+ <PropertyItemLabel>Aspect ratio</PropertyItemLabel>
+ <PropertyItemValue>
+ <Select
+ value={selectedPresetValue}
+ onValueChange={(value) => handleAspectRatioChange({ value })}
+ >
+ <SelectTrigger className="bg-panel-accent">
+ <SelectValue placeholder="Select an aspect ratio" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value={originalPresetValue}>Original</SelectItem>
+ {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.settings.fps.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;
+ }) => (
+ <button
+ 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}
+ type="button"
+ aria-label={`Select ${blur.label} blur`}
+ >
+ <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 right-1 bottom-1 left-1 text-center">
+ <span className="rounded bg-black/50 px-1 text-xs text-white">
+ {blur.label}
+ </span>
+ </div>
+ </button>
+ ),
+);
+
+BlurPreview.displayName = "BlurPreview";
+
+const BackgroundPreviews = memo(
+ ({
+ backgrounds,
+ currentBackgroundColor,
+ isColorBackground,
+ handleColorSelect,
+ useBackgroundColor = false,
+ }: {
+ backgrounds: string[];
+ currentBackgroundColor: string;
+ isColorBackground: boolean;
+ handleColorSelect: ({ bg }: { bg: string }) => void;
+ useBackgroundColor?: boolean;
+ }) => {
+ return useMemo(
+ () =>
+ backgrounds.map((bg, index) => (
+ <button
+ 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 })}
+ type="button"
+ aria-label={`Select background ${useBackgroundColor ? bg : index + 1}`}
+ />
+ )),
+ [
+ backgrounds,
+ isColorBackground,
+ currentBackgroundColor,
+ handleColorSelect,
+ useBackgroundColor,
+ ],
+ );
+ },
+);
+
+BackgroundPreviews.displayName = "BackgroundPreviews";
+
+function BackgroundView() {
+ const editor = useEditor();
+ const activeProject = editor.project.getActive();
+ const blurLevels = useMemo(() => BLUR_INTENSITY_PRESETS, []);
+
+ const handleBlurSelect = useCallback(
+ async ({ blurIntensity }: { blurIntensity: number }) => {
+ await editor.project.updateSettings({
+ settings: { background: { type: "blur", blurIntensity } },
+ });
+ },
+ [editor.project],
+ );
+
+ const handleColorSelect = useCallback(
+ async ({ color }: { color: string }) => {
+ await editor.project.updateSettings({
+ settings: { background: { type: "color", color } },
+ });
+ },
+ [editor.project],
+ );
+
+ const currentBlurIntensity =
+ activeProject.settings.background.type === "blur"
+ ? activeProject.settings.background.blurIntensity
+ : DEFAULT_BLUR_INTENSITY;
+
+ const currentBackgroundColor =
+ activeProject.settings.background.type === "color"
+ ? activeProject.settings.background.color
+ : DEFAULT_COLOR;
+
+ const isBlurBackground = activeProject.settings.background.type === "blur";
+ const isColorBackground = activeProject.settings.background.type === "color";
+
+ const blurPreviews = useMemo(
+ () =>
+ 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">
+ <HugeiconsIcon icon={DropperIcon} className="size-4" />
+ </div>
+ <BackgroundPreviews
+ backgrounds={colors}
+ currentBackgroundColor={currentBackgroundColor}
+ isColorBackground={isColorBackground}
+ handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
+ 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={({ bg }) => handleColorSelect({ color: bg })}
+ />
+ </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={({ bg }) => handleColorSelect({ color: bg })}
+ />
+ </div>
+ </PropertyGroup>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/sounds.tsx b/apps/web/src/components/editor/panels/assets/views/sounds.tsx
new file mode 100644
index 00000000..84a5d0bf
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/sounds.tsx
@@ -0,0 +1,574 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Input } from "@/components/ui/input";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Separator } from "@/components/ui/separator";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
+import { useSoundSearch } from "@/hooks/use-sound-search";
+import { useSoundsStore } from "@/stores/sounds-store";
+import type { SavedSound, SoundEffect } from "@/types/sounds";
+import { cn } from "@/utils/ui";
+import {
+ FavouriteIcon,
+ FilterMailIcon,
+ PauseIcon,
+ PlayIcon,
+ PlusSignIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+
+export function SoundsView() {
+ return (
+ <div className="flex h-full flex-col">
+ <Tabs defaultValue="sound-effects" className="flex h-full flex-col">
+ <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="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0"
+ >
+ <SoundEffectsView />
+ </TabsContent>
+ <TabsContent
+ value="saved"
+ className="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0"
+ >
+ <SavedSoundsView />
+ </TabsContent>
+ <TabsContent
+ value="songs"
+ className="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0"
+ >
+ <SongsView />
+ </TabsContent>
+ </Tabs>
+ </div>
+ );
+}
+
+function SoundEffectsView() {
+ const {
+ topSoundEffects,
+ isLoading,
+ searchQuery,
+ setSearchQuery,
+ scrollPosition,
+ setScrollPosition,
+ loadSavedSounds,
+ showCommercialOnly,
+ toggleCommercialFilter,
+ hasLoaded,
+ setTopSoundEffects,
+ setLoading,
+ setError,
+ setHasLoaded,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ } = useSoundsStore();
+ const {
+ results: searchResults,
+ isLoading: isSearching,
+ loadMore,
+ hasNextPage,
+ isLoadingMore,
+ } = useSoundSearch({
+ query: searchQuery,
+ commercialOnly: showCommercialOnly,
+ });
+
+ 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();
+ }, [loadSavedSounds]);
+
+ useEffect(() => {
+ if (hasLoaded) {
+ return;
+ }
+
+ let shouldIgnore = false;
+
+ const fetchTopSounds = async () => {
+ try {
+ if (!shouldIgnore) {
+ setLoading({ loading: true });
+ setError({ error: null });
+ }
+
+ const response = await fetch(
+ "/api/sounds/search?page_size=50&sort=downloads",
+ );
+
+ if (!shouldIgnore) {
+ if (!response.ok) {
+ throw new Error(`Failed to fetch: ${response.status}`);
+ }
+
+ const data = await response.json();
+ setTopSoundEffects({ sounds: data.results });
+ setHasLoaded({ loaded: true });
+
+ setCurrentPage({ page: 1 });
+ setHasNextPage({ hasNext: !!data.next });
+ setTotalCount({ count: data.count });
+ }
+ } catch (error) {
+ if (!shouldIgnore) {
+ console.error("Failed to fetch top sounds:", error);
+ setError({
+ error:
+ error instanceof Error ? error.message : "Failed to load sounds",
+ });
+ }
+ } finally {
+ if (!shouldIgnore) {
+ setLoading({ loading: false });
+ }
+ }
+ };
+
+ const timeoutId = setTimeout(fetchTopSounds, 100, {});
+
+ return () => {
+ shouldIgnore = true;
+ clearTimeout(timeoutId);
+ };
+ }, [
+ hasLoaded,
+ setTopSoundEffects,
+ setLoading,
+ setError,
+ setHasLoaded,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ ]);
+
+ useEffect(() => {
+ if (!scrollAreaRef.current || scrollPosition <= 0) {
+ return;
+ }
+
+ const restoreScrollPosition = () => {
+ scrollAreaRef.current?.scrollTo({ top: scrollPosition });
+ };
+
+ const timeoutId = setTimeout(restoreScrollPosition, 100, {});
+
+ return () => clearTimeout(timeoutId);
+ }, [scrollPosition, scrollAreaRef]);
+
+ const handleScrollWithPosition = ({
+ currentTarget,
+ }: React.UIEvent<HTMLDivElement>) => {
+ const { scrollTop } = currentTarget;
+ setScrollPosition({ position: scrollTop });
+ handleScroll({ currentTarget } as React.UIEvent<HTMLDivElement>);
+ };
+
+ const displayedSounds = searchQuery ? searchResults : topSoundEffects;
+
+ const playSound = ({ sound }: { sound: SoundEffect }) => {
+ if (playingId === sound.id) {
+ audioElement?.pause();
+ setPlayingId(null);
+ return;
+ }
+
+ audioElement?.pause();
+
+ if (sound.previewUrl) {
+ const audio = new Audio(sound.previewUrl);
+ audio.addEventListener("ended", () => {
+ setPlayingId(null);
+ });
+ audio.addEventListener("error", () => {
+ setPlayingId(null);
+ });
+ audio.play().catch((error) => {
+ console.error("Failed to play sound preview:", error);
+ setPlayingId(null);
+ });
+
+ setAudioElement(audio);
+ setPlayingId(sound.id);
+ }
+ };
+
+ return (
+ <div className="mt-1 flex h-full flex-col gap-5">
+ <div className="flex items-center gap-3">
+ <Input
+ placeholder="Search sound effects"
+ className="bg-panel-accent w-full"
+ containerClassName="w-full"
+ value={searchQuery}
+ onChange={({ currentTarget }) =>
+ setSearchQuery({ query: currentTarget.value })
+ }
+ showClearIcon
+ onClear={() => setSearchQuery({ query: "" })}
+ />
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ className={cn(showCommercialOnly && "text-primary")}
+ >
+ <HugeiconsIcon icon={FilterMailIcon} />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-56">
+ <DropdownMenuCheckboxItem
+ checked={showCommercialOnly}
+ onCheckedChange={() => toggleCommercialFilter()}
+ >
+ Show only commercially licensed
+ </DropdownMenuCheckboxItem>
+ <div className="text-muted-foreground px-2 py-1.5 text-xs">
+ {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="h-full flex-1"
+ 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}
+ />
+ ))}
+ {!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 py-4 text-center text-sm">
+ Loading more sounds...
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+ </div>
+ );
+}
+
+function SavedSoundsView() {
+ const {
+ savedSounds,
+ isLoadingSavedSounds,
+ savedSoundsError,
+ loadSavedSounds,
+ clearSavedSounds,
+ } = useSoundsStore();
+
+ const [playingId, setPlayingId] = useState<number | null>(null);
+ const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(
+ null,
+ );
+
+ const [showClearDialog, setShowClearDialog] = useState(false);
+
+ useEffect(() => {
+ loadSavedSounds();
+ }, [loadSavedSounds]);
+
+ const playSound = ({ sound }: { sound: SoundEffect }) => {
+ if (playingId === sound.id) {
+ audioElement?.pause();
+ setPlayingId(null);
+ return;
+ }
+
+ audioElement?.pause();
+
+ if (sound.previewUrl) {
+ const audio = new Audio(sound.previewUrl);
+ audio.addEventListener("ended", () => {
+ setPlayingId(null);
+ });
+ audio.addEventListener("error", () => {
+ setPlayingId(null);
+ });
+ audio.play().catch((error) => {
+ console.error("Failed to play sound preview:", error);
+ setPlayingId(null);
+ });
+
+ setAudioElement(audio);
+ setPlayingId(sound.id);
+ }
+ };
+
+ const convertToSoundEffect = ({
+ savedSound,
+ }: {
+ 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 h-full items-center justify-center">
+ <div className="text-muted-foreground text-sm">
+ Loading saved sounds...
+ </div>
+ </div>
+ );
+ }
+
+ if (savedSoundsError) {
+ return (
+ <div className="flex h-full items-center justify-center">
+ <div className="text-destructive text-sm">
+ Error: {savedSoundsError}
+ </div>
+ </div>
+ );
+ }
+
+ if (savedSounds.length === 0) {
+ return (
+ <div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
+ <HugeiconsIcon
+ icon={FavouriteIcon}
+ className="text-muted-foreground size-10"
+ />
+ <div className="flex flex-col gap-2 text-center">
+ <p className="text-lg font-medium">No saved sounds</p>
+ <p className="text-muted-foreground text-sm text-balance">
+ Click the heart icon on any sound to save it here
+ </p>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="mt-1 flex h-full flex-col gap-5">
+ <div className="flex items-center justify-between">
+ <p className="text-muted-foreground text-sm">
+ {savedSounds.length} saved{" "}
+ {savedSounds.length === 1 ? "sound" : "sounds"}
+ </p>
+ <Dialog open={showClearDialog} onOpenChange={setShowClearDialog}>
+ <DialogTrigger asChild>
+ <Button
+ variant="text"
+ size="sm"
+ className="text-muted-foreground hover:text-destructive h-auto !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 ({
+ stopPropagation,
+ }: React.MouseEvent<HTMLButtonElement>) => {
+ stopPropagation();
+ await clearSavedSounds();
+ setShowClearDialog(false);
+ }}
+ >
+ Clear all sounds
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+
+ <div className="relative h-full overflow-hidden">
+ <ScrollArea className="h-full flex-1">
+ <div className="flex flex-col gap-4">
+ {savedSounds.map((sound) => (
+ <AudioItem
+ key={sound.id}
+ sound={convertToSoundEffect({ savedSound: sound })}
+ isPlaying={playingId === sound.id}
+ onPlay={playSound}
+ />
+ ))}
+ </div>
+ </ScrollArea>
+ </div>
+ </div>
+ );
+}
+
+function SongsView() {
+ return <div>Songs</div>;
+}
+
+interface AudioItemProps {
+ sound: SoundEffect;
+ isPlaying: boolean;
+ onPlay: ({ sound }: { sound: SoundEffect }) => void;
+}
+
+function AudioItem({ sound, isPlaying, onPlay }: AudioItemProps) {
+ const { addSoundToTimeline, isSoundSaved, toggleSavedSound } =
+ useSoundsStore();
+ const isSaved = isSoundSaved({ soundId: sound.id });
+
+ const handleClick = () => {
+ onPlay({ sound });
+ };
+
+ const handleSaveClick = ({
+ stopPropagation,
+ }: React.MouseEvent<HTMLButtonElement>) => {
+ stopPropagation();
+ toggleSavedSound({ soundEffect: sound });
+ };
+
+ const handleAddToTimeline = async ({
+ stopPropagation,
+ }: React.MouseEvent<HTMLButtonElement>) => {
+ stopPropagation();
+ await addSoundToTimeline({ sound });
+ };
+
+ return (
+ <div className="group flex items-center gap-3 opacity-100 hover:opacity-75">
+ <button
+ type="button"
+ className="flex min-w-0 flex-1 items-center gap-3 text-left"
+ onClick={handleClick}
+ >
+ <div className="bg-accent relative flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-md">
+ <div className="from-primary/20 absolute inset-0 bg-gradient-to-br to-transparent" />
+ {isPlaying ? (
+ <HugeiconsIcon icon={PauseIcon} className="size-5" />
+ ) : (
+ <HugeiconsIcon icon={PlayIcon} className="size-5" />
+ )}
+ </div>
+
+ <div className="min-w-0 flex-1 overflow-hidden">
+ <p className="truncate text-sm font-medium">{sound.name}</p>
+ <span className="text-muted-foreground block truncate text-xs">
+ {sound.username}
+ </span>
+ </div>
+ </button>
+
+ <div className="flex items-center gap-3 pr-2">
+ <Button
+ variant="text"
+ size="icon"
+ className="text-muted-foreground hover:text-foreground w-auto !opacity-100"
+ onClick={handleAddToTimeline}
+ title="Add to timeline"
+ >
+ <HugeiconsIcon icon={PlusSignIcon} />
+ </Button>
+ <Button
+ variant="text"
+ size="icon"
+ className={`hover:text-foreground w-auto !opacity-100 ${
+ isSaved
+ ? "text-red-500 hover:text-red-600"
+ : "text-muted-foreground"
+ }`}
+ onClick={handleSaveClick}
+ title={isSaved ? "Remove from saved" : "Save sound"}
+ >
+ <HugeiconsIcon
+ icon={FavouriteIcon}
+ className={`${isSaved ? "fill-current" : ""}`}
+ />
+ </Button>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/stickers.tsx b/apps/web/src/components/editor/panels/assets/views/stickers.tsx
new file mode 100644
index 00000000..6b48e775
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx
@@ -0,0 +1,615 @@
+"use client";
+
+import Image from "next/image";
+import type { CSSProperties } from "react";
+import { useEffect, useMemo, useState } from "react";
+import { toast } from "sonner";
+import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
+import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
+import { Button } from "@/components/ui/button";
+import { InputWithBack } from "@/components/ui/input-with-back";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { STICKER_CATEGORIES } from "@/constants/stickers-constants";
+import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
+import {
+ buildIconSvgUrl,
+ getIconSvgUrl,
+ ICONIFY_HOSTS,
+ POPULAR_COLLECTIONS,
+} from "@/lib/iconify-api";
+import { useStickersStore } from "@/stores/stickers-store";
+import type { StickerCategory } from "@/types/stickers";
+import { cn } from "@/utils/ui";
+import {
+ ArrowRightIcon,
+ HappyIcon,
+ ClockIcon,
+ LayoutGridIcon,
+ MultiplicationSignIcon,
+ SparklesIcon,
+ HashtagIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { Spinner } from "@/components/ui/spinner";
+
+function isStickerCategory(value: string): value is StickerCategory {
+ return STICKER_CATEGORIES.includes(value as StickerCategory);
+}
+
+export function StickersView() {
+ const { selectedCategory, setSelectedCategory } = useStickersStore();
+
+ return (
+ <BaseView
+ value={selectedCategory}
+ onValueChange={(v) => {
+ if (isStickerCategory(v)) {
+ setSelectedCategory({ category: v });
+ }
+ }}
+ tabs={[
+ {
+ value: "all",
+ label: "All",
+ icon: <HugeiconsIcon icon={LayoutGridIcon} className="size-3" />,
+ content: <StickersContentView category="all" />,
+ },
+ {
+ value: "general",
+ label: "Icons",
+ icon: <HugeiconsIcon icon={SparklesIcon} className="size-3" />,
+ content: <StickersContentView category="general" />,
+ },
+ {
+ value: "brands",
+ label: "Brands",
+ icon: <HugeiconsIcon icon={HashtagIcon} className="size-3" />,
+ content: <StickersContentView category="brands" />,
+ },
+ {
+ value: "emoji",
+ label: "Emoji",
+ icon: <HugeiconsIcon icon={HappyIcon} className="size-3" />,
+ content: <StickersContentView category="emoji" />,
+ },
+ ]}
+ className="flex h-full flex-col overflow-hidden p-0"
+ />
+ );
+}
+
+function StickerGrid({
+ icons,
+ onAdd,
+ addingSticker,
+ capSize = false,
+}: {
+ icons: string[];
+ onAdd: (iconName: string) => void;
+ addingSticker: string | null;
+ capSize?: boolean;
+}) {
+ const gridStyle: CSSProperties & {
+ "--sticker-min": string;
+ "--sticker-max"?: string;
+ } = {
+ 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": "96px",
+ ...(capSize ? { "--sticker-max": "160px" } : {}),
+ };
+
+ return (
+ <div className="grid gap-2" style={gridStyle}>
+ {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 }: { 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({ prefix: collection.prefix })}
+ />
+ ))}
+ </div>
+ );
+}
+
+function EmptyView({ message }: { message: string }) {
+ return (
+ <div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
+ <HugeiconsIcon
+ icon={HappyIcon}
+ className="text-muted-foreground size-10"
+ />
+ <div className="flex flex-col gap-2 text-center">
+ <p className="text-lg font-medium">No stickers found</p>
+ <p className="text-muted-foreground text-sm 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();
+ }
+ }, [collections, loadCollections]);
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ if (localSearchQuery !== searchQuery) {
+ setSearchQuery({ query: localSearchQuery });
+ if (localSearchQuery.trim()) {
+ searchStickers({ query: localSearchQuery });
+ }
+ }
+ }, 500);
+
+ return () => clearTimeout(timer);
+ }, [localSearchQuery, searchQuery, searchStickers, setSearchQuery]);
+
+ 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="mt-1 flex h-full flex-col gap-5 p-4">
+ <div className="space-y-3">
+ <InputWithBack
+ isExpanded={isInCollection}
+ setIsExpanded={(expanded) => {
+ if (!expanded && isInCollection) {
+ setSelectedCollection({ collection: 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="h-full flex-1"
+ ref={scrollAreaRef}
+ onScrollCapture={handleScroll}
+ >
+ <div className="flex h-full flex-col gap-4">
+ {recentStickers.length > 0 && viewMode === "browse" && (
+ <div className="h-full">
+ <div className="mb-2 flex items-center gap-2">
+ <HugeiconsIcon
+ icon={ClockIcon}
+ className="text-muted-foreground size-4"
+ />
+ <span className="text-sm font-medium">Recent</span>
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <button
+ type="button"
+ onClick={clearRecentStickers}
+ className="hover:bg-accent ml-auto flex size-5 items-center justify-center rounded p-0"
+ >
+ <HugeiconsIcon
+ icon={MultiplicationSignIcon}
+ className="text-muted-foreground size-3"
+ />
+ </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">
+ <Spinner className="text-muted-foreground size-6" />
+ </div>
+ ) : showCollectionItems ? (
+ <StickerGrid
+ icons={iconsToDisplay}
+ onAdd={handleAddSticker}
+ addingSticker={addingSticker}
+ />
+ ) : (
+ <div className="flex items-center justify-center py-8">
+ <Spinner className="text-muted-foreground size-6" />
+ </div>
+ )}
+ </div>
+ )}
+
+ {viewMode === "search" && (
+ <div className="h-full">
+ {isSearching ? (
+ <div className="flex items-center justify-center py-8">
+ <Spinner className="text-muted-foreground size-6" />
+ </div>
+ ) : searchResults?.icons.length ? (
+ <>
+ <div className="mb-3 flex items-center justify-between">
+ <span className="text-muted-foreground text-sm">
+ {searchResults.total} results
+ </span>
+ </div>
+ <StickerGrid
+ icons={iconsToDisplay}
+ onAdd={handleAddSticker}
+ addingSticker={addingSticker}
+ capSize
+ />
+ </>
+ ) : searchQuery ? (
+ <div className="flex flex-col items-center justify-center gap-3 py-8">
+ <EmptyView
+ message={`No stickers found for "${searchQuery}"`}
+ />
+ {category !== "all" && (
+ <Button
+ variant="outline"
+ onClick={() => {
+ const q = localSearchQuery || searchQuery;
+ if (q) {
+ setSearchQuery({ query: q });
+ }
+ setSelectedCategory({ category: "all" });
+ if (q) {
+ searchStickers({ query: q });
+ }
+ }}
+ >
+ Search in all icons
+ </Button>
+ )}
+ </div>
+ ) : null}
+ </div>
+ )}
+
+ {viewMode === "browse" && !selectedCollection && (
+ <div className="h-full space-y-4">
+ {isLoadingCollections ? (
+ <div className="flex items-center justify-center py-8">
+ <Spinner className="text-muted-foreground size-6" />
+ </div>
+ ) : (
+ <>
+ {category !== "all" && (
+ <div className="h-full">
+ <CollectionGrid
+ collections={filteredCollections}
+ onSelectCollection={({ prefix }) =>
+ setSelectedCollection({ collection: prefix })
+ }
+ />
+ </div>
+ )}
+
+ {category === "all" && filteredCollections.length > 0 && (
+ <div className="h-full">
+ <CollectionGrid
+ collections={filteredCollections.slice(
+ 0,
+ collectionsToShow,
+ )}
+ onSelectCollection={({ prefix }) =>
+ setSelectedCollection({ collection: prefix })
+ }
+ />
+ </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="h-auto justify-between rounded-md py-2"
+ onClick={onClick}
+ >
+ <div className="text-left">
+ <p className="font-medium">{title}</p>
+ <p className="text-muted-foreground text-xs">{subtitle}</p>
+ </div>
+ <HugeiconsIcon icon={ArrowRightIcon} className="size-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(() => {
+ if (!iconName) {
+ return;
+ }
+ setImageError(false);
+ setHostIndex(0);
+ }, [iconName]);
+
+ const displayName = iconName.split(":")[1] || iconName;
+ const collectionPrefix = iconName.split(":")[0];
+
+ const preview = imageError ? (
+ <div className="flex size-full items-center justify-center p-2">
+ <span className="text-muted-foreground text-center text-xs break-all">
+ {displayName}
+ </span>
+ </div>
+ ) : (
+ <div className="flex size-full items-center justify-center p-4">
+ <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="size-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 && "pointer-events-none opacity-50",
+ )}
+ >
+ <DraggableItem
+ name={displayName}
+ preview={preview}
+ dragData={{
+ id: iconName,
+ type: "sticker",
+ name: displayName,
+ iconName,
+ }}
+ onAddToTimeline={() => onAdd(iconName)}
+ aspectRatio={1}
+ shouldShowLabel={false}
+ isRounded={true}
+ variant="card"
+ className=""
+ containerClassName="w-full"
+ />
+ {isAdding && (
+ <div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60">
+ <Spinner className="size-6 text-white" />
+ </div>
+ )}
+ </div>
+ </TooltipTrigger>
+ <TooltipContent>
+ <div className="space-y-1">
+ <p className="font-medium">{displayName}</p>
+ <p className="text-muted-foreground text-xs">{collectionPrefix}</p>
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/text.tsx b/apps/web/src/components/editor/panels/assets/views/text.tsx
new file mode 100644
index 00000000..6ccf5d98
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/text.tsx
@@ -0,0 +1,46 @@
+import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
+import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
+import { useEditor } from "@/hooks/use-editor";
+import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
+import { buildTextElement } from "@/lib/timeline/element-utils";
+
+export function TextView() {
+ const editor = useEditor();
+
+ const handleAddToTimeline = ({ currentTime }: { currentTime: number }) => {
+ const activeScene = editor.scenes.getActiveScene();
+ if (!activeScene) return;
+
+ const element = buildTextElement({
+ raw: DEFAULT_TEXT_ELEMENT,
+ startTime: currentTime,
+ });
+
+ editor.timeline.insertElement({
+ element,
+ placement: { mode: "auto" },
+ });
+ };
+
+ return (
+ <BaseView>
+ <DraggableItem
+ name="Default text"
+ preview={
+ <div className="bg-panel-accent flex size-full items-center justify-center 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={handleAddToTimeline}
+ shouldShowLabel={false}
+ />
+ </BaseView>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/panel-base-view.tsx b/apps/web/src/components/editor/panels/panel-base-view.tsx
new file mode 100644
index 00000000..b16d1db6
--- /dev/null
+++ b/apps/web/src/components/editor/panels/panel-base-view.tsx
@@ -0,0 +1,85 @@
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Separator } from "@/components/ui/separator";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { cn } from "@/utils/ui";
+
+interface PanelBaseViewProps {
+ children?: React.ReactNode;
+ defaultTab?: string;
+ value?: string;
+ onValueChange?: (value: string) => void;
+ tabs?: {
+ value: string;
+ label: string;
+ icon?: React.ReactNode;
+ content: React.ReactNode;
+ }[];
+ className?: string;
+ ref?: React.RefObject<HTMLDivElement>;
+}
+
+function ViewContent({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return (
+ <ScrollArea className="flex-1">
+ <div className={cn("p-5", className)}>{children}</div>
+ </ScrollArea>
+ );
+}
+
+export function PanelBaseView({
+ children,
+ defaultTab,
+ value,
+ onValueChange,
+ tabs,
+ className = "",
+ ref,
+}: PanelBaseViewProps) {
+ return (
+ <div className={cn("flex h-full flex-col", className)} ref={ref}>
+ {!tabs || tabs.length === 0 ? (
+ <ViewContent className={className}>{children}</ViewContent>
+ ) : (
+ <Tabs
+ defaultValue={defaultTab}
+ value={value}
+ onValueChange={onValueChange}
+ className="flex h-full flex-col"
+ >
+ <div className="bg-panel sticky top-0 z-10">
+ <div className="px-3 pt-3 pb-0">
+ <TabsList>
+ {tabs.map((tab) => (
+ <TabsTrigger key={tab.value} value={tab.value}>
+ {tab.icon ? (
+ <span className="mr-1 inline-flex items-center">
+ {tab.icon}
+ </span>
+ ) : null}
+ {tab.label}
+ </TabsTrigger>
+ ))}
+ </TabsList>
+ </div>
+ <Separator className="mt-3" />
+ </div>
+ {tabs.map((tab) => (
+ <TabsContent
+ key={tab.value}
+ value={tab.value}
+ className="mt-0 flex min-h-0 flex-1 flex-col"
+ >
+ <ViewContent className={className}>{tab.content}</ViewContent>
+ </TabsContent>
+ ))}
+ </Tabs>
+ )}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx
new file mode 100644
index 00000000..5ed9db00
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/index.tsx
@@ -0,0 +1,124 @@
+"use client";
+
+import { useCallback, useMemo, useRef } from "react";
+import useDeepCompareEffect from "use-deep-compare-effect";
+import { useEditor } from "@/hooks/use-editor";
+import { useRafLoop } from "@/hooks/use-raf-loop";
+import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
+import type { RootNode } from "@/services/renderer/nodes/root-node";
+import { buildScene } from "@/services/renderer/scene-builder";
+import { getLastFrameTime } from "@/lib/time";
+
+function usePreviewSize() {
+ const editor = useEditor();
+ const activeProject = editor.project.getActive();
+
+ return {
+ width: activeProject?.settings.canvasSize.width,
+ height: activeProject?.settings.canvasSize.height,
+ };
+}
+
+function RenderTreeController() {
+ const editor = useEditor();
+ const tracks = editor.timeline.getTracks();
+ const mediaAssets = editor.media.getAssets();
+ const activeProject = editor.project.getActive();
+
+ const { width, height } = usePreviewSize();
+
+ useDeepCompareEffect(() => {
+ if (!activeProject) return;
+
+ const duration = editor.timeline.getTotalDuration();
+ const renderTree = buildScene({
+ tracks,
+ mediaAssets,
+ duration,
+ canvasSize: { width, height },
+ background: activeProject.settings.background,
+ });
+
+ editor.renderer.setRenderTree({ renderTree });
+ }, [tracks, mediaAssets, activeProject?.settings.background, width, height]);
+
+ return null;
+}
+
+export function PreviewPanel() {
+ return (
+ <div className="bg-panel relative flex h-full min-h-0 w-full min-w-0 flex-col rounded-sm">
+ <div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2">
+ <PreviewCanvas />
+ <RenderTreeController />
+ </div>
+ </div>
+ );
+}
+
+function PreviewCanvas() {
+ const ref = useRef<HTMLCanvasElement>(null);
+ const lastFrameRef = useRef(-1);
+ const lastSceneRef = useRef<RootNode | null>(null);
+ const renderingRef = useRef(false);
+ const { width, height } = usePreviewSize();
+ const editor = useEditor();
+ const activeProject = editor.project.getActive();
+
+ const renderer = useMemo(() => {
+ return new CanvasRenderer({
+ width,
+ height,
+ fps: activeProject.settings.fps,
+ });
+ }, [width, height, activeProject.settings.fps]);
+
+ const renderTree = editor.renderer.getRenderTree();
+
+ const render = useCallback(() => {
+ if (ref.current && renderTree && !renderingRef.current) {
+ const time = editor.playback.getCurrentTime();
+ const lastFrameTime = getLastFrameTime({
+ duration: renderTree.duration,
+ fps: renderer.fps,
+ });
+ const renderTime = Math.min(time, lastFrameTime);
+ const frame = Math.floor(renderTime * renderer.fps);
+
+ if (
+ frame !== lastFrameRef.current ||
+ renderTree !== lastSceneRef.current
+ ) {
+ renderingRef.current = true;
+ lastSceneRef.current = renderTree;
+ lastFrameRef.current = frame;
+ renderer
+ .renderToCanvas({
+ node: renderTree,
+ time: renderTime,
+ targetCanvas: ref.current,
+ })
+ .then(() => {
+ renderingRef.current = false;
+ });
+ }
+ }
+ }, [renderer, renderTree, editor.playback]);
+
+ useRafLoop(render);
+
+ return (
+ <canvas
+ ref={ref}
+ width={width}
+ height={height}
+ className="block max-h-full max-w-full border"
+ style={{
+ background:
+ activeProject.settings.background.type === "blur"
+ ? "transparent"
+ : activeProject?.settings.background.color,
+ }}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/audio-properties.tsx b/apps/web/src/components/editor/panels/properties/audio-properties.tsx
new file mode 100644
index 00000000..0b83a4b6
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/audio-properties.tsx
@@ -0,0 +1,5 @@
+import type { AudioElement } from "@/types/timeline";
+
+export function AudioProperties({ _element }: { _element: AudioElement }) {
+ return <div className="space-y-4 p-5">Audio properties</div>;
+}
diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx
new file mode 100644
index 00000000..3a552b93
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/index.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { AudioProperties } from "./audio-properties";
+import { VideoProperties } from "./video-properties";
+import { TextProperties } from "./text-properties";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { Settings05Icon } from "@hugeicons/core-free-icons";
+import { useEditor } from "@/hooks/use-editor";
+import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
+
+export function PropertiesPanel() {
+ const editor = useEditor();
+ const { selectedElements } = useElementSelection();
+
+ const elementsWithTracks = editor.timeline.getElementsWithTracks({
+ elements: selectedElements,
+ });
+
+ return (
+ <>
+ {selectedElements.length > 0 ? (
+ <ScrollArea className="bg-panel h-full rounded-sm">
+ {elementsWithTracks.map(({ track, element }) => {
+ if (element.type === "text") {
+ return (
+ <div key={element.id}>
+ <TextProperties element={element} trackId={track.id} />
+ </div>
+ );
+ }
+ if (element.type === "audio") {
+ return <AudioProperties key={element.id} _element={element} />;
+ }
+ if (element.type === "video" || element.type === "image") {
+ return (
+ <div key={element.id}>
+ <VideoProperties _element={element} />
+ </div>
+ );
+ }
+ return null;
+ })}
+ </ScrollArea>
+ ) : (
+ <EmptyView />
+ )}
+ </>
+ );
+}
+
+function EmptyView() {
+ return (
+ <div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
+ <HugeiconsIcon
+ icon={Settings05Icon}
+ className="text-muted-foreground/75 size-10"
+ strokeWidth={1}
+ />
+ <div className="flex flex-col gap-2 text-center">
+ <p className="text-lg font-medium">It's empty here</p>
+ <p className="text-muted-foreground text-sm text-balance">
+ Click an element on the timeline to edit its properties
+ </p>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/property-item.tsx b/apps/web/src/components/editor/panels/properties/property-item.tsx
new file mode 100644
index 00000000..6d2fe444
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/property-item.tsx
@@ -0,0 +1,91 @@
+import { useState } from "react";
+import { cn } from "@/utils/ui";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { ArrowDownIcon } from "@hugeicons/core-free-icons";
+
+interface PropertyItemProps {
+ direction?: "row" | "column";
+ children: React.ReactNode;
+ className?: string;
+}
+
+export function PropertyItem({
+ direction = "row",
+ children,
+ className,
+}: PropertyItemProps) {
+ return (
+ <div
+ className={cn(
+ "flex gap-2",
+ direction === "row"
+ ? "items-center justify-between gap-6"
+ : "flex-col gap-1.5",
+ className,
+ )}
+ >
+ {children}
+ </div>
+ );
+}
+
+export function PropertyItemLabel({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return (
+ <span className={cn("text-muted-foreground text-xs", className)}>
+ {children}
+ </span>
+ );
+}
+
+export function PropertyItemValue({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return <div className={cn("flex-1 text-sm", className)}>{children}</div>;
+}
+
+interface PropertyGroupProps {
+ title: string;
+ children: React.ReactNode;
+ defaultExpanded?: boolean;
+ className?: string;
+ titleClassName?: string;
+}
+
+export function PropertyGroup({
+ title,
+ children,
+ defaultExpanded = true,
+ className,
+ titleClassName,
+}: PropertyGroupProps) {
+ const [isExpanded, setIsExpanded] = useState(defaultExpanded);
+
+ return (
+ <PropertyItem direction="column" className={cn("gap-3", className)}>
+ <button
+ type="button"
+ className="flex items-center gap-1.5 cursor-pointer"
+ onClick={() => setIsExpanded(!isExpanded)}
+ >
+ <PropertyItemLabel className={cn(titleClassName)}>
+ {title}
+ </PropertyItemLabel>
+ <HugeiconsIcon
+ icon={ArrowDownIcon}
+ className={cn("size-3", !isExpanded && "-rotate-90")}
+ />
+ </button>
+ {isExpanded && <PropertyItemValue>{children}</PropertyItemValue>}
+ </PropertyItem>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/text-properties.tsx b/apps/web/src/components/editor/panels/properties/text-properties.tsx
new file mode 100644
index 00000000..f8c9bf87
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx
@@ -0,0 +1,410 @@
+import { Textarea } from "@/components/ui/textarea";
+import { FontPicker } from "@/components/ui/font-picker";
+import type { FontFamily } from "@/constants/font-constants";
+import type { TextElement } from "@/types/timeline";
+import { Slider } from "@/components/ui/slider";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { useState, useRef } from "react";
+import { PanelBaseView } from "@/components/editor/panels/panel-base-view";
+import {
+ TEXT_PROPERTIES_TABS,
+ isTextPropertiesTab,
+ useTextPropertiesStore,
+} from "@/stores/text-properties-store";
+import {
+ PropertyItem,
+ PropertyItemLabel,
+ PropertyItemValue,
+} from "./property-item";
+import { ColorPicker } from "@/components/ui/color-picker";
+import { cn } from "@/utils/ui";
+import { capitalizeFirstLetter, uppercase } from "@/utils/string";
+import { clamp } from "@/utils/math";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { LayoutGridIcon } from "@hugeicons/core-free-icons";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { useEditor } from "@/hooks/use-editor";
+import { DEFAULT_COLOR } from "@/constants/project-constants";
+
+export function TextProperties({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const { activeTab, setActiveTab } = useTextPropertiesStore();
+ const containerRef = useRef<HTMLDivElement>(null);
+ const [fontSizeInput, setFontSizeInput] = useState(
+ element.fontSize.toString(),
+ );
+ const [opacityInput, setOpacityInput] = useState(
+ Math.round(element.opacity * 100).toString(),
+ );
+
+ const lastSelectedColor = useRef(DEFAULT_COLOR);
+
+ const handleFontSizeChange = ({ value }: { value: string }) => {
+ setFontSizeInput(value);
+
+ if (value.trim() !== "") {
+ const parsed = parseInt(value, 10);
+ const fontSize = Number.isNaN(parsed)
+ ? element.fontSize
+ : clamp({ value: parsed, min: 8, max: 300 });
+ editor.timeline.updateTextElement({
+ trackId,
+ elementId: element.id,
+ updates: { fontSize },
+ });
+ }
+ };
+
+ const handleFontSizeBlur = () => {
+ const parsed = parseInt(fontSizeInput, 10);
+ const fontSize = Number.isNaN(parsed)
+ ? element.fontSize
+ : clamp({ value: parsed, min: 8, max: 300 });
+ setFontSizeInput(fontSize.toString());
+ editor.timeline.updateTextElement({
+ trackId,
+ elementId: element.id,
+ updates: { fontSize },
+ });
+ };
+
+ const handleOpacityChange = ({ value }: { value: string }) => {
+ setOpacityInput(value);
+
+ if (value.trim() !== "") {
+ const parsed = parseInt(value, 10);
+ const opacityPercent = Number.isNaN(parsed)
+ ? Math.round(element.opacity * 100)
+ : clamp({ value: parsed, min: 0, max: 100 });
+ editor.timeline.updateTextElement({
+ trackId,
+ elementId: element.id,
+ updates: { opacity: opacityPercent / 100 },
+ });
+ }
+ };
+
+ const handleOpacityBlur = () => {
+ const parsed = parseInt(opacityInput, 10);
+ const opacityPercent = Number.isNaN(parsed)
+ ? Math.round(element.opacity * 100)
+ : clamp({ value: parsed, min: 0, max: 100 });
+ setOpacityInput(opacityPercent.toString());
+ editor.timeline.updateTextElement({
+ trackId,
+ elementId: element.id,
+ updates: { opacity: opacityPercent / 100 },
+ });
+ };
+
+ const handleColorChange = ({ color }: { color: string }) => {
+ if (color !== "transparent") {
+ lastSelectedColor.current = color;
+ }
+ editor.timeline.updateTextElement({
+ trackId,
+ elementId: element.id,
+ updates: { backgroundColor: color },
+ });
+ };
+
+ const handleTransparentToggle = ({
+ isTransparent,
+ }: {
+ isTransparent: boolean;
+ }) => {
+ const newColor = isTransparent ? "transparent" : lastSelectedColor.current;
+ editor.timeline.updateTextElement({
+ trackId,
+ elementId: element.id,
+ updates: { backgroundColor: newColor },
+ });
+ };
+
+ return (
+ <PanelBaseView
+ defaultTab="transform"
+ value={activeTab}
+ onValueChange={(v) => {
+ if (isTextPropertiesTab(v)) setActiveTab(v);
+ }}
+ ref={containerRef}
+ tabs={TEXT_PROPERTIES_TABS.map((t) => ({
+ value: t.value,
+ label: t.label,
+ content:
+ t.value === "transform" ? (
+ <div className="space-y-6"></div>
+ ) : (
+ <div className="space-y-6">
+ <Textarea
+ placeholder="Name"
+ defaultValue={element.content}
+ className="bg-panel-accent min-h-20"
+ onChange={(e) =>
+ editor.timeline.updateTextElement({
+ trackId,
+ elementId: element.id,
+ updates: { content: e.target.value },
+ })
+ }
+ />
+ <PropertyItem direction="column">
+ <PropertyItemLabel>Font</PropertyItemLabel>
+ <PropertyItemValue>
+ <FontPicker
+ defaultValue={element.fontFamily}
+ onValueChange={(value: FontFamily) =>
+ editor.timeline.updateTextElement({
+ trackId,
+ elementId: element.id,
+ updates: { fontFamily: value },
+ })
+ }
+ />
+ </PropertyItemValue>
+ </PropertyItem>
+ <PropertyItem direction="column">
+ <PropertyItemLabel>Style</PropertyItemLabel>
+ <PropertyItemValue>
+ <div className="flex items-center gap-2">
+ <Button
+ variant={
+ element.fontWeight === "bold" ? "default" : "outline"
+ }
+ size="sm"
+ onClick={() =>
+ editor.timeline.updateTextElement({
+ trackId,
+ elementId: element.id,
+ updates: {
+ fontWeight:
+ element.fontWeight === "bold" ? "normal" : "bold",
+ },
+ })
+ }
+ className="h-8 px-3 font-bold"
+ >
+ B
+ </Button>
+ <Button
+ variant={
+ element.fontStyle === "italic" ? "default" : "outline"
+ }
+ size="sm"
+ onClick={() =>
+ editor.timeline.updateTextElement({
+ trackId,
+ elementId: element.id,
+ updates: {
+ fontStyle:
+ element.fontStyle === "italic"
+ ? "normal"
+ : "italic",
+ },
+ })
+ }
+ className="h-8 px-3 italic"
+ >
+ I
+ </Button>
+ <Button
+ variant={
+ element.textDecoration === "underline"
+ ? "default"
+ : "outline"
+ }
+ size="sm"
+ onClick={() =>
+ editor.timeline.updateTextElement({
+ trackId,
+ elementId: element.id,
+ updates: {
+ textDecoration:
+ element.textDecoration === "underline"
+ ? "none"
+ : "underline",
+ },
+ })
+ }
+ className="h-8 px-3 underline"
+ >
+ U
+ </Button>
+ <Button
+ variant={
+ element.textDecoration === "line-through"
+ ? "default"
+ : "outline"
+ }
+ size="sm"
+ onClick={() =>
+ editor.timeline.updateTextElement({
+ trackId,
+ elementId: element.id,
+ updates: {
+ textDecoration:
+ element.textDecoration === "line-through"
+ ? "none"
+ : "line-through",
+ },
+ })
+ }
+ className="h-8 px-3 line-through"
+ >
+ S
+ </Button>
+ </div>
+ </PropertyItemValue>
+ </PropertyItem>
+ <PropertyItem direction="column">
+ <PropertyItemLabel>Font size</PropertyItemLabel>
+ <PropertyItemValue>
+ <div className="flex items-center gap-2">
+ <Slider
+ value={[element.fontSize]}
+ min={8}
+ max={300}
+ step={1}
+ onValueChange={([value]) => {
+ editor.timeline.updateTextElement({
+ trackId,
+ elementId: element.id,
+ updates: { fontSize: value },
+ });
+ setFontSizeInput(value.toString());
+ }}
+ className="w-full"
+ />
+ <Input
+ type="number"
+ value={fontSizeInput}
+ min={8}
+ max={300}
+ onChange={(e) =>
+ handleFontSizeChange({ value: e.target.value })
+ }
+ onBlur={handleFontSizeBlur}
+ className="bg-panel-accent h-7 w-12 [appearance:textfield] rounded-sm px-2 text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
+ />
+ </div>
+ </PropertyItemValue>
+ </PropertyItem>
+ <PropertyItem direction="column">
+ <PropertyItemLabel>Color</PropertyItemLabel>
+ <PropertyItemValue>
+ <ColorPicker
+ value={uppercase({
+ string: (element.color || "FFFFFF").replace("#", ""),
+ })}
+ onChange={(color) => {
+ editor.timeline.updateTextElement({
+ trackId,
+ elementId: element.id,
+ updates: { color: `#${color}` },
+ });
+ }}
+ containerRef={containerRef}
+ />
+ </PropertyItemValue>
+ </PropertyItem>
+ <PropertyItem direction="column">
+ <PropertyItemLabel>Opacity</PropertyItemLabel>
+ <PropertyItemValue>
+ <div className="flex items-center gap-2">
+ <Slider
+ value={[element.opacity * 100]}
+ min={0}
+ max={100}
+ step={1}
+ onValueChange={([value]) => {
+ editor.timeline.updateTextElement({
+ trackId,
+ elementId: element.id,
+ updates: { opacity: value / 100 },
+ });
+ setOpacityInput(value.toString());
+ }}
+ className="w-full"
+ />
+ <Input
+ type="number"
+ value={opacityInput}
+ min={0}
+ max={100}
+ onChange={(e) =>
+ handleOpacityChange({ value: e.target.value })
+ }
+ onBlur={handleOpacityBlur}
+ className="bg-panel-accent h-7 w-12 [appearance:textfield] rounded-sm text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
+ />
+ </div>
+ </PropertyItemValue>
+ </PropertyItem>
+ <PropertyItem direction="column">
+ <PropertyItemLabel>Background</PropertyItemLabel>
+ <PropertyItemValue>
+ <div className="flex items-center gap-2">
+ <ColorPicker
+ value={capitalizeFirstLetter({
+ string:
+ element.backgroundColor === "transparent"
+ ? lastSelectedColor.current.replace("#", "")
+ : element.backgroundColor.replace("#", ""),
+ })}
+ onChange={(color) =>
+ handleColorChange({ color: `#${color}` })
+ }
+ containerRef={containerRef}
+ className={
+ element.backgroundColor === "transparent"
+ ? "pointer-events-none opacity-50"
+ : ""
+ }
+ />
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="outline"
+ size="icon"
+ onClick={() =>
+ handleTransparentToggle({
+ isTransparent:
+ element.backgroundColor !== "transparent",
+ })
+ }
+ className="bg-panel-accent size-9 overflow-hidden rounded-full p-0"
+ >
+ <HugeiconsIcon
+ icon={LayoutGridIcon}
+ className={cn(
+ "text-foreground",
+ element.backgroundColor === "transparent" &&
+ "text-primary",
+ )}
+ />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Transparent background</TooltipContent>
+ </Tooltip>
+ </div>
+ </PropertyItemValue>
+ </PropertyItem>
+ </div>
+ ),
+ }))}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/video-properties.tsx b/apps/web/src/components/editor/panels/properties/video-properties.tsx
new file mode 100644
index 00000000..bf6d92d8
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/video-properties.tsx
@@ -0,0 +1,9 @@
+import type { ImageElement, VideoElement } from "@/types/timeline";
+
+export function VideoProperties({
+ _element,
+}: {
+ _element: VideoElement | ImageElement;
+}) {
+ return <div className="space-y-4 p-5">Video properties</div>;
+}