summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/assets
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-27 00:55:15 +0100
committerMaze Winther <mazewinther@gmail.com>2026-03-29 15:56:48 +0200
commit4d127a038bf768eded68aae3cfa5c1b12241bdac (patch)
tree16bbb801caf6770546cff4fdb37b6408ec9ead98 /apps/web/src/components/editor/panels/assets
parent82817cb50cb0487350573d6129130aec705f924b (diff)
downloadopencut-4d127a038bf768eded68aae3cfa5c1b12241bdac.tar.gz
chore: normalize line endings
Diffstat (limited to 'apps/web/src/components/editor/panels/assets')
-rw-r--r--apps/web/src/components/editor/panels/assets/drag-overlay.tsx122
-rw-r--r--apps/web/src/components/editor/panels/assets/draggable-item.tsx478
-rw-r--r--apps/web/src/components/editor/panels/assets/tabbar.tsx222
-rw-r--r--apps/web/src/components/editor/panels/assets/views/base-panel.tsx114
-rw-r--r--apps/web/src/components/editor/panels/assets/views/captions.tsx358
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings/background.tsx454
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings/index.tsx738
-rw-r--r--apps/web/src/components/editor/panels/assets/views/sounds.tsx1148
-rw-r--r--apps/web/src/components/editor/panels/assets/views/stickers.tsx940
-rw-r--r--apps/web/src/components/editor/panels/assets/views/text.tsx92
10 files changed, 2333 insertions, 2333 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
index 9d9ae903..00ccc6e4 100644
--- a/apps/web/src/components/editor/panels/assets/drag-overlay.tsx
+++ b/apps/web/src/components/editor/panels/assets/drag-overlay.tsx
@@ -1,61 +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 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>
- );
-}
+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 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
index 8c9acce8..166c81db 100644
--- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx
+++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx
@@ -1,239 +1,239 @@
-"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 "@/lib/timeline/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;
-}
-
-export function DraggableItem({
- name,
- preview,
- dragData,
- onDragStart,
- onAddToTimeline,
- aspectRatio = 16 / 9,
- className = "",
- containerClassName,
- shouldShowPlusOnDrag = true,
- shouldShowLabel = true,
- isRounded = true,
- variant = "card",
- isDraggable = true,
-}: DraggableItemProps) {
- const [isDragging, setIsDragging] = useState(false);
- const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
- const dragRef = useRef<HTMLDivElement>(null);
- const editor = useEditor();
-
- 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 ?? "w-28")}
- >
- <div
- className={cn(
- "relative flex h-auto w-full cursor-default flex-col gap-1 p-",
- className,
- )}
- >
- <AspectRatio
- ratio={aspectRatio}
- className={cn(
- "bg-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", containerClassName)}
- >
- <button
- type="button"
- className={cn(
- "flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none",
- isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
- className,
- )}
- draggable={isDraggable}
- onDragStart={isDraggable ? handleDragStart : undefined}
- onDragEnd={isDraggable ? handleDragEnd : undefined}
- >
- <div className="size-6 shrink-0 overflow-hidden rounded-sm">
- {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-background 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;
-}
+"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 "@/lib/timeline/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;
+}
+
+export function DraggableItem({
+ name,
+ preview,
+ dragData,
+ onDragStart,
+ onAddToTimeline,
+ aspectRatio = 16 / 9,
+ className = "",
+ containerClassName,
+ shouldShowPlusOnDrag = true,
+ shouldShowLabel = true,
+ isRounded = true,
+ variant = "card",
+ isDraggable = true,
+}: DraggableItemProps) {
+ const [isDragging, setIsDragging] = useState(false);
+ const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
+ const dragRef = useRef<HTMLDivElement>(null);
+ const editor = useEditor();
+
+ 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 ?? "w-28")}
+ >
+ <div
+ className={cn(
+ "relative flex h-auto w-full cursor-default flex-col gap-1 p-",
+ className,
+ )}
+ >
+ <AspectRatio
+ ratio={aspectRatio}
+ className={cn(
+ "bg-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", containerClassName)}
+ >
+ <button
+ type="button"
+ className={cn(
+ "flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none",
+ isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
+ className,
+ )}
+ draggable={isDraggable}
+ onDragStart={isDraggable ? handleDragStart : undefined}
+ onDragEnd={isDraggable ? handleDragEnd : undefined}
+ >
+ <div className="size-6 shrink-0 overflow-hidden rounded-sm">
+ {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-background 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/tabbar.tsx b/apps/web/src/components/editor/panels/assets/tabbar.tsx
index a4e072f1..da53bed0 100644
--- a/apps/web/src/components/editor/panels/assets/tabbar.tsx
+++ b/apps/web/src/components/editor/panels/assets/tabbar.tsx
@@ -1,111 +1,111 @@
-"use client";
-
-import { useCallback, useEffect, useRef, useState } from "react";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-import { Button } from "@/components/ui/button";
-import { cn } from "@/utils/ui";
-import {
- TAB_KEYS,
- tabs,
- useAssetsPanelStore,
-} from "@/stores/assets-panel-store";
-
-export function TabBar() {
- const { activeTab, setActiveTab } = useAssetsPanelStore();
- const [showTopFade, setShowTopFade] = useState(false);
- const [showBottomFade, setShowBottomFade] = useState(false);
- const scrollRef = useRef<HTMLDivElement>(null);
-
- 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 p-1 flex-col items-center justify-start gap-0.5 overflow-y-auto"
- >
- {TAB_KEYS.map((tabKey) => {
- const tab = tabs[tabKey];
- return (
- <Tooltip key={tabKey} delayDuration={10}>
- <TooltipTrigger asChild>
- <Button
- variant={activeTab === tabKey ? "secondary" : "ghost"}
- size="icon"
- aria-label={tab.label}
- className={cn(
- "shrink-0",
- "h-8 w-8",
- activeTab !== tabKey && "text-muted-foreground",
- )}
- onClick={() => setActiveTab(tabKey)}
- >
- <tab.icon />
- </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-background top-0 bg-linear-to-b to-transparent"
- : "from-background bottom-0 bg-linear-to-t to-transparent",
- )}
- />
- );
-}
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/utils/ui";
+import {
+ TAB_KEYS,
+ tabs,
+ useAssetsPanelStore,
+} from "@/stores/assets-panel-store";
+
+export function TabBar() {
+ const { activeTab, setActiveTab } = useAssetsPanelStore();
+ const [showTopFade, setShowTopFade] = useState(false);
+ const [showBottomFade, setShowBottomFade] = useState(false);
+ const scrollRef = useRef<HTMLDivElement>(null);
+
+ 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 p-1 flex-col items-center justify-start gap-0.5 overflow-y-auto"
+ >
+ {TAB_KEYS.map((tabKey) => {
+ const tab = tabs[tabKey];
+ return (
+ <Tooltip key={tabKey} delayDuration={10}>
+ <TooltipTrigger asChild>
+ <Button
+ variant={activeTab === tabKey ? "secondary" : "ghost"}
+ size="icon"
+ aria-label={tab.label}
+ className={cn(
+ "shrink-0",
+ "h-8 w-8",
+ activeTab !== tabKey && "text-muted-foreground",
+ )}
+ onClick={() => setActiveTab(tabKey)}
+ >
+ <tab.icon />
+ </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-background top-0 bg-linear-to-b to-transparent"
+ : "from-background bottom-0 bg-linear-to-t to-transparent",
+ )}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/base-panel.tsx b/apps/web/src/components/editor/panels/assets/views/base-panel.tsx
index 0ece9b19..869304cd 100644
--- a/apps/web/src/components/editor/panels/assets/views/base-panel.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/base-panel.tsx
@@ -1,57 +1,57 @@
-import { cn } from "@/utils/ui";
-
-interface PanelViewProps extends React.HTMLAttributes<HTMLDivElement> {
- title?: string;
- actions?: React.ReactNode;
- children: React.ReactNode;
- contentClassName?: string;
- scrollClassName?: string;
- hideHeader?: boolean;
- ref?: React.Ref<HTMLDivElement>;
- onScroll?: React.UIEventHandler<HTMLDivElement>;
- scrollRef?: React.Ref<HTMLDivElement>;
-}
-
-export function PanelView({
- title,
- actions,
- children,
- className,
- contentClassName,
- scrollClassName,
- hideHeader = false,
- ref,
- onScroll,
- scrollRef,
- ...rest
-}: PanelViewProps) {
- return (
- <div
- className={cn("relative flex h-full flex-col", className)}
- ref={ref}
- {...rest}
- >
- {!hideHeader && (
- <div className="bg-background h-11 shrink-0 pl-3 pr-2 flex items-center justify-between border-b">
- {title && (
- <span className="text-muted-foreground text-sm">{title}</span>
- )}
- {actions}
- </div>
- )}
- <div
- className={cn(
- "scrollbar-hidden size-full overflow-y-auto",
- hideHeader ? "pt-4" : "pt-2",
- scrollClassName,
- )}
- ref={scrollRef}
- onScroll={onScroll}
- >
- <div className={cn("w-full flex-1 px-2 pt-0", contentClassName)}>
- {children}
- </div>
- </div>
- </div>
- );
-}
+import { cn } from "@/utils/ui";
+
+interface PanelViewProps extends React.HTMLAttributes<HTMLDivElement> {
+ title?: string;
+ actions?: React.ReactNode;
+ children: React.ReactNode;
+ contentClassName?: string;
+ scrollClassName?: string;
+ hideHeader?: boolean;
+ ref?: React.Ref<HTMLDivElement>;
+ onScroll?: React.UIEventHandler<HTMLDivElement>;
+ scrollRef?: React.Ref<HTMLDivElement>;
+}
+
+export function PanelView({
+ title,
+ actions,
+ children,
+ className,
+ contentClassName,
+ scrollClassName,
+ hideHeader = false,
+ ref,
+ onScroll,
+ scrollRef,
+ ...rest
+}: PanelViewProps) {
+ return (
+ <div
+ className={cn("relative flex h-full flex-col", className)}
+ ref={ref}
+ {...rest}
+ >
+ {!hideHeader && (
+ <div className="bg-background h-11 shrink-0 pl-3 pr-2 flex items-center justify-between border-b">
+ {title && (
+ <span className="text-muted-foreground text-sm">{title}</span>
+ )}
+ {actions}
+ </div>
+ )}
+ <div
+ className={cn(
+ "scrollbar-hidden size-full overflow-y-auto",
+ hideHeader ? "pt-4" : "pt-2",
+ scrollClassName,
+ )}
+ ref={scrollRef}
+ onScroll={onScroll}
+ >
+ <div className={cn("w-full flex-1 px-2 pt-0", contentClassName)}>
+ {children}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/captions.tsx b/apps/web/src/components/editor/panels/assets/views/captions.tsx
index 023e1578..f22c0211 100644
--- a/apps/web/src/components/editor/panels/assets/views/captions.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/captions.tsx
@@ -1,179 +1,179 @@
-import { Button } from "@/components/ui/button";
-import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
-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_TRANSCRIPTION_SAMPLE_RATE,
- TRANSCRIPTION_LANGUAGES,
-} from "@/constants/transcription-constants";
-import type {
- TranscriptionLanguage,
- TranscriptionProgress,
-} from "@/lib/transcription/types";
-import { transcriptionService } from "@/services/transcription/service";
-import { decodeAudioToFloat32 } from "@/lib/media/audio";
-import { buildCaptionChunks } from "@/lib/transcription/caption";
-import { Spinner } from "@/components/ui/spinner";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionFields,
-} from "@/components/section";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import {
- AddTrackCommand,
- BatchCommand,
- InsertElementCommand,
-} from "@/lib/commands";
-
-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,
- sampleRate: DEFAULT_TRANSCRIPTION_SAMPLE_RATE,
- });
-
- const result = await transcriptionService.transcribe({
- audioData: samples,
- language: selectedLanguage === "auto" ? undefined : selectedLanguage,
- onProgress: handleProgress,
- });
-
- setProcessingStep("Generating captions...");
- const captionChunks = buildCaptionChunks({ segments: result.segments });
-
- const addTrackCommand = new AddTrackCommand("text", 0);
- const insertCommands = captionChunks.map(
- (caption, i) =>
- new InsertElementCommand({
- placement: {
- mode: "explicit",
- trackId: addTrackCommand.getTrackId(),
- },
- element: {
- ...DEFAULTS.text.element,
- name: `Caption ${i + 1}`,
- content: caption.text,
- duration: caption.duration,
- startTime: caption.startTime,
- fontSize: 65,
- fontWeight: "bold",
- },
- }),
- );
-
- editor.command.execute({
- command: new BatchCommand([addTrackCommand, ...insertCommands]),
- });
- } 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 (
- <PanelView
- title="Captions"
- contentClassName="px-0 flex flex-col h-full"
- ref={containerRef}
- >
- <Section showTopBorder={false} showBottomBorder={false} className="flex-1">
- <SectionContent className="flex flex-col gap-4 h-full pt-1">
- <SectionFields>
- <SectionField label="Language">
- <Select
- value={selectedLanguage}
- onValueChange={(value) => handleLanguageChange({ value })}
- >
- <SelectTrigger>
- <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>
- </SectionField>
- </SectionFields>
-
- {error && (
- <div className="bg-destructive/10 border-destructive/20 rounded-md border p-3">
- <p className="text-destructive text-sm">{error}</p>
- </div>
- )}
- </SectionContent>
- </Section>
- <Section showBottomBorder={false} showTopBorder={false}>
- <SectionContent>
- <Button
- className="w-full"
- onClick={handleGenerateTranscript}
- disabled={isProcessing}
- >
- {isProcessing && <Spinner className="mr-1" />}
- {isProcessing ? processingStep : "Generate transcript"}
- </Button>
- </SectionContent>
- </Section>
- </PanelView>
- );
-}
+import { Button } from "@/components/ui/button";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
+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_TRANSCRIPTION_SAMPLE_RATE,
+ TRANSCRIPTION_LANGUAGES,
+} from "@/constants/transcription-constants";
+import type {
+ TranscriptionLanguage,
+ TranscriptionProgress,
+} from "@/lib/transcription/types";
+import { transcriptionService } from "@/services/transcription/service";
+import { decodeAudioToFloat32 } from "@/lib/media/audio";
+import { buildCaptionChunks } from "@/lib/transcription/caption";
+import { Spinner } from "@/components/ui/spinner";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+} from "@/components/section";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import {
+ AddTrackCommand,
+ BatchCommand,
+ InsertElementCommand,
+} from "@/lib/commands";
+
+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,
+ sampleRate: DEFAULT_TRANSCRIPTION_SAMPLE_RATE,
+ });
+
+ const result = await transcriptionService.transcribe({
+ audioData: samples,
+ language: selectedLanguage === "auto" ? undefined : selectedLanguage,
+ onProgress: handleProgress,
+ });
+
+ setProcessingStep("Generating captions...");
+ const captionChunks = buildCaptionChunks({ segments: result.segments });
+
+ const addTrackCommand = new AddTrackCommand("text", 0);
+ const insertCommands = captionChunks.map(
+ (caption, i) =>
+ new InsertElementCommand({
+ placement: {
+ mode: "explicit",
+ trackId: addTrackCommand.getTrackId(),
+ },
+ element: {
+ ...DEFAULTS.text.element,
+ name: `Caption ${i + 1}`,
+ content: caption.text,
+ duration: caption.duration,
+ startTime: caption.startTime,
+ fontSize: 65,
+ fontWeight: "bold",
+ },
+ }),
+ );
+
+ editor.command.execute({
+ command: new BatchCommand([addTrackCommand, ...insertCommands]),
+ });
+ } 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 (
+ <PanelView
+ title="Captions"
+ contentClassName="px-0 flex flex-col h-full"
+ ref={containerRef}
+ >
+ <Section showTopBorder={false} showBottomBorder={false} className="flex-1">
+ <SectionContent className="flex flex-col gap-4 h-full pt-1">
+ <SectionFields>
+ <SectionField label="Language">
+ <Select
+ value={selectedLanguage}
+ onValueChange={(value) => handleLanguageChange({ value })}
+ >
+ <SelectTrigger>
+ <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>
+ </SectionField>
+ </SectionFields>
+
+ {error && (
+ <div className="bg-destructive/10 border-destructive/20 rounded-md border p-3">
+ <p className="text-destructive text-sm">{error}</p>
+ </div>
+ )}
+ </SectionContent>
+ </Section>
+ <Section showBottomBorder={false} showTopBorder={false}>
+ <SectionContent>
+ <Button
+ className="w-full"
+ onClick={handleGenerateTranscript}
+ disabled={isProcessing}
+ >
+ {isProcessing && <Spinner className="mr-1" />}
+ {isProcessing ? processingStep : "Generate transcript"}
+ </Button>
+ </SectionContent>
+ </Section>
+ </PanelView>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/settings/background.tsx b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx
index b3ef1eb1..7a81188f 100644
--- a/apps/web/src/components/editor/panels/assets/views/settings/background.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx
@@ -1,227 +1,227 @@
-"use client";
-
-import { memo, useCallback, useEffect, useMemo, useRef } from "react";
-import {
- Section,
- SectionContent,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import {
- BLUR_INTENSITY_PRESETS,
- DEFAULT_BLUR_INTENSITY,
- DEFAULT_COLOR,
-} 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 { effectPreviewService } from "@/services/renderer/effect-preview";
-import type { TCanvasSize } from "@/lib/project/types";
-import { cn } from "@/utils/ui";
-
-const BlurPreview = memo(
- ({
- blur,
- canvasSize,
- isSelected,
- onSelect,
- }: {
- blur: { label: string; value: number };
- canvasSize: TCanvasSize;
- isSelected: boolean;
- onSelect: () => void;
- }) => {
- const canvasRef = useRef<HTMLCanvasElement>(null);
- const { width, height } = canvasSize;
-
- useEffect(() => {
- const renderPreview = () => {
- if (!canvasRef.current) return;
-
- effectPreviewService.renderPreview({
- effectType: "blur",
- params: { intensity: blur.value },
- targetCanvas: canvasRef.current,
- uniformDimensions: { width, height },
- });
- };
-
- renderPreview();
- return effectPreviewService.onPreviewImageReady({ callback: renderPreview });
- }, [blur.value, width, height]);
-
- return (
- <button
- className={cn(
- "border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
- isSelected && "border-primary border-2",
- )}
- onClick={onSelect}
- type="button"
- aria-label={`Select ${blur.label} blur`}
- >
- <canvas
- ref={canvasRef}
- className="absolute inset-0 h-full w-full object-cover"
- />
- <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,
- onSelect,
- useBackgroundColor = false,
- }: {
- backgrounds: string[];
- currentBackgroundColor: string;
- isColorBackground: boolean;
- onSelect: (bg: string) => void;
- useBackgroundColor?: boolean;
- }) => {
- return useMemo(
- () =>
- backgrounds.map((bg) => (
- <button
- key={bg}
- className={cn(
- "border-foreground/15 hover:border-primary aspect-square size-20 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={() => onSelect(bg)}
- type="button"
- aria-label={`Select background ${bg}`}
- />
- )),
- [
- backgrounds,
- isColorBackground,
- currentBackgroundColor,
- onSelect,
- useBackgroundColor,
- ],
- );
- },
-);
-
-BackgroundPreviews.displayName = "BackgroundPreviews";
-
-const COLOR_SECTIONS = [
- { title: "Colors", backgrounds: colors, useBackgroundColor: true },
- { title: "Pattern craft", backgrounds: patternCraftGradients },
- { title: "Syntax UI", backgrounds: syntaxUIGradients },
-] as const;
-
-export function BackgroundContent() {
- const editor = useEditor();
- const activeProject = useEditor((e) => e.project.getActive());
-
- const handleBlurSelect = useCallback(
- async (blurIntensity: number) => {
- await editor.project.updateSettings({
- settings: { background: { type: "blur", blurIntensity } },
- });
- },
- [editor.project],
- );
-
- const handleColorSelect = useCallback(
- async (color: string) => {
- await editor.project.updateSettings({
- settings: { background: { type: "color", color } },
- });
- },
- [editor.project],
- );
-
- const isBlurBackground = activeProject.settings.background.type === "blur";
- const isColorBackground = activeProject.settings.background.type === "color";
-
- const currentBlurIntensity = isBlurBackground
- ? (activeProject.settings.background as { blurIntensity: number })
- .blurIntensity
- : DEFAULT_BLUR_INTENSITY;
-
- const currentBackgroundColor = isColorBackground
- ? (activeProject.settings.background as { color: string }).color
- : DEFAULT_COLOR;
- const canvasSize = activeProject.settings.canvasSize;
-
- const blurPreviews = useMemo(
- () =>
- BLUR_INTENSITY_PRESETS.map((blur) => (
- <BlurPreview
- key={blur.value}
- blur={blur}
- canvasSize={canvasSize}
- isSelected={isBlurBackground && currentBlurIntensity === blur.value}
- onSelect={() => handleBlurSelect(blur.value)}
- />
- )),
- [canvasSize, isBlurBackground, currentBlurIntensity, handleBlurSelect],
- );
-
- return (
- <div className="flex flex-col">
- <Section collapsible defaultOpen={true} sectionKey="background-blur" showTopBorder={false}>
- <SectionHeader>
- <SectionTitle>Blur</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <div className="flex flex-wrap gap-2">{blurPreviews}</div>
- </SectionContent>
- </Section>
- {COLOR_SECTIONS.map((section) => (
- <Section
- key={section.title}
- collapsible
- defaultOpen={false}
- sectionKey={`settings:background-${section.title.toLowerCase().replace(/\s+/g, "-")}`}
- >
- <SectionHeader>
- <SectionTitle>{section.title}</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <div className="flex flex-wrap gap-2">
- <BackgroundPreviews
- backgrounds={section.backgrounds as string[]}
- currentBackgroundColor={currentBackgroundColor}
- isColorBackground={isColorBackground}
- onSelect={handleColorSelect}
- useBackgroundColor={
- "useBackgroundColor" in section
- ? section.useBackgroundColor
- : false
- }
- />
- </div>
- </SectionContent>
- </Section>
- ))}
- </div>
- );
-}
+"use client";
+
+import { memo, useCallback, useEffect, useMemo, useRef } from "react";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import {
+ BLUR_INTENSITY_PRESETS,
+ DEFAULT_BLUR_INTENSITY,
+ DEFAULT_COLOR,
+} 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 { effectPreviewService } from "@/services/renderer/effect-preview";
+import type { TCanvasSize } from "@/lib/project/types";
+import { cn } from "@/utils/ui";
+
+const BlurPreview = memo(
+ ({
+ blur,
+ canvasSize,
+ isSelected,
+ onSelect,
+ }: {
+ blur: { label: string; value: number };
+ canvasSize: TCanvasSize;
+ isSelected: boolean;
+ onSelect: () => void;
+ }) => {
+ const canvasRef = useRef<HTMLCanvasElement>(null);
+ const { width, height } = canvasSize;
+
+ useEffect(() => {
+ const renderPreview = () => {
+ if (!canvasRef.current) return;
+
+ effectPreviewService.renderPreview({
+ effectType: "blur",
+ params: { intensity: blur.value },
+ targetCanvas: canvasRef.current,
+ uniformDimensions: { width, height },
+ });
+ };
+
+ renderPreview();
+ return effectPreviewService.onPreviewImageReady({ callback: renderPreview });
+ }, [blur.value, width, height]);
+
+ return (
+ <button
+ className={cn(
+ "border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
+ isSelected && "border-primary border-2",
+ )}
+ onClick={onSelect}
+ type="button"
+ aria-label={`Select ${blur.label} blur`}
+ >
+ <canvas
+ ref={canvasRef}
+ className="absolute inset-0 h-full w-full object-cover"
+ />
+ <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,
+ onSelect,
+ useBackgroundColor = false,
+ }: {
+ backgrounds: string[];
+ currentBackgroundColor: string;
+ isColorBackground: boolean;
+ onSelect: (bg: string) => void;
+ useBackgroundColor?: boolean;
+ }) => {
+ return useMemo(
+ () =>
+ backgrounds.map((bg) => (
+ <button
+ key={bg}
+ className={cn(
+ "border-foreground/15 hover:border-primary aspect-square size-20 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={() => onSelect(bg)}
+ type="button"
+ aria-label={`Select background ${bg}`}
+ />
+ )),
+ [
+ backgrounds,
+ isColorBackground,
+ currentBackgroundColor,
+ onSelect,
+ useBackgroundColor,
+ ],
+ );
+ },
+);
+
+BackgroundPreviews.displayName = "BackgroundPreviews";
+
+const COLOR_SECTIONS = [
+ { title: "Colors", backgrounds: colors, useBackgroundColor: true },
+ { title: "Pattern craft", backgrounds: patternCraftGradients },
+ { title: "Syntax UI", backgrounds: syntaxUIGradients },
+] as const;
+
+export function BackgroundContent() {
+ const editor = useEditor();
+ const activeProject = useEditor((e) => e.project.getActive());
+
+ const handleBlurSelect = useCallback(
+ async (blurIntensity: number) => {
+ await editor.project.updateSettings({
+ settings: { background: { type: "blur", blurIntensity } },
+ });
+ },
+ [editor.project],
+ );
+
+ const handleColorSelect = useCallback(
+ async (color: string) => {
+ await editor.project.updateSettings({
+ settings: { background: { type: "color", color } },
+ });
+ },
+ [editor.project],
+ );
+
+ const isBlurBackground = activeProject.settings.background.type === "blur";
+ const isColorBackground = activeProject.settings.background.type === "color";
+
+ const currentBlurIntensity = isBlurBackground
+ ? (activeProject.settings.background as { blurIntensity: number })
+ .blurIntensity
+ : DEFAULT_BLUR_INTENSITY;
+
+ const currentBackgroundColor = isColorBackground
+ ? (activeProject.settings.background as { color: string }).color
+ : DEFAULT_COLOR;
+ const canvasSize = activeProject.settings.canvasSize;
+
+ const blurPreviews = useMemo(
+ () =>
+ BLUR_INTENSITY_PRESETS.map((blur) => (
+ <BlurPreview
+ key={blur.value}
+ blur={blur}
+ canvasSize={canvasSize}
+ isSelected={isBlurBackground && currentBlurIntensity === blur.value}
+ onSelect={() => handleBlurSelect(blur.value)}
+ />
+ )),
+ [canvasSize, isBlurBackground, currentBlurIntensity, handleBlurSelect],
+ );
+
+ return (
+ <div className="flex flex-col">
+ <Section collapsible defaultOpen={true} sectionKey="background-blur" showTopBorder={false}>
+ <SectionHeader>
+ <SectionTitle>Blur</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <div className="flex flex-wrap gap-2">{blurPreviews}</div>
+ </SectionContent>
+ </Section>
+ {COLOR_SECTIONS.map((section) => (
+ <Section
+ key={section.title}
+ collapsible
+ defaultOpen={false}
+ sectionKey={`settings:background-${section.title.toLowerCase().replace(/\s+/g, "-")}`}
+ >
+ <SectionHeader>
+ <SectionTitle>{section.title}</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <div className="flex flex-wrap gap-2">
+ <BackgroundPreviews
+ backgrounds={section.backgrounds as string[]}
+ currentBackgroundColor={currentBackgroundColor}
+ isColorBackground={isColorBackground}
+ onSelect={handleColorSelect}
+ useBackgroundColor={
+ "useBackgroundColor" in section
+ ? section.useBackgroundColor
+ : false
+ }
+ />
+ </div>
+ </SectionContent>
+ </Section>
+ ))}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx
index e0229ea9..0578eeb7 100644
--- a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx
@@ -1,369 +1,369 @@
-"use client";
-
-import { useRef, useState } from "react";
-import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { FPS_PRESETS } from "@/constants/project-constants";
-import { useEditor } from "@/hooks/use-editor";
-import {
- Section,
- SectionContent,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import { BackgroundContent } from "./background";
-import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { Button } from "@/components/ui/button";
-import { NumberField } from "@/components/ui/number-field";
-import { useEditorStore } from "@/stores/editor-store";
-import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { Tick02Icon } from "@hugeicons/core-free-icons";
-import { cn } from "@/utils/ui";
-import { dimensionToAspectRatio } from "@/utils/geometry";
-import { formatNumberForDisplay } from "@/utils/math";
-import { OcSquarePlusIcon } from "@opencut/ui/icons";
-import type { TCanvasSize } from "@/lib/project/types";
-
-type SettingsView = "project-info" | "background";
-
-const PRESET_LABELS: Record<string, string> = {
- "1:1": "1:1",
- "16:9": "16:9",
- "9:16": "9:16",
- "4:3": "4:3",
-};
-
-function areCanvasSizesEqual({
- left,
- right,
-}: {
- left: TCanvasSize;
- right: TCanvasSize;
-}) {
- return left.width === right.width && left.height === right.height;
-}
-
-function formatCanvasDimension({ value }: { value: number }) {
- return formatNumberForDisplay({ value, maxFractionDigits: 0 });
-}
-
-function parseCanvasDimension({ input }: { input: string }): number | null {
- const trimmed = input.trim();
- if (!trimmed) return null;
-
- const parsed = Number(trimmed);
- if (!Number.isFinite(parsed)) return null;
-
- const rounded = Math.round(parsed);
- return rounded > 0 ? rounded : null;
-}
-
-function useCanvasDimensionDraft({
- value,
- onCommit,
-}: {
- value: number;
- onCommit: (value: number) => void;
-}) {
- const pendingValueRef = useRef(value);
- const syncedValueRef = useRef(value);
-
- if (syncedValueRef.current !== value) {
- syncedValueRef.current = value;
- pendingValueRef.current = value;
- }
-
- return usePropertyDraft({
- displayValue: formatCanvasDimension({ value }),
- parse: (input) => parseCanvasDimension({ input }),
- onPreview: (nextValue) => {
- pendingValueRef.current = nextValue;
- },
- onCommit: () => {
- if (pendingValueRef.current !== value) {
- onCommit(pendingValueRef.current);
- }
- },
- });
-}
-
-export function SettingsView() {
- const [view, setView] = useState<SettingsView>("project-info");
- const editor = useEditor();
- const activeProject = useEditor((e) => e.project.getActive());
- const { canvasPresets } = useEditorStore();
- const currentCanvasSize = activeProject.settings.canvasSize;
- const canvasSizeMode = activeProject.settings.canvasSizeMode ?? "preset";
- const lastCustomCanvasSize =
- activeProject.settings.lastCustomCanvasSize ?? null;
-
- const presetItems = canvasPresets.map((preset, index) => {
- const ratio = dimensionToAspectRatio(preset);
- return {
- id: index.toString(),
- label: PRESET_LABELS[ratio] ?? ratio,
- ratio,
- canvasSize: preset,
- };
- });
-
- const selectedPresetId = canvasSizeMode === "preset"
- ? (presetItems.find((preset) =>
- areCanvasSizesEqual({
- left: preset.canvasSize,
- right: currentCanvasSize,
- }),
- )?.id ?? null)
- : null;
-
- const updateCustomCanvasSize = ({
- canvasSize,
- }: {
- canvasSize: TCanvasSize;
- }) => {
- const shouldUpdateCanvasSize = !areCanvasSizesEqual({
- left: canvasSize,
- right: currentCanvasSize,
- });
- const shouldUpdateLastCustomCanvasSize =
- lastCustomCanvasSize === null ||
- !areCanvasSizesEqual({
- left: canvasSize,
- right: lastCustomCanvasSize,
- });
- const shouldUpdateCanvasSizeMode = canvasSizeMode !== "custom";
-
- if (
- !shouldUpdateCanvasSize &&
- !shouldUpdateLastCustomCanvasSize &&
- !shouldUpdateCanvasSizeMode
- ) {
- return;
- }
-
- editor.project.updateSettings({
- settings: {
- ...(shouldUpdateCanvasSize ? { canvasSize } : {}),
- ...(shouldUpdateCanvasSizeMode
- ? { canvasSizeMode: "custom" as const }
- : {}),
- lastCustomCanvasSize: canvasSize,
- },
- });
- };
-
- const selectPresetCanvasSize = ({
- canvasSize,
- }: {
- canvasSize: TCanvasSize;
- }) => {
- const shouldUpdateCanvasSize = !areCanvasSizesEqual({
- left: canvasSize,
- right: currentCanvasSize,
- });
- const shouldUpdateCanvasSizeMode = canvasSizeMode !== "preset";
-
- if (!shouldUpdateCanvasSize && !shouldUpdateCanvasSizeMode) return;
-
- editor.project.updateSettings({
- settings: {
- ...(shouldUpdateCanvasSize ? { canvasSize } : {}),
- ...(shouldUpdateCanvasSizeMode
- ? { canvasSizeMode: "preset" as const }
- : {}),
- },
- });
- };
-
- const selectCustomCanvasSize = () => {
- updateCustomCanvasSize({
- canvasSize: lastCustomCanvasSize ?? currentCanvasSize,
- });
- };
-
- const widthDraft = useCanvasDimensionDraft({
- value: currentCanvasSize.width,
- onCommit: (width) =>
- updateCustomCanvasSize({
- canvasSize: { width, height: currentCanvasSize.height },
- }),
- });
-
- const heightDraft = useCanvasDimensionDraft({
- value: currentCanvasSize.height,
- onCommit: (height) =>
- updateCustomCanvasSize({
- canvasSize: { width: currentCanvasSize.width, height },
- }),
- });
-
- const isCustomSelected = canvasSizeMode === "custom";
-
- return (
- <PanelView
- contentClassName="px-0"
- scrollClassName="pt-0"
- actions={
- <Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}>
- <TabsList>
- <TabsTrigger value="project-info">Project info</TabsTrigger>
- <TabsTrigger value="background">Background</TabsTrigger>
- </TabsList>
- </Tabs>
- }
- >
- {view === "project-info" && (
- <div className="flex flex-col">
- <Section showTopBorder={false}>
- <SectionHeader>
- <SectionTitle className="flex-1">Name</SectionTitle>
- <span className="text-sm truncate">
- {activeProject.metadata.name}
- </span>
- </SectionHeader>
- </Section>
- <Section showTopBorder={false}>
- <SectionHeader className="justify-between">
- <SectionTitle className="flex-1">Frame rate</SectionTitle>
- <Select
- value={activeProject.settings.fps.toString()}
- onValueChange={(value) => {
- const fps = parseFloat(value);
- editor.project.updateSettings({ settings: { fps } });
- }}
- >
- <SelectTrigger className="bg-transparent border-none p-1 h-auto">
- <SelectValue placeholder="Select a frame rate" />
- </SelectTrigger>
- <SelectContent>
- {FPS_PRESETS.map((preset) => (
- <SelectItem key={preset.value} value={preset.value}>
- {preset.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </SectionHeader>
- </Section>
- <Section
- showTopBorder={false}
- collapsible
- sectionKey="settings:aspect-ratio"
- >
- <SectionHeader>
- <SectionTitle className="flex-1">Aspect ratio</SectionTitle>
- </SectionHeader>
- <SectionContent className="px-2 flex flex-col gap-1 pb-2">
- {presetItems.map((preset) => (
- <AspectRatioItem
- key={preset.id}
- label={preset.label}
- previewIcon={<AspectRatioPreview ratio={preset.ratio} />}
- isSelected={selectedPresetId === preset.id}
- onClick={() => {
- selectPresetCanvasSize({
- canvasSize: preset.canvasSize,
- });
- }}
- />
- ))}
- <div className="pb-2">
- <AspectRatioItem
- key="custom"
- label="Custom"
- previewIcon={<OcSquarePlusIcon />}
- isSelected={isCustomSelected}
- onClick={selectCustomCanvasSize}
- uiOptions={
- <div className=" flex items-center gap-2 text-foreground">
- <NumberField
- value={widthDraft.displayValue}
- className="w-full"
- aria-label="Canvas width"
- onFocus={widthDraft.onFocus}
- onChange={widthDraft.onChange}
- onBlur={widthDraft.onBlur}
- />
- <NumberField
- value={heightDraft.displayValue}
- className="w-full"
- aria-label="Canvas height"
- onFocus={heightDraft.onFocus}
- onChange={heightDraft.onChange}
- onBlur={heightDraft.onBlur}
- />
- </div>
- }
- />
- </div>
- </SectionContent>
- </Section>
- </div>
- )}
- {view === "background" && <BackgroundContent />}
- </PanelView>
- );
-}
-
-function AspectRatioItem({
- label,
- previewIcon,
- isSelected,
- onClick,
- uiOptions,
-}: {
- label: string;
- previewIcon: React.ReactNode;
- isSelected: boolean;
- onClick: () => void;
- uiOptions?: React.ReactNode;
-}) {
- return (
- <Button
- variant={isSelected ? "secondary" : "ghost"}
- className={cn(
- "px-2 py-0 flex flex-col h-fit w-full",
- !isSelected && "border border-transparent opacity-75!",
- )}
- onClick={onClick}
- >
- <div className="w-full flex justify-between items-center h-8">
- <div className="flex-1 flex items-center gap-2">
- <div className="flex items-center justify-center size-5">
- {previewIcon}
- </div>
- <span className="text-sm truncate">{label}</span>
- </div>
- <div>
- {isSelected && <HugeiconsIcon icon={Tick02Icon} className="size-4" />}
- </div>
- </div>
- {uiOptions && isSelected && (
- <div className="w-full pb-2">{uiOptions}</div>
- )}
- </Button>
- );
-}
-
-function AspectRatioPreview({ ratio }: { ratio?: string }) {
- if (!ratio) return null;
-
- const [w, h] = ratio.split(":").map(Number);
- const maxSize = 16;
- const width = w >= h ? maxSize : (w / h) * maxSize;
- const height = h >= w ? maxSize : (h / w) * maxSize;
-
- return (
- <div
- style={{ width, height, borderWidth: 1.5 }}
- className="rounded-xs border-current opacity-60"
- />
- );
-}
+"use client";
+
+import { useRef, useState } from "react";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { FPS_PRESETS } from "@/constants/project-constants";
+import { useEditor } from "@/hooks/use-editor";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { BackgroundContent } from "./background";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Button } from "@/components/ui/button";
+import { NumberField } from "@/components/ui/number-field";
+import { useEditorStore } from "@/stores/editor-store";
+import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { Tick02Icon } from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+import { dimensionToAspectRatio } from "@/utils/geometry";
+import { formatNumberForDisplay } from "@/utils/math";
+import { OcSquarePlusIcon } from "@opencut/ui/icons";
+import type { TCanvasSize } from "@/lib/project/types";
+
+type SettingsView = "project-info" | "background";
+
+const PRESET_LABELS: Record<string, string> = {
+ "1:1": "1:1",
+ "16:9": "16:9",
+ "9:16": "9:16",
+ "4:3": "4:3",
+};
+
+function areCanvasSizesEqual({
+ left,
+ right,
+}: {
+ left: TCanvasSize;
+ right: TCanvasSize;
+}) {
+ return left.width === right.width && left.height === right.height;
+}
+
+function formatCanvasDimension({ value }: { value: number }) {
+ return formatNumberForDisplay({ value, maxFractionDigits: 0 });
+}
+
+function parseCanvasDimension({ input }: { input: string }): number | null {
+ const trimmed = input.trim();
+ if (!trimmed) return null;
+
+ const parsed = Number(trimmed);
+ if (!Number.isFinite(parsed)) return null;
+
+ const rounded = Math.round(parsed);
+ return rounded > 0 ? rounded : null;
+}
+
+function useCanvasDimensionDraft({
+ value,
+ onCommit,
+}: {
+ value: number;
+ onCommit: (value: number) => void;
+}) {
+ const pendingValueRef = useRef(value);
+ const syncedValueRef = useRef(value);
+
+ if (syncedValueRef.current !== value) {
+ syncedValueRef.current = value;
+ pendingValueRef.current = value;
+ }
+
+ return usePropertyDraft({
+ displayValue: formatCanvasDimension({ value }),
+ parse: (input) => parseCanvasDimension({ input }),
+ onPreview: (nextValue) => {
+ pendingValueRef.current = nextValue;
+ },
+ onCommit: () => {
+ if (pendingValueRef.current !== value) {
+ onCommit(pendingValueRef.current);
+ }
+ },
+ });
+}
+
+export function SettingsView() {
+ const [view, setView] = useState<SettingsView>("project-info");
+ const editor = useEditor();
+ const activeProject = useEditor((e) => e.project.getActive());
+ const { canvasPresets } = useEditorStore();
+ const currentCanvasSize = activeProject.settings.canvasSize;
+ const canvasSizeMode = activeProject.settings.canvasSizeMode ?? "preset";
+ const lastCustomCanvasSize =
+ activeProject.settings.lastCustomCanvasSize ?? null;
+
+ const presetItems = canvasPresets.map((preset, index) => {
+ const ratio = dimensionToAspectRatio(preset);
+ return {
+ id: index.toString(),
+ label: PRESET_LABELS[ratio] ?? ratio,
+ ratio,
+ canvasSize: preset,
+ };
+ });
+
+ const selectedPresetId = canvasSizeMode === "preset"
+ ? (presetItems.find((preset) =>
+ areCanvasSizesEqual({
+ left: preset.canvasSize,
+ right: currentCanvasSize,
+ }),
+ )?.id ?? null)
+ : null;
+
+ const updateCustomCanvasSize = ({
+ canvasSize,
+ }: {
+ canvasSize: TCanvasSize;
+ }) => {
+ const shouldUpdateCanvasSize = !areCanvasSizesEqual({
+ left: canvasSize,
+ right: currentCanvasSize,
+ });
+ const shouldUpdateLastCustomCanvasSize =
+ lastCustomCanvasSize === null ||
+ !areCanvasSizesEqual({
+ left: canvasSize,
+ right: lastCustomCanvasSize,
+ });
+ const shouldUpdateCanvasSizeMode = canvasSizeMode !== "custom";
+
+ if (
+ !shouldUpdateCanvasSize &&
+ !shouldUpdateLastCustomCanvasSize &&
+ !shouldUpdateCanvasSizeMode
+ ) {
+ return;
+ }
+
+ editor.project.updateSettings({
+ settings: {
+ ...(shouldUpdateCanvasSize ? { canvasSize } : {}),
+ ...(shouldUpdateCanvasSizeMode
+ ? { canvasSizeMode: "custom" as const }
+ : {}),
+ lastCustomCanvasSize: canvasSize,
+ },
+ });
+ };
+
+ const selectPresetCanvasSize = ({
+ canvasSize,
+ }: {
+ canvasSize: TCanvasSize;
+ }) => {
+ const shouldUpdateCanvasSize = !areCanvasSizesEqual({
+ left: canvasSize,
+ right: currentCanvasSize,
+ });
+ const shouldUpdateCanvasSizeMode = canvasSizeMode !== "preset";
+
+ if (!shouldUpdateCanvasSize && !shouldUpdateCanvasSizeMode) return;
+
+ editor.project.updateSettings({
+ settings: {
+ ...(shouldUpdateCanvasSize ? { canvasSize } : {}),
+ ...(shouldUpdateCanvasSizeMode
+ ? { canvasSizeMode: "preset" as const }
+ : {}),
+ },
+ });
+ };
+
+ const selectCustomCanvasSize = () => {
+ updateCustomCanvasSize({
+ canvasSize: lastCustomCanvasSize ?? currentCanvasSize,
+ });
+ };
+
+ const widthDraft = useCanvasDimensionDraft({
+ value: currentCanvasSize.width,
+ onCommit: (width) =>
+ updateCustomCanvasSize({
+ canvasSize: { width, height: currentCanvasSize.height },
+ }),
+ });
+
+ const heightDraft = useCanvasDimensionDraft({
+ value: currentCanvasSize.height,
+ onCommit: (height) =>
+ updateCustomCanvasSize({
+ canvasSize: { width: currentCanvasSize.width, height },
+ }),
+ });
+
+ const isCustomSelected = canvasSizeMode === "custom";
+
+ return (
+ <PanelView
+ contentClassName="px-0"
+ scrollClassName="pt-0"
+ actions={
+ <Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}>
+ <TabsList>
+ <TabsTrigger value="project-info">Project info</TabsTrigger>
+ <TabsTrigger value="background">Background</TabsTrigger>
+ </TabsList>
+ </Tabs>
+ }
+ >
+ {view === "project-info" && (
+ <div className="flex flex-col">
+ <Section showTopBorder={false}>
+ <SectionHeader>
+ <SectionTitle className="flex-1">Name</SectionTitle>
+ <span className="text-sm truncate">
+ {activeProject.metadata.name}
+ </span>
+ </SectionHeader>
+ </Section>
+ <Section showTopBorder={false}>
+ <SectionHeader className="justify-between">
+ <SectionTitle className="flex-1">Frame rate</SectionTitle>
+ <Select
+ value={activeProject.settings.fps.toString()}
+ onValueChange={(value) => {
+ const fps = parseFloat(value);
+ editor.project.updateSettings({ settings: { fps } });
+ }}
+ >
+ <SelectTrigger className="bg-transparent border-none p-1 h-auto">
+ <SelectValue placeholder="Select a frame rate" />
+ </SelectTrigger>
+ <SelectContent>
+ {FPS_PRESETS.map((preset) => (
+ <SelectItem key={preset.value} value={preset.value}>
+ {preset.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </SectionHeader>
+ </Section>
+ <Section
+ showTopBorder={false}
+ collapsible
+ sectionKey="settings:aspect-ratio"
+ >
+ <SectionHeader>
+ <SectionTitle className="flex-1">Aspect ratio</SectionTitle>
+ </SectionHeader>
+ <SectionContent className="px-2 flex flex-col gap-1 pb-2">
+ {presetItems.map((preset) => (
+ <AspectRatioItem
+ key={preset.id}
+ label={preset.label}
+ previewIcon={<AspectRatioPreview ratio={preset.ratio} />}
+ isSelected={selectedPresetId === preset.id}
+ onClick={() => {
+ selectPresetCanvasSize({
+ canvasSize: preset.canvasSize,
+ });
+ }}
+ />
+ ))}
+ <div className="pb-2">
+ <AspectRatioItem
+ key="custom"
+ label="Custom"
+ previewIcon={<OcSquarePlusIcon />}
+ isSelected={isCustomSelected}
+ onClick={selectCustomCanvasSize}
+ uiOptions={
+ <div className=" flex items-center gap-2 text-foreground">
+ <NumberField
+ value={widthDraft.displayValue}
+ className="w-full"
+ aria-label="Canvas width"
+ onFocus={widthDraft.onFocus}
+ onChange={widthDraft.onChange}
+ onBlur={widthDraft.onBlur}
+ />
+ <NumberField
+ value={heightDraft.displayValue}
+ className="w-full"
+ aria-label="Canvas height"
+ onFocus={heightDraft.onFocus}
+ onChange={heightDraft.onChange}
+ onBlur={heightDraft.onBlur}
+ />
+ </div>
+ }
+ />
+ </div>
+ </SectionContent>
+ </Section>
+ </div>
+ )}
+ {view === "background" && <BackgroundContent />}
+ </PanelView>
+ );
+}
+
+function AspectRatioItem({
+ label,
+ previewIcon,
+ isSelected,
+ onClick,
+ uiOptions,
+}: {
+ label: string;
+ previewIcon: React.ReactNode;
+ isSelected: boolean;
+ onClick: () => void;
+ uiOptions?: React.ReactNode;
+}) {
+ return (
+ <Button
+ variant={isSelected ? "secondary" : "ghost"}
+ className={cn(
+ "px-2 py-0 flex flex-col h-fit w-full",
+ !isSelected && "border border-transparent opacity-75!",
+ )}
+ onClick={onClick}
+ >
+ <div className="w-full flex justify-between items-center h-8">
+ <div className="flex-1 flex items-center gap-2">
+ <div className="flex items-center justify-center size-5">
+ {previewIcon}
+ </div>
+ <span className="text-sm truncate">{label}</span>
+ </div>
+ <div>
+ {isSelected && <HugeiconsIcon icon={Tick02Icon} className="size-4" />}
+ </div>
+ </div>
+ {uiOptions && isSelected && (
+ <div className="w-full pb-2">{uiOptions}</div>
+ )}
+ </Button>
+ );
+}
+
+function AspectRatioPreview({ ratio }: { ratio?: string }) {
+ if (!ratio) return null;
+
+ const [w, h] = ratio.split(":").map(Number);
+ const maxSize = 16;
+ const width = w >= h ? maxSize : (w / h) * maxSize;
+ const height = h >= w ? maxSize : (h / w) * maxSize;
+
+ return (
+ <div
+ style={{ width, height, borderWidth: 1.5 }}
+ className="rounded-xs border-current opacity-60"
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/sounds.tsx b/apps/web/src/components/editor/panels/assets/views/sounds.tsx
index 4fb43ac2..dc92ad9c 100644
--- a/apps/web/src/components/editor/panels/assets/views/sounds.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/sounds.tsx
@@ -1,574 +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 "@/lib/sounds/types";
-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="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-background 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>
- );
-}
+"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 "@/lib/sounds/types";
+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="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-background 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
index af489630..285b37d3 100644
--- a/apps/web/src/components/editor/panels/assets/views/stickers.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx
@@ -1,470 +1,470 @@
-"use client";
-
-import Image from "next/image";
-import type { CSSProperties } from "react";
-import { useEffect, useState } from "react";
-import { toast } from "sonner";
-import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Spinner } from "@/components/ui/spinner";
-import { useEditor } from "@/hooks/use-editor";
-import { resolveStickerIntrinsicSize } from "@/lib/stickers";
-import {
- buildGraphicElement,
- buildStickerElement,
-} from "@/lib/timeline/element-utils";
-import { STICKER_CATEGORIES } from "@/constants/sticker-constants";
-import { getRegionLabel, resolveQueryToRegions } from "@/lib/stickers";
-import { parseShapeStickerId } from "@/lib/stickers/providers/shapes";
-import type { TimelineDragData } from "@/lib/timeline/drag";
-import type {
- StickerBrowseSection,
- StickerCategory,
- StickerItem as StickerData,
-} from "@/lib/stickers";
-import { useStickersStore } from "@/stores/stickers-store";
-import { cn } from "@/utils/ui";
-import {
- HappyIcon,
-} from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-
-export function StickersView() {
- const {
- browseContent,
- browseStickers,
- searchQuery,
- searchStickers,
- selectedCategory,
- setSearchQuery,
- setSelectedCategory,
- viewMode,
- } = useStickersStore();
-
- useEffect(() => {
- if (viewMode === "browse" && !browseContent) {
- void browseStickers();
- }
- }, [browseContent, browseStickers, viewMode]);
-
- return (
- <div className="flex h-full flex-col py-2">
- <div className="px-2">
- <Input
- size="sm"
- variant="default"
- placeholder="Search..."
- value={searchQuery}
- onChange={(e) => {
- setSearchQuery({ query: e.target.value });
- void searchStickers({ query: e.target.value });
- }}
- showClearIcon
- onClear={() => {
- setSearchQuery({ query: "" });
- void searchStickers({ query: "" });
- }}
- className="w-full"
- containerClassName="w-full"
- />
- </div>
-
- <div className="mt-2 flex min-h-0 flex-1 flex-col">
- <div className="border-b border-border px-2">
- <div
- role="tablist"
- aria-label="Sticker categories"
- className="text-muted-foreground inline-flex h-auto items-center gap-0 bg-transparent p-0"
- >
- {Object.entries(STICKER_CATEGORIES).map(([key, label]) => {
- const isActive = key === selectedCategory;
- return (
- <button
- key={key}
- type="button"
- role="tab"
- aria-selected={isActive}
- onClick={() =>
- setSelectedCategory({
- category: key as StickerCategory,
- })
- }
- className={cn(
- "text-muted-foreground rounded-none border-b-2 border-transparent px-3 py-2 text-sm font-medium whitespace-nowrap -mb-px",
- isActive && "text-primary border-primary",
- )}
- >
- {label}
- </button>
- );
- })}
- </div>
- </div>
- <div className="min-h-0 flex-1 overflow-y-auto px-4 pt-4">
- <StickersContentView />
- </div>
- </div>
- </div>
- );
-}
-
-function StickerGrid({
- items,
- shouldCapSize = false,
-}: {
- items: StickerData[];
- shouldCapSize?: boolean;
-}) {
- const gridStyle: CSSProperties & {
- "--sticker-min": string;
- "--sticker-max"?: string;
- } = {
- gridTemplateColumns: shouldCapSize
- ? "repeat(auto-fill, minmax(var(--sticker-min, 80px), var(--sticker-max, 140px)))"
- : "repeat(auto-fill, minmax(var(--sticker-min, 80px), 1fr))",
- "--sticker-min": "80px",
- ...(shouldCapSize ? { "--sticker-max": "140px" } : {}),
- };
-
- return (
- <div className="grid gap-2" style={gridStyle}>
- {items.map((item) => (
- <StickerItem key={item.id} item={item} shouldCapSize={shouldCapSize} />
- ))}
- </div>
- );
-}
-
-function StickerRow({ items }: { items: StickerData[] }) {
- return (
- <div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hidden">
- {items.map((item) => (
- <div key={item.id} className="w-20 shrink-0">
- <StickerItem item={item} shouldCapSize containerClassName="w-full" />
- </div>
- ))}
- </div>
- );
-}
-
-function EmptyView({ message }: { message: string }) {
- return (
- <div className="bg-background 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 RegionBanner({ region }: { region: string }) {
- return (
- <div className="flex h-7 items-center gap-1.5 rounded-lg border border-sky-100 bg-sky-50 px-2">
- <svg
- width="12"
- height="12"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- strokeWidth="2"
- strokeLinecap="round"
- strokeLinejoin="round"
- className="shrink-0 text-sky-600"
- aria-hidden="true"
- >
- <path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
- <circle cx="12" cy="10" r="3" />
- </svg>
- <span className="text-xs font-semibold text-sky-600">{region}</span>
- </div>
- );
-}
-
-function StickersContentView() {
- const {
- browseContent,
- clearRecentStickers,
- isBrowsing,
- isSearching,
- searchQuery,
- searchResults,
- selectedCategory,
- setSelectedCategory,
- viewMode,
- } = useStickersStore();
-
- if (viewMode === "search") {
- if (isSearching) {
- return (
- <div className="flex items-center justify-center py-8">
- <Spinner className="text-muted-foreground size-6" />
- </div>
- );
- }
-
- if (searchResults?.items.length) {
- const normalizedQuery = searchQuery.trim().toLowerCase();
- const isRegionSearch =
- selectedCategory === "flags" &&
- resolveQueryToRegions({ query: normalizedQuery }) !== null;
- const regionLabel = getRegionLabel({ query: normalizedQuery });
-
- return (
- <div className="flex flex-col gap-3 pb-4">
- {isRegionSearch && <RegionBanner region={regionLabel} />}
- <div className="flex items-center justify-between">
- <span className="text-muted-foreground text-sm">
- {searchResults.total} results
- </span>
- </div>
- <StickerGrid items={searchResults.items} />
- </div>
- );
- }
-
- // "all" tab search — sections are in browseContent, fall through to section rendering below
- if (selectedCategory !== "all" && searchQuery) {
- return <EmptyView message={`No stickers found for "${searchQuery}"`} />;
- }
- }
-
- if (isBrowsing && !browseContent) {
- return (
- <div className="flex items-center justify-center py-8">
- <Spinner className="text-muted-foreground size-6" />
- </div>
- );
- }
-
- if (!browseContent?.sections.length) {
- const categoryLabel = STICKER_CATEGORIES[selectedCategory];
- return (
- <EmptyView
- message={
- viewMode === "search"
- ? `No stickers found for "${searchQuery}"`
- : selectedCategory === "all"
- ? "No stickers available yet."
- : `No stickers available in ${categoryLabel.toLowerCase()} yet.`
- }
- />
- );
- }
-
- return (
- <div className="flex flex-col gap-4 pb-4">
- {browseContent.sections.map((section) => (
- <StickerSection
- key={section.id}
- section={section}
- onClearRecent={clearRecentStickers}
- onSeeAll={(category) => {
- setSelectedCategory({ category });
- }}
- />
- ))}
- </div>
- );
-}
-
-function StickerSection({
- section,
- onClearRecent,
- onSeeAll,
-}: {
- section: StickerBrowseSection;
- onClearRecent: () => void;
- onSeeAll: (category: StickerCategory) => void;
-}) {
- const hasHeader =
- Boolean(section.title) || section.id === "recent" || section.action;
-
- return (
- <div className="flex flex-col gap-2">
- {hasHeader && (
- <div className="flex items-center justify-between gap-2">
- {section.title ? (
- <p className="text-xs text-muted-foreground">{section.title}</p>
- ) : (
- <div />
- )}
-
- <div className="ml-auto flex items-center gap-2">
- {section.id === "recent" && (
- <Button
- onClick={onClearRecent}
- variant="text"
- size="sm"
- className="h-auto gap-1 p-0 text-xs text-muted-foreground"
- >
- Clear
- </Button>
- )}
-
- {section.action?.type === "see-all" && section.action.category && (
- <Button
- variant="text"
- size="sm"
- className="h-auto gap-1 p-0 text-xs text-primary"
- onClick={() => {
- onSeeAll(section.action?.category as StickerCategory);
- }}
- >
- See all
- </Button>
- )}
- </div>
- </div>
- )}
-
- {section.layout === "row" ? (
- <StickerRow items={section.items} />
- ) : (
- <StickerGrid items={section.items} />
- )}
- </div>
- );
-}
-
-interface StickerItemProps {
- item: StickerData;
- shouldCapSize?: boolean;
- containerClassName?: string;
-}
-
-function StickerItem({
- item,
- shouldCapSize = false,
- containerClassName,
-}: StickerItemProps) {
- const editor = useEditor();
- const { addToRecentStickers } = useStickersStore();
- const [isAdding, setIsAdding] = useState(false);
- const [hasImageError, setHasImageError] = useState(false);
-
- useEffect(() => {
- if (!item.id) {
- return;
- }
-
- setHasImageError(false);
- }, [item.id]);
-
- const displayName = item.name;
- const shapePreset =
- item.provider === "shapes" ? parseShapeStickerId({ stickerId: item.id }) : null;
-
- const handleAdd = async () => {
- setIsAdding(true);
- try {
- const currentTime = editor.playback.getCurrentTime();
-
- let element:
- | ReturnType<typeof buildGraphicElement>
- | ReturnType<typeof buildStickerElement>;
- if (shapePreset) {
- element = buildGraphicElement({
- definitionId: shapePreset.definitionId,
- name: shapePreset.name,
- startTime: currentTime,
- params: shapePreset.params,
- });
- } else {
- const { width: intrinsicWidth, height: intrinsicHeight } =
- await resolveStickerIntrinsicSize({ stickerId: item.id });
- element = buildStickerElement({
- stickerId: item.id,
- name: item.name,
- startTime: currentTime,
- intrinsicWidth,
- intrinsicHeight,
- });
- }
-
- editor.timeline.insertElement({
- placement: { mode: "auto" },
- element,
- });
-
- addToRecentStickers({ stickerId: item.id });
- } catch (error) {
- console.error("Failed to add sticker:", error);
- toast.error("Failed to add sticker to timeline");
- } finally {
- setIsAdding(false);
- }
- };
-
- const preview = (
- <div className="flex size-full items-center justify-center p-3">
- {hasImageError ? (
- <span className="text-muted-foreground text-center text-xs break-all">
- {displayName}
- </span>
- ) : (
- <Image
- src={item.previewUrl}
- alt={displayName}
- width={64}
- height={64}
- className="size-full object-contain"
- style={
- shouldCapSize
- ? {
- maxWidth: "var(--sticker-max, 160px)",
- maxHeight: "var(--sticker-max, 160px)",
- }
- : undefined
- }
- onError={() => setHasImageError(true)}
- loading="lazy"
- unoptimized
- />
- )}
- </div>
- );
-
- const dragData: TimelineDragData = shapePreset
- ? {
- id: item.id,
- type: "graphic",
- name: displayName,
- definitionId: shapePreset.definitionId,
- params: shapePreset.params ?? {},
- }
- : {
- id: item.id,
- type: "sticker",
- name: displayName,
- stickerId: item.id,
- };
-
- return (
- <div
- className={cn("relative", isAdding && "pointer-events-none opacity-50")}
- >
- <DraggableItem
- name={displayName}
- preview={preview}
- dragData={dragData}
- onAddToTimeline={handleAdd}
- aspectRatio={1}
- shouldShowLabel={false}
- isRounded
- variant="card"
- containerClassName={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>
- );
-}
+"use client";
+
+import Image from "next/image";
+import type { CSSProperties } from "react";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Spinner } from "@/components/ui/spinner";
+import { useEditor } from "@/hooks/use-editor";
+import { resolveStickerIntrinsicSize } from "@/lib/stickers";
+import {
+ buildGraphicElement,
+ buildStickerElement,
+} from "@/lib/timeline/element-utils";
+import { STICKER_CATEGORIES } from "@/constants/sticker-constants";
+import { getRegionLabel, resolveQueryToRegions } from "@/lib/stickers";
+import { parseShapeStickerId } from "@/lib/stickers/providers/shapes";
+import type { TimelineDragData } from "@/lib/timeline/drag";
+import type {
+ StickerBrowseSection,
+ StickerCategory,
+ StickerItem as StickerData,
+} from "@/lib/stickers";
+import { useStickersStore } from "@/stores/stickers-store";
+import { cn } from "@/utils/ui";
+import {
+ HappyIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+
+export function StickersView() {
+ const {
+ browseContent,
+ browseStickers,
+ searchQuery,
+ searchStickers,
+ selectedCategory,
+ setSearchQuery,
+ setSelectedCategory,
+ viewMode,
+ } = useStickersStore();
+
+ useEffect(() => {
+ if (viewMode === "browse" && !browseContent) {
+ void browseStickers();
+ }
+ }, [browseContent, browseStickers, viewMode]);
+
+ return (
+ <div className="flex h-full flex-col py-2">
+ <div className="px-2">
+ <Input
+ size="sm"
+ variant="default"
+ placeholder="Search..."
+ value={searchQuery}
+ onChange={(e) => {
+ setSearchQuery({ query: e.target.value });
+ void searchStickers({ query: e.target.value });
+ }}
+ showClearIcon
+ onClear={() => {
+ setSearchQuery({ query: "" });
+ void searchStickers({ query: "" });
+ }}
+ className="w-full"
+ containerClassName="w-full"
+ />
+ </div>
+
+ <div className="mt-2 flex min-h-0 flex-1 flex-col">
+ <div className="border-b border-border px-2">
+ <div
+ role="tablist"
+ aria-label="Sticker categories"
+ className="text-muted-foreground inline-flex h-auto items-center gap-0 bg-transparent p-0"
+ >
+ {Object.entries(STICKER_CATEGORIES).map(([key, label]) => {
+ const isActive = key === selectedCategory;
+ return (
+ <button
+ key={key}
+ type="button"
+ role="tab"
+ aria-selected={isActive}
+ onClick={() =>
+ setSelectedCategory({
+ category: key as StickerCategory,
+ })
+ }
+ className={cn(
+ "text-muted-foreground rounded-none border-b-2 border-transparent px-3 py-2 text-sm font-medium whitespace-nowrap -mb-px",
+ isActive && "text-primary border-primary",
+ )}
+ >
+ {label}
+ </button>
+ );
+ })}
+ </div>
+ </div>
+ <div className="min-h-0 flex-1 overflow-y-auto px-4 pt-4">
+ <StickersContentView />
+ </div>
+ </div>
+ </div>
+ );
+}
+
+function StickerGrid({
+ items,
+ shouldCapSize = false,
+}: {
+ items: StickerData[];
+ shouldCapSize?: boolean;
+}) {
+ const gridStyle: CSSProperties & {
+ "--sticker-min": string;
+ "--sticker-max"?: string;
+ } = {
+ gridTemplateColumns: shouldCapSize
+ ? "repeat(auto-fill, minmax(var(--sticker-min, 80px), var(--sticker-max, 140px)))"
+ : "repeat(auto-fill, minmax(var(--sticker-min, 80px), 1fr))",
+ "--sticker-min": "80px",
+ ...(shouldCapSize ? { "--sticker-max": "140px" } : {}),
+ };
+
+ return (
+ <div className="grid gap-2" style={gridStyle}>
+ {items.map((item) => (
+ <StickerItem key={item.id} item={item} shouldCapSize={shouldCapSize} />
+ ))}
+ </div>
+ );
+}
+
+function StickerRow({ items }: { items: StickerData[] }) {
+ return (
+ <div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hidden">
+ {items.map((item) => (
+ <div key={item.id} className="w-20 shrink-0">
+ <StickerItem item={item} shouldCapSize containerClassName="w-full" />
+ </div>
+ ))}
+ </div>
+ );
+}
+
+function EmptyView({ message }: { message: string }) {
+ return (
+ <div className="bg-background 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 RegionBanner({ region }: { region: string }) {
+ return (
+ <div className="flex h-7 items-center gap-1.5 rounded-lg border border-sky-100 bg-sky-50 px-2">
+ <svg
+ width="12"
+ height="12"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ className="shrink-0 text-sky-600"
+ aria-hidden="true"
+ >
+ <path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
+ <circle cx="12" cy="10" r="3" />
+ </svg>
+ <span className="text-xs font-semibold text-sky-600">{region}</span>
+ </div>
+ );
+}
+
+function StickersContentView() {
+ const {
+ browseContent,
+ clearRecentStickers,
+ isBrowsing,
+ isSearching,
+ searchQuery,
+ searchResults,
+ selectedCategory,
+ setSelectedCategory,
+ viewMode,
+ } = useStickersStore();
+
+ if (viewMode === "search") {
+ if (isSearching) {
+ return (
+ <div className="flex items-center justify-center py-8">
+ <Spinner className="text-muted-foreground size-6" />
+ </div>
+ );
+ }
+
+ if (searchResults?.items.length) {
+ const normalizedQuery = searchQuery.trim().toLowerCase();
+ const isRegionSearch =
+ selectedCategory === "flags" &&
+ resolveQueryToRegions({ query: normalizedQuery }) !== null;
+ const regionLabel = getRegionLabel({ query: normalizedQuery });
+
+ return (
+ <div className="flex flex-col gap-3 pb-4">
+ {isRegionSearch && <RegionBanner region={regionLabel} />}
+ <div className="flex items-center justify-between">
+ <span className="text-muted-foreground text-sm">
+ {searchResults.total} results
+ </span>
+ </div>
+ <StickerGrid items={searchResults.items} />
+ </div>
+ );
+ }
+
+ // "all" tab search — sections are in browseContent, fall through to section rendering below
+ if (selectedCategory !== "all" && searchQuery) {
+ return <EmptyView message={`No stickers found for "${searchQuery}"`} />;
+ }
+ }
+
+ if (isBrowsing && !browseContent) {
+ return (
+ <div className="flex items-center justify-center py-8">
+ <Spinner className="text-muted-foreground size-6" />
+ </div>
+ );
+ }
+
+ if (!browseContent?.sections.length) {
+ const categoryLabel = STICKER_CATEGORIES[selectedCategory];
+ return (
+ <EmptyView
+ message={
+ viewMode === "search"
+ ? `No stickers found for "${searchQuery}"`
+ : selectedCategory === "all"
+ ? "No stickers available yet."
+ : `No stickers available in ${categoryLabel.toLowerCase()} yet.`
+ }
+ />
+ );
+ }
+
+ return (
+ <div className="flex flex-col gap-4 pb-4">
+ {browseContent.sections.map((section) => (
+ <StickerSection
+ key={section.id}
+ section={section}
+ onClearRecent={clearRecentStickers}
+ onSeeAll={(category) => {
+ setSelectedCategory({ category });
+ }}
+ />
+ ))}
+ </div>
+ );
+}
+
+function StickerSection({
+ section,
+ onClearRecent,
+ onSeeAll,
+}: {
+ section: StickerBrowseSection;
+ onClearRecent: () => void;
+ onSeeAll: (category: StickerCategory) => void;
+}) {
+ const hasHeader =
+ Boolean(section.title) || section.id === "recent" || section.action;
+
+ return (
+ <div className="flex flex-col gap-2">
+ {hasHeader && (
+ <div className="flex items-center justify-between gap-2">
+ {section.title ? (
+ <p className="text-xs text-muted-foreground">{section.title}</p>
+ ) : (
+ <div />
+ )}
+
+ <div className="ml-auto flex items-center gap-2">
+ {section.id === "recent" && (
+ <Button
+ onClick={onClearRecent}
+ variant="text"
+ size="sm"
+ className="h-auto gap-1 p-0 text-xs text-muted-foreground"
+ >
+ Clear
+ </Button>
+ )}
+
+ {section.action?.type === "see-all" && section.action.category && (
+ <Button
+ variant="text"
+ size="sm"
+ className="h-auto gap-1 p-0 text-xs text-primary"
+ onClick={() => {
+ onSeeAll(section.action?.category as StickerCategory);
+ }}
+ >
+ See all
+ </Button>
+ )}
+ </div>
+ </div>
+ )}
+
+ {section.layout === "row" ? (
+ <StickerRow items={section.items} />
+ ) : (
+ <StickerGrid items={section.items} />
+ )}
+ </div>
+ );
+}
+
+interface StickerItemProps {
+ item: StickerData;
+ shouldCapSize?: boolean;
+ containerClassName?: string;
+}
+
+function StickerItem({
+ item,
+ shouldCapSize = false,
+ containerClassName,
+}: StickerItemProps) {
+ const editor = useEditor();
+ const { addToRecentStickers } = useStickersStore();
+ const [isAdding, setIsAdding] = useState(false);
+ const [hasImageError, setHasImageError] = useState(false);
+
+ useEffect(() => {
+ if (!item.id) {
+ return;
+ }
+
+ setHasImageError(false);
+ }, [item.id]);
+
+ const displayName = item.name;
+ const shapePreset =
+ item.provider === "shapes" ? parseShapeStickerId({ stickerId: item.id }) : null;
+
+ const handleAdd = async () => {
+ setIsAdding(true);
+ try {
+ const currentTime = editor.playback.getCurrentTime();
+
+ let element:
+ | ReturnType<typeof buildGraphicElement>
+ | ReturnType<typeof buildStickerElement>;
+ if (shapePreset) {
+ element = buildGraphicElement({
+ definitionId: shapePreset.definitionId,
+ name: shapePreset.name,
+ startTime: currentTime,
+ params: shapePreset.params,
+ });
+ } else {
+ const { width: intrinsicWidth, height: intrinsicHeight } =
+ await resolveStickerIntrinsicSize({ stickerId: item.id });
+ element = buildStickerElement({
+ stickerId: item.id,
+ name: item.name,
+ startTime: currentTime,
+ intrinsicWidth,
+ intrinsicHeight,
+ });
+ }
+
+ editor.timeline.insertElement({
+ placement: { mode: "auto" },
+ element,
+ });
+
+ addToRecentStickers({ stickerId: item.id });
+ } catch (error) {
+ console.error("Failed to add sticker:", error);
+ toast.error("Failed to add sticker to timeline");
+ } finally {
+ setIsAdding(false);
+ }
+ };
+
+ const preview = (
+ <div className="flex size-full items-center justify-center p-3">
+ {hasImageError ? (
+ <span className="text-muted-foreground text-center text-xs break-all">
+ {displayName}
+ </span>
+ ) : (
+ <Image
+ src={item.previewUrl}
+ alt={displayName}
+ width={64}
+ height={64}
+ className="size-full object-contain"
+ style={
+ shouldCapSize
+ ? {
+ maxWidth: "var(--sticker-max, 160px)",
+ maxHeight: "var(--sticker-max, 160px)",
+ }
+ : undefined
+ }
+ onError={() => setHasImageError(true)}
+ loading="lazy"
+ unoptimized
+ />
+ )}
+ </div>
+ );
+
+ const dragData: TimelineDragData = shapePreset
+ ? {
+ id: item.id,
+ type: "graphic",
+ name: displayName,
+ definitionId: shapePreset.definitionId,
+ params: shapePreset.params ?? {},
+ }
+ : {
+ id: item.id,
+ type: "sticker",
+ name: displayName,
+ stickerId: item.id,
+ };
+
+ return (
+ <div
+ className={cn("relative", isAdding && "pointer-events-none opacity-50")}
+ >
+ <DraggableItem
+ name={displayName}
+ preview={preview}
+ dragData={dragData}
+ onAddToTimeline={handleAdd}
+ aspectRatio={1}
+ shouldShowLabel={false}
+ isRounded
+ variant="card"
+ containerClassName={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>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/text.tsx b/apps/web/src/components/editor/panels/assets/views/text.tsx
index 075cc25b..da295da3 100644
--- a/apps/web/src/components/editor/panels/assets/views/text.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/text.tsx
@@ -1,46 +1,46 @@
-import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
-import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
-import { useEditor } from "@/hooks/use-editor";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-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: DEFAULTS.text.element,
- startTime: currentTime,
- });
-
- editor.timeline.insertElement({
- element,
- placement: { mode: "auto" },
- });
- };
-
- return (
- <PanelView title="Text">
- <DraggableItem
- name="Default text"
- preview={
- <div className="bg-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: DEFAULTS.text.element.type,
- name: DEFAULTS.text.element.name,
- content: DEFAULTS.text.element.content,
- }}
- aspectRatio={1}
- onAddToTimeline={handleAddToTimeline}
- shouldShowLabel={false}
- />
- </PanelView>
- );
-}
+import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
+import { useEditor } from "@/hooks/use-editor";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+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: DEFAULTS.text.element,
+ startTime: currentTime,
+ });
+
+ editor.timeline.insertElement({
+ element,
+ placement: { mode: "auto" },
+ });
+ };
+
+ return (
+ <PanelView title="Text">
+ <DraggableItem
+ name="Default text"
+ preview={
+ <div className="bg-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: DEFAULTS.text.element.type,
+ name: DEFAULTS.text.element.name,
+ content: DEFAULTS.text.element.content,
+ }}
+ aspectRatio={1}
+ onAddToTimeline={handleAddToTimeline}
+ shouldShowLabel={false}
+ />
+ </PanelView>
+ );
+}