summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/assets/views
diff options
context:
space:
mode:
authorMaze <167211895+mazeincoding@users.noreply.github.com>2026-01-31 00:20:04 +0100
committerGitHub <noreply@github.com>2026-01-31 00:20:04 +0100
commit7bf0984698e43b9aa2dd879f65951318ed410390 (patch)
treeccf2cd590a47d550152afc07129cb55c71a9ec35 /apps/web/src/components/editor/panels/assets/views
parent0173db99448d1c4c6415dcf15272e17551b48b04 (diff)
downloadopencut-7bf0984698e43b9aa2dd879f65951318ed410390.tar.gz
codebase overhaul (#697)
Diffstat (limited to 'apps/web/src/components/editor/panels/assets/views')
-rw-r--r--apps/web/src/components/editor/panels/assets/views/captions.tsx152
-rw-r--r--apps/web/src/components/editor/panels/assets/views/media.tsx675
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings.tsx394
-rw-r--r--apps/web/src/components/editor/panels/assets/views/sounds.tsx574
-rw-r--r--apps/web/src/components/editor/panels/assets/views/stickers.tsx615
-rw-r--r--apps/web/src/components/editor/panels/assets/views/text.tsx46
6 files changed, 2456 insertions, 0 deletions
diff --git a/apps/web/src/components/editor/panels/assets/views/captions.tsx b/apps/web/src/components/editor/panels/assets/views/captions.tsx
new file mode 100644
index 00000000..306bad2a
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/captions.tsx
@@ -0,0 +1,152 @@
+import { Button } from "@/components/ui/button";
+import { PropertyGroup } from "@/components/editor/panels/properties/property-item";
+import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { useState, useRef } from "react";
+import { extractTimelineAudio } from "@/lib/media/mediabunny";
+import { useEditor } from "@/hooks/use-editor";
+import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
+import { TRANSCRIPTION_LANGUAGES } from "@/constants/transcription-constants";
+import type {
+ TranscriptionLanguage,
+ TranscriptionProgress,
+} from "@/types/transcription";
+import { transcriptionService } from "@/services/transcription";
+import { decodeAudioToFloat32 } from "@/lib/media/audio";
+import { buildCaptionChunks } from "@/lib/transcription/caption";
+import { Spinner } from "@/components/ui/spinner";
+
+export function Captions() {
+ const [selectedLanguage, setSelectedLanguage] =
+ useState<TranscriptionLanguage>("auto");
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [processingStep, setProcessingStep] = useState("");
+ const [error, setError] = useState<string | null>(null);
+ const containerRef = useRef<HTMLDivElement>(null);
+ const editor = useEditor();
+
+ const handleProgress = (progress: TranscriptionProgress) => {
+ if (progress.status === "loading-model") {
+ setProcessingStep(`Loading model ${Math.round(progress.progress)}%`);
+ } else if (progress.status === "transcribing") {
+ setProcessingStep("Transcribing...");
+ }
+ };
+
+ const handleGenerateTranscript = async () => {
+ try {
+ setIsProcessing(true);
+ setError(null);
+ setProcessingStep("Extracting audio...");
+
+ const audioBlob = await extractTimelineAudio({
+ tracks: editor.timeline.getTracks(),
+ mediaAssets: editor.media.getAssets(),
+ totalDuration: editor.timeline.getTotalDuration(),
+ });
+
+ setProcessingStep("Preparing audio...");
+ const { samples } = await decodeAudioToFloat32({ audioBlob });
+
+ const result = await transcriptionService.transcribe({
+ audioData: samples,
+ language: selectedLanguage === "auto" ? undefined : selectedLanguage,
+ onProgress: handleProgress,
+ });
+
+ setProcessingStep("Generating captions...");
+ const captionChunks = buildCaptionChunks({ segments: result.segments });
+
+ const captionTrackId = editor.timeline.addTrack({
+ type: "text",
+ index: 0,
+ });
+
+ for (let i = 0; i < captionChunks.length; i++) {
+ const caption = captionChunks[i];
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId: captionTrackId },
+ element: {
+ ...DEFAULT_TEXT_ELEMENT,
+ name: `Caption ${i + 1}`,
+ content: caption.text,
+ duration: caption.duration,
+ startTime: caption.startTime,
+ fontSize: 65,
+ fontWeight: "bold",
+ },
+ });
+ }
+ } catch (error) {
+ console.error("Transcription failed:", error);
+ setError(
+ error instanceof Error ? error.message : "An unexpected error occurred",
+ );
+ } finally {
+ setIsProcessing(false);
+ setProcessingStep("");
+ }
+ };
+
+ const handleLanguageChange = ({ value }: { value: string }) => {
+ if (value === "auto") {
+ setSelectedLanguage("auto");
+ return;
+ }
+
+ const matchedLanguage = TRANSCRIPTION_LANGUAGES.find(
+ (language) => language.code === value,
+ );
+ if (!matchedLanguage) return;
+ setSelectedLanguage(matchedLanguage.code);
+ };
+
+ return (
+ <BaseView
+ ref={containerRef}
+ className="flex h-full flex-col justify-between"
+ >
+ <PropertyGroup title="Language">
+ <Select
+ value={selectedLanguage}
+ onValueChange={(value) => handleLanguageChange({ value })}
+ >
+ <SelectTrigger className="bg-panel-accent h-8 w-full text-xs">
+ <SelectValue placeholder="Select a language" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="auto">Auto detect</SelectItem>
+ {TRANSCRIPTION_LANGUAGES.map((language) => (
+ <SelectItem key={language.code} value={language.code}>
+ {language.name}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </PropertyGroup>
+
+ <div className="flex flex-col gap-4">
+ {error && (
+ <div className="bg-destructive/10 border-destructive/20 rounded-md border p-3">
+ <p className="text-destructive text-sm">{error}</p>
+ </div>
+ )}
+
+ <Button
+ className="w-full"
+ onClick={handleGenerateTranscript}
+ disabled={isProcessing}
+ >
+ {isProcessing && <Spinner className="mr-1" />}
+ {isProcessing ? processingStep : "Generate transcript"}
+ </Button>
+ </div>
+ </BaseView>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/media.tsx b/apps/web/src/components/editor/panels/assets/views/media.tsx
new file mode 100644
index 00000000..76ae0064
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/media.tsx
@@ -0,0 +1,675 @@
+"use client";
+
+import Image from "next/image";
+import { useMemo, useState } from "react";
+import { toast } from "sonner";
+import { MediaDragOverlay } from "@/components/editor/panels/assets/drag-overlay";
+import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
+import { Button } from "@/components/ui/button";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuTrigger,
+} from "@/components/ui/context-menu";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { useEditor } from "@/hooks/use-editor";
+import { useFileUpload } from "@/hooks/use-file-upload";
+import { useRevealItem } from "@/hooks/use-reveal-item";
+import { processMediaAssets } from "@/lib/media/processing";
+import {
+ buildImageElement,
+ buildUploadAudioElement,
+ buildVideoElement,
+} from "@/lib/timeline/element-utils";
+import { useAssetsPanelStore } from "@/stores/assets-panel-store";
+import type { MediaAsset } from "@/types/assets";
+import type { CreateTimelineElement } from "@/types/timeline";
+import { cn } from "@/utils/ui";
+import {
+ CloudUploadIcon,
+ GridViewIcon,
+ LeftToRightListDashIcon,
+ SortingOneNineIcon,
+ Image02Icon,
+ MusicNote03Icon,
+ Video01Icon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
+
+export function MediaView() {
+ const editor = useEditor();
+ const mediaFiles = editor.media.getAssets();
+ const activeProject = editor.project.getActive();
+
+ const { mediaViewMode, setMediaViewMode, highlightMediaId, clearHighlight } =
+ useAssetsPanelStore();
+ const { highlightedId, registerElement } = useRevealItem(
+ highlightMediaId,
+ clearHighlight,
+ );
+
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [progress, setProgress] = useState(0);
+ const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">(
+ "name",
+ );
+ const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
+
+ const processFiles = async ({ files }: { files: FileList }) => {
+ if (!files || files.length === 0) return;
+ if (!activeProject) {
+ toast.error("No active project");
+ return;
+ }
+
+ setIsProcessing(true);
+ setProgress(0);
+ try {
+ const processedAssets = await processMediaAssets({
+ files,
+ onProgress: (progress: { progress: number }) =>
+ setProgress(progress.progress),
+ });
+ for (const asset of processedAssets) {
+ await editor.media.addMediaAsset({
+ projectId: activeProject.metadata.id,
+ asset,
+ });
+ }
+ } catch (error) {
+ console.error("Error processing files:", error);
+ toast.error("Failed to process files");
+ } finally {
+ setIsProcessing(false);
+ setProgress(0);
+ }
+ };
+
+ const { isDragOver, dragProps, openFilePicker, fileInputProps } =
+ useFileUpload({
+ accept: "image/*,video/*,audio/*",
+ multiple: true,
+ onFilesSelected: (files) => processFiles({ files }),
+ });
+
+ const handleRemove = async ({
+ event,
+ id,
+ }: {
+ event: React.MouseEvent;
+ id: string;
+ }) => {
+ event.stopPropagation();
+
+ if (!activeProject) {
+ toast.error("No active project");
+ return;
+ }
+
+ await editor.media.removeMediaAsset({
+ projectId: activeProject.metadata.id,
+ id,
+ });
+ };
+
+ const addElementAtTime = ({
+ asset,
+ startTime,
+ }: {
+ asset: MediaAsset;
+ startTime: number;
+ }): boolean => {
+ const element = createElementFromMedia({ asset, startTime });
+ editor.timeline.insertElement({
+ element,
+ placement: { mode: "auto" },
+ });
+ return true;
+ };
+
+ const filteredMediaItems = useMemo(() => {
+ const filtered = mediaFiles.filter((item) => !item.ephemeral);
+
+ filtered.sort((a, b) => {
+ let valueA: string | number;
+ let valueB: string | number;
+
+ switch (sortBy) {
+ case "name":
+ valueA = a.name.toLowerCase();
+ valueB = b.name.toLowerCase();
+ break;
+ case "type":
+ valueA = a.type;
+ valueB = b.type;
+ break;
+ case "duration":
+ valueA = a.duration || 0;
+ valueB = b.duration || 0;
+ break;
+ case "size":
+ valueA = a.file.size;
+ valueB = b.file.size;
+ break;
+ default:
+ return 0;
+ }
+
+ if (valueA < valueB) return sortOrder === "asc" ? -1 : 1;
+ if (valueA > valueB) return sortOrder === "asc" ? 1 : -1;
+ return 0;
+ });
+
+ return filtered;
+ }, [mediaFiles, sortBy, sortOrder]);
+
+ const previewComponents = useMemo(() => {
+ const previews = new Map<string, React.ReactNode>();
+
+ filteredMediaItems.forEach((item) => {
+ previews.set(item.id, <MediaPreview item={item} />);
+ previews.set(
+ `compact-${item.id}`,
+ <MediaPreview item={item} variant="compact" />,
+ );
+ });
+
+ return previews;
+ }, [filteredMediaItems]);
+
+ const renderPreview = (item: MediaAsset) => previewComponents.get(item.id);
+ const renderCompactPreview = (item: MediaAsset) =>
+ previewComponents.get(`compact-${item.id}`);
+
+ return (
+ <>
+ <input {...fileInputProps} />
+
+ <div
+ className={`relative flex h-full flex-col gap-1 ${isDragOver ? "bg-accent/30" : ""}`}
+ {...dragProps}
+ >
+ <div className="bg-panel py-2 px-4 flex items-center justify-between border-b">
+ <span className="text-muted-foreground text-sm">Assets</span>
+ <div className="flex items-center gap-0">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ size="icon"
+ variant="text"
+ onClick={() =>
+ setMediaViewMode(
+ mediaViewMode === "grid" ? "list" : "grid",
+ )
+ }
+ disabled={isProcessing}
+ className="items-center justify-center"
+ >
+ {mediaViewMode === "grid" ? (
+ <HugeiconsIcon icon={LeftToRightListDashIcon} />
+ ) : (
+ <HugeiconsIcon icon={GridViewIcon} />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>
+ {mediaViewMode === "grid"
+ ? "Switch to list view"
+ : "Switch to grid view"}
+ </p>
+ </TooltipContent>
+ <Tooltip>
+ <DropdownMenu>
+ <TooltipTrigger asChild>
+ <DropdownMenuTrigger asChild>
+ <Button
+ size="icon"
+ variant="text"
+ disabled={isProcessing}
+ className="items-center justify-center"
+ >
+ <HugeiconsIcon icon={SortingOneNineIcon} />
+ </Button>
+ </DropdownMenuTrigger>
+ </TooltipTrigger>
+ <DropdownMenuContent align="end">
+ <SortMenuItem
+ label="Name"
+ sortKey="name"
+ currentSortBy={sortBy}
+ currentSortOrder={sortOrder}
+ onSort={({ key }) => {
+ if (sortBy === key) {
+ setSortOrder(sortOrder === "asc" ? "desc" : "asc");
+ } else {
+ setSortBy(key);
+ setSortOrder("asc");
+ }
+ }}
+ />
+ <SortMenuItem
+ label="Type"
+ sortKey="type"
+ currentSortBy={sortBy}
+ currentSortOrder={sortOrder}
+ onSort={({ key }) => {
+ if (sortBy === key) {
+ setSortOrder(sortOrder === "asc" ? "desc" : "asc");
+ } else {
+ setSortBy(key);
+ setSortOrder("asc");
+ }
+ }}
+ />
+ <SortMenuItem
+ label="Duration"
+ sortKey="duration"
+ currentSortBy={sortBy}
+ currentSortOrder={sortOrder}
+ onSort={({ key }) => {
+ if (sortBy === key) {
+ setSortOrder(sortOrder === "asc" ? "desc" : "asc");
+ } else {
+ setSortBy(key);
+ setSortOrder("asc");
+ }
+ }}
+ />
+ <SortMenuItem
+ label="File size"
+ sortKey="size"
+ currentSortBy={sortBy}
+ currentSortOrder={sortOrder}
+ onSort={({ key }) => {
+ if (sortBy === key) {
+ setSortOrder(sortOrder === "asc" ? "desc" : "asc");
+ } else {
+ setSortBy(key);
+ setSortOrder("asc");
+ }
+ }}
+ />
+ </DropdownMenuContent>
+ </DropdownMenu>
+ <TooltipContent>
+ <p>
+ Sort by {sortBy} (
+ {sortOrder === "asc" ? "ascending" : "descending"})
+ </p>
+ </TooltipContent>
+ </Tooltip>
+ </Tooltip>
+ </TooltipProvider>
+ <Button
+ variant="outline"
+ onClick={openFilePicker}
+ disabled={isProcessing}
+ size="sm"
+ className="items-center justify-center gap-1.5 ml-1.5"
+ >
+ <HugeiconsIcon icon={CloudUploadIcon} />
+ Upload
+ </Button>
+ </div>
+ </div>
+
+ <div className="scrollbar-thin size-full overflow-y-auto pt-1">
+ <div className="w-full flex-1 p-3 pt-0">
+ {isDragOver || filteredMediaItems.length === 0 ? (
+ <MediaDragOverlay
+ isVisible={true}
+ isProcessing={isProcessing}
+ progress={progress}
+ onClick={openFilePicker}
+ />
+ ) : mediaViewMode === "grid" ? (
+ <GridView
+ items={filteredMediaItems}
+ renderPreview={renderPreview}
+ onRemove={handleRemove}
+ onAddToTimeline={addElementAtTime}
+ highlightedId={highlightedId}
+ registerElement={registerElement}
+ />
+ ) : (
+ <ListView
+ items={filteredMediaItems}
+ renderPreview={renderCompactPreview}
+ onRemove={handleRemove}
+ onAddToTimeline={addElementAtTime}
+ highlightedId={highlightedId}
+ registerElement={registerElement}
+ />
+ )}
+ </div>
+ </div>
+ </div>
+ </>
+ );
+}
+
+function MediaItemWithContextMenu({
+ item,
+ children,
+ onRemove,
+}: {
+ item: MediaAsset;
+ children: React.ReactNode;
+ onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
+}) {
+ return (
+ <ContextMenu>
+ <ContextMenuTrigger>{children}</ContextMenuTrigger>
+ <ContextMenuContent>
+ <ContextMenuItem>Export clips</ContextMenuItem>
+ <ContextMenuItem
+ variant="destructive"
+ onClick={(event) => onRemove({ event, id: item.id })}
+ >
+ Delete
+ </ContextMenuItem>
+ </ContextMenuContent>
+ </ContextMenu>
+ );
+}
+
+function GridView({
+ items,
+ renderPreview,
+ onRemove,
+ onAddToTimeline,
+ highlightedId,
+ registerElement,
+}: {
+ items: MediaAsset[];
+ renderPreview: (item: MediaAsset) => React.ReactNode;
+ onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
+ onAddToTimeline: ({
+ asset,
+ startTime,
+ }: {
+ asset: MediaAsset;
+ startTime: number;
+ }) => boolean;
+ highlightedId: string | null;
+ registerElement: (id: string, element: HTMLElement | null) => void;
+}) {
+ return (
+ <div
+ className="grid gap-2"
+ style={{
+ gridTemplateColumns: "repeat(auto-fill, 160px)",
+ }}
+ >
+ {items.map((item) => (
+ <div key={item.id} ref={(el) => registerElement(item.id, el)}>
+ <MediaItemWithContextMenu item={item} onRemove={onRemove}>
+ <DraggableItem
+ name={item.name}
+ preview={renderPreview(item)}
+ dragData={{
+ id: item.id,
+ type: "media",
+ mediaType: item.type,
+ name: item.name,
+ }}
+ shouldShowPlusOnDrag={false}
+ onAddToTimeline={({ currentTime }) =>
+ onAddToTimeline({ asset: item, startTime: currentTime })
+ }
+ isRounded={false}
+ variant="card"
+ isHighlighted={highlightedId === item.id}
+ />
+ </MediaItemWithContextMenu>
+ </div>
+ ))}
+ </div>
+ );
+}
+
+function ListView({
+ items,
+ renderPreview,
+ onRemove,
+ onAddToTimeline,
+ highlightedId,
+ registerElement,
+}: {
+ items: MediaAsset[];
+ renderPreview: (item: MediaAsset) => React.ReactNode;
+ onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
+ onAddToTimeline: ({
+ asset,
+ startTime,
+ }: {
+ asset: MediaAsset;
+ startTime: number;
+ }) => boolean;
+ highlightedId: string | null;
+ registerElement: (id: string, element: HTMLElement | null) => void;
+}) {
+ return (
+ <div className="space-y-1">
+ {items.map((item) => (
+ <div key={item.id} ref={(el) => registerElement(item.id, el)}>
+ <MediaItemWithContextMenu item={item} onRemove={onRemove}>
+ <DraggableItem
+ name={item.name}
+ preview={renderPreview(item)}
+ dragData={{
+ id: item.id,
+ type: "media",
+ mediaType: item.type,
+ name: item.name,
+ }}
+ shouldShowPlusOnDrag={false}
+ onAddToTimeline={({ currentTime }) =>
+ onAddToTimeline({ asset: item, startTime: currentTime })
+ }
+ variant="compact"
+ isHighlighted={highlightedId === item.id}
+ />
+ </MediaItemWithContextMenu>
+ </div>
+ ))}
+ </div>
+ );
+}
+
+const formatDuration = ({ duration }: { duration: number }) => {
+ const min = Math.floor(duration / 60);
+ const sec = Math.floor(duration % 60);
+ return `${min}:${sec.toString().padStart(2, "0")}`;
+};
+
+function MediaDurationBadge({ duration }: { duration?: number }) {
+ if (!duration) return null;
+
+ return (
+ <div className="absolute right-1 bottom-1 rounded bg-black/70 px-1 text-xs text-white">
+ {formatDuration({ duration })}
+ </div>
+ );
+}
+
+function MediaDurationLabel({ duration }: { duration?: number }) {
+ if (!duration) return null;
+
+ return (
+ <span className="text-xs opacity-70">{formatDuration({ duration })}</span>
+ );
+}
+
+function MediaTypePlaceholder({
+ icon,
+ label,
+ duration,
+ variant,
+}: {
+ icon: IconSvgElement;
+ label: string;
+ duration?: number;
+ variant: "muted" | "bordered";
+}) {
+ const iconClassName = cn("size-6", variant === "bordered" && "mb-1");
+
+ return (
+ <div
+ className={cn(
+ "text-muted-foreground flex size-full flex-col items-center justify-center rounded",
+ variant === "muted" ? "bg-muted/30" : "border",
+ )}
+ >
+ <HugeiconsIcon icon={icon} className={iconClassName} />
+ <span className="text-xs">{label}</span>
+ <MediaDurationLabel duration={duration} />
+ </div>
+ );
+}
+
+function MediaPreview({
+ item,
+ variant = "grid",
+}: {
+ item: MediaAsset;
+ variant?: "grid" | "compact";
+}) {
+ const shouldShowDurationBadge = variant === "grid";
+
+ if (item.type === "image") {
+ return (
+ <div className="relative flex size-full items-center justify-center">
+ <Image
+ src={item.url ?? ""}
+ alt={item.name}
+ fill
+ sizes="100vw"
+ className="object-cover"
+ loading="lazy"
+ unoptimized
+ />
+ </div>
+ );
+ }
+
+ if (item.type === "video") {
+ if (item.thumbnailUrl) {
+ return (
+ <div className="relative size-full">
+ <Image
+ src={item.thumbnailUrl}
+ alt={item.name}
+ fill
+ sizes="100vw"
+ className="rounded object-cover"
+ loading="lazy"
+ unoptimized
+ />
+ {shouldShowDurationBadge ? (
+ <MediaDurationBadge duration={item.duration} />
+ ) : null}
+ </div>
+ );
+ }
+
+ return (
+ <MediaTypePlaceholder
+ icon={Video01Icon}
+ label="Video"
+ duration={item.duration}
+ variant="muted"
+ />
+ );
+ }
+
+ if (item.type === "audio") {
+ return (
+ <MediaTypePlaceholder
+ icon={MusicNote03Icon}
+ label="Audio"
+ duration={item.duration}
+ variant="bordered"
+ />
+ );
+ }
+
+ return (
+ <MediaTypePlaceholder icon={Image02Icon} label="Unknown" variant="muted" />
+ );
+}
+
+function SortMenuItem({
+ label,
+ sortKey,
+ currentSortBy,
+ currentSortOrder,
+ onSort,
+}: {
+ label: string;
+ sortKey: "name" | "type" | "duration" | "size";
+ currentSortBy: string;
+ currentSortOrder: "asc" | "desc";
+ onSort: ({ key }: { key: "name" | "type" | "duration" | "size" }) => void;
+}) {
+ const isActive = currentSortBy === sortKey;
+ const arrow = isActive ? (currentSortOrder === "asc" ? "↑" : "↓") : "";
+
+ return (
+ <DropdownMenuItem onClick={() => onSort({ key: sortKey })}>
+ {label} {arrow}
+ </DropdownMenuItem>
+ );
+}
+
+function createElementFromMedia({
+ asset,
+ startTime,
+}: {
+ asset: MediaAsset;
+ startTime: number;
+}): CreateTimelineElement {
+ const duration =
+ asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+
+ switch (asset.type) {
+ case "video":
+ return buildVideoElement({
+ mediaId: asset.id,
+ name: asset.name,
+ duration,
+ startTime,
+ });
+ case "image":
+ return buildImageElement({
+ mediaId: asset.id,
+ name: asset.name,
+ duration,
+ startTime,
+ });
+ case "audio":
+ return buildUploadAudioElement({
+ mediaId: asset.id,
+ name: asset.name,
+ duration,
+ startTime,
+ });
+ default:
+ throw new Error(`Unsupported media type: ${asset.type}`);
+ }
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/settings.tsx b/apps/web/src/components/editor/panels/assets/views/settings.tsx
new file mode 100644
index 00000000..b8c54510
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/settings.tsx
@@ -0,0 +1,394 @@
+"use client";
+
+import Image from "next/image";
+import { memo, useCallback, useMemo } from "react";
+import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ BLUR_INTENSITY_PRESETS,
+ DEFAULT_BLUR_INTENSITY,
+ DEFAULT_COLOR,
+ FPS_PRESETS,
+} from "@/constants/project-constants";
+import { patternCraftGradients } from "@/data/colors/pattern-craft";
+import { colors } from "@/data/colors/solid";
+import { syntaxUIGradients } from "@/data/colors/syntax-ui";
+import { useEditor } from "@/hooks/use-editor";
+import { useEditorStore } from "@/stores/editor-store";
+import type { TProject } from "@/types/project";
+import { dimensionToAspectRatio } from "@/utils/geometry";
+import { cn } from "@/utils/ui";
+import {
+ PropertyGroup,
+ PropertyItem,
+ PropertyItemLabel,
+ PropertyItemValue,
+} from "@/components/editor/panels/properties/property-item";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { DropperIcon } from "@hugeicons/core-free-icons";
+
+export function SettingsView() {
+ return <ProjectSettingsTabs />;
+}
+
+function ProjectSettingsTabs() {
+ return (
+ <BaseView
+ defaultTab="project-info"
+ tabs={[
+ {
+ value: "project-info",
+ label: "Project info",
+ content: (
+ <div className="p-5">
+ <ProjectInfoView />
+ </div>
+ ),
+ },
+ {
+ value: "background",
+ label: "Background",
+ content: (
+ <div className="flex h-full flex-col justify-between">
+ <div className="flex-1 p-5">
+ <BackgroundView />
+ </div>
+ {/* <div className="bg-panel/85 sticky -bottom-0 flex flex-col backdrop-blur-lg">
+ <Separator />
+ <Button className="text-muted-foreground hover:text-foreground/85 h-auto w-fit !bg-transparent p-5 py-4 text-xs shadow-none">
+ Custom background
+ <PlusIcon />
+ </Button>
+ </div> */}
+
+ {/* another ui */}
+ {/* <div className="flex flex-col justify-center items-center pb-5 sticky bottom-0">
+ <Button className="w-fit h-auto gap-1.5 px-3.5 py-1.5 bg-foreground hover:bg-foreground/85 text-background rounded-full">
+ <span className="text-sm">Custom</span>
+ <PlusIcon className="" />
+ </Button>
+ </div> */}
+ </div>
+ ),
+ },
+ ]}
+ className="flex h-full flex-col justify-between p-0"
+ />
+ );
+}
+
+function getCurrentCanvasSize({ activeProject }: { activeProject: TProject }) {
+ const { canvasSize } = activeProject.settings;
+
+ return {
+ width: canvasSize.width,
+ height: canvasSize.height,
+ };
+}
+
+function ProjectInfoView() {
+ const editor = useEditor();
+ const activeProject = editor.project.getActive();
+ const { canvasPresets } = useEditorStore();
+
+ const findPresetIndexByAspectRatio = ({
+ presets,
+ targetAspectRatio,
+ }: {
+ presets: Array<{ width: number; height: number }>;
+ targetAspectRatio: string;
+ }) => {
+ for (let index = 0; index < presets.length; index++) {
+ const preset = presets[index];
+ const presetAspectRatio = dimensionToAspectRatio({
+ width: preset.width,
+ height: preset.height,
+ });
+ if (presetAspectRatio === targetAspectRatio) {
+ return index;
+ }
+ }
+ return -1;
+ };
+
+ const currentCanvasSize = getCurrentCanvasSize({ activeProject });
+ const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize);
+ const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null;
+ const presetIndex = findPresetIndexByAspectRatio({
+ presets: canvasPresets,
+ targetAspectRatio: currentAspectRatio,
+ });
+ const originalPresetValue = "original";
+ const selectedPresetValue =
+ presetIndex !== -1 ? presetIndex.toString() : originalPresetValue;
+
+ const handleAspectRatioChange = ({ value }: { value: string }) => {
+ if (value === originalPresetValue) {
+ const canvasSize = originalCanvasSize ?? currentCanvasSize;
+ editor.project.updateSettings({
+ settings: { canvasSize },
+ });
+ return;
+ }
+ const index = parseInt(value, 10);
+ const preset = canvasPresets[index];
+ if (preset) {
+ editor.project.updateSettings({ settings: { canvasSize: preset } });
+ }
+ };
+
+ const handleFpsChange = (value: string) => {
+ const fps = parseFloat(value);
+ editor.project.updateSettings({ settings: { fps } });
+ };
+
+ return (
+ <div className="flex flex-col gap-4">
+ <PropertyItem direction="column">
+ <PropertyItemLabel>Name</PropertyItemLabel>
+ <PropertyItemValue>{activeProject.metadata.name}</PropertyItemValue>
+ </PropertyItem>
+
+ <PropertyItem direction="column">
+ <PropertyItemLabel>Aspect ratio</PropertyItemLabel>
+ <PropertyItemValue>
+ <Select
+ value={selectedPresetValue}
+ onValueChange={(value) => handleAspectRatioChange({ value })}
+ >
+ <SelectTrigger className="bg-panel-accent">
+ <SelectValue placeholder="Select an aspect ratio" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value={originalPresetValue}>Original</SelectItem>
+ {canvasPresets.map((preset, index) => {
+ const label = dimensionToAspectRatio({
+ width: preset.width,
+ height: preset.height,
+ });
+ return (
+ <SelectItem key={label} value={index.toString()}>
+ {label}
+ </SelectItem>
+ );
+ })}
+ </SelectContent>
+ </Select>
+ </PropertyItemValue>
+ </PropertyItem>
+
+ <PropertyItem direction="column">
+ <PropertyItemLabel>Frame rate</PropertyItemLabel>
+ <PropertyItemValue>
+ <Select
+ value={activeProject.settings.fps.toString()}
+ onValueChange={handleFpsChange}
+ >
+ <SelectTrigger className="bg-panel-accent">
+ <SelectValue placeholder="Select a frame rate" />
+ </SelectTrigger>
+ <SelectContent>
+ {FPS_PRESETS.map((preset) => (
+ <SelectItem key={preset.value} value={preset.value}>
+ {preset.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </PropertyItemValue>
+ </PropertyItem>
+ </div>
+ );
+}
+
+const BlurPreview = memo(
+ ({
+ blur,
+ isSelected,
+ onSelect,
+ }: {
+ blur: { label: string; value: number };
+ isSelected: boolean;
+ onSelect: () => void;
+ }) => (
+ <button
+ className={cn(
+ "border-foreground/15 hover:border-primary relative aspect-square w-full cursor-pointer overflow-hidden rounded-sm border",
+ isSelected && "border-primary border-2",
+ )}
+ onClick={onSelect}
+ type="button"
+ aria-label={`Select ${blur.label} blur`}
+ >
+ <Image
+ src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
+ alt={`Blur preview ${blur.label}`}
+ fill
+ className="object-cover"
+ style={{ filter: `blur(${blur.value}px)` }}
+ loading="eager"
+ />
+ <div className="absolute right-1 bottom-1 left-1 text-center">
+ <span className="rounded bg-black/50 px-1 text-xs text-white">
+ {blur.label}
+ </span>
+ </div>
+ </button>
+ ),
+);
+
+BlurPreview.displayName = "BlurPreview";
+
+const BackgroundPreviews = memo(
+ ({
+ backgrounds,
+ currentBackgroundColor,
+ isColorBackground,
+ handleColorSelect,
+ useBackgroundColor = false,
+ }: {
+ backgrounds: string[];
+ currentBackgroundColor: string;
+ isColorBackground: boolean;
+ handleColorSelect: ({ bg }: { bg: string }) => void;
+ useBackgroundColor?: boolean;
+ }) => {
+ return useMemo(
+ () =>
+ backgrounds.map((bg, index) => (
+ <button
+ key={`${index}-${bg}`}
+ className={cn(
+ "border-foreground/15 hover:border-primary aspect-square w-full cursor-pointer rounded-sm border",
+ isColorBackground &&
+ bg === currentBackgroundColor &&
+ "border-primary border-2",
+ )}
+ style={
+ useBackgroundColor
+ ? { backgroundColor: bg }
+ : {
+ background: bg,
+ backgroundSize: "cover",
+ backgroundPosition: "center",
+ backgroundRepeat: "no-repeat",
+ }
+ }
+ onClick={() => handleColorSelect({ bg })}
+ type="button"
+ aria-label={`Select background ${useBackgroundColor ? bg : index + 1}`}
+ />
+ )),
+ [
+ backgrounds,
+ isColorBackground,
+ currentBackgroundColor,
+ handleColorSelect,
+ useBackgroundColor,
+ ],
+ );
+ },
+);
+
+BackgroundPreviews.displayName = "BackgroundPreviews";
+
+function BackgroundView() {
+ const editor = useEditor();
+ const activeProject = editor.project.getActive();
+ const blurLevels = useMemo(() => BLUR_INTENSITY_PRESETS, []);
+
+ const handleBlurSelect = useCallback(
+ async ({ blurIntensity }: { blurIntensity: number }) => {
+ await editor.project.updateSettings({
+ settings: { background: { type: "blur", blurIntensity } },
+ });
+ },
+ [editor.project],
+ );
+
+ const handleColorSelect = useCallback(
+ async ({ color }: { color: string }) => {
+ await editor.project.updateSettings({
+ settings: { background: { type: "color", color } },
+ });
+ },
+ [editor.project],
+ );
+
+ const currentBlurIntensity =
+ activeProject.settings.background.type === "blur"
+ ? activeProject.settings.background.blurIntensity
+ : DEFAULT_BLUR_INTENSITY;
+
+ const currentBackgroundColor =
+ activeProject.settings.background.type === "color"
+ ? activeProject.settings.background.color
+ : DEFAULT_COLOR;
+
+ const isBlurBackground = activeProject.settings.background.type === "blur";
+ const isColorBackground = activeProject.settings.background.type === "color";
+
+ const blurPreviews = useMemo(
+ () =>
+ blurLevels.map((blur) => (
+ <BlurPreview
+ key={blur.value}
+ blur={blur}
+ isSelected={isBlurBackground && currentBlurIntensity === blur.value}
+ onSelect={() => handleBlurSelect({ blurIntensity: blur.value })}
+ />
+ )),
+ [blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect],
+ );
+
+ return (
+ <div className="flex h-full flex-col gap-4">
+ <PropertyGroup title="Blur" defaultExpanded={false}>
+ <div className="grid w-full grid-cols-4 gap-2">{blurPreviews}</div>
+ </PropertyGroup>
+
+ <PropertyGroup title="Colors" defaultExpanded={false}>
+ <div className="grid w-full grid-cols-4 gap-2">
+ <div className="border-foreground/15 hover:border-primary flex aspect-square w-full cursor-pointer items-center justify-center rounded-sm border">
+ <HugeiconsIcon icon={DropperIcon} className="size-4" />
+ </div>
+ <BackgroundPreviews
+ backgrounds={colors}
+ currentBackgroundColor={currentBackgroundColor}
+ isColorBackground={isColorBackground}
+ handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
+ useBackgroundColor={true}
+ />
+ </div>
+ </PropertyGroup>
+
+ <PropertyGroup title="Pattern craft" defaultExpanded={false}>
+ <div className="grid w-full grid-cols-4 gap-2">
+ <BackgroundPreviews
+ backgrounds={patternCraftGradients}
+ currentBackgroundColor={currentBackgroundColor}
+ isColorBackground={isColorBackground}
+ handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
+ />
+ </div>
+ </PropertyGroup>
+
+ <PropertyGroup title="Syntax UI" defaultExpanded={false}>
+ <div className="grid w-full grid-cols-4 gap-2">
+ <BackgroundPreviews
+ backgrounds={syntaxUIGradients}
+ currentBackgroundColor={currentBackgroundColor}
+ isColorBackground={isColorBackground}
+ handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
+ />
+ </div>
+ </PropertyGroup>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/sounds.tsx b/apps/web/src/components/editor/panels/assets/views/sounds.tsx
new file mode 100644
index 00000000..84a5d0bf
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/sounds.tsx
@@ -0,0 +1,574 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Input } from "@/components/ui/input";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Separator } from "@/components/ui/separator";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
+import { useSoundSearch } from "@/hooks/use-sound-search";
+import { useSoundsStore } from "@/stores/sounds-store";
+import type { SavedSound, SoundEffect } from "@/types/sounds";
+import { cn } from "@/utils/ui";
+import {
+ FavouriteIcon,
+ FilterMailIcon,
+ PauseIcon,
+ PlayIcon,
+ PlusSignIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+
+export function SoundsView() {
+ return (
+ <div className="flex h-full flex-col">
+ <Tabs defaultValue="sound-effects" className="flex h-full flex-col">
+ <div className="px-3 pt-4 pb-0">
+ <TabsList>
+ <TabsTrigger value="sound-effects">Sound effects</TabsTrigger>
+ <TabsTrigger value="songs">Songs</TabsTrigger>
+ <TabsTrigger value="saved">Saved</TabsTrigger>
+ </TabsList>
+ </div>
+ <Separator className="my-4" />
+ <TabsContent
+ value="sound-effects"
+ className="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0"
+ >
+ <SoundEffectsView />
+ </TabsContent>
+ <TabsContent
+ value="saved"
+ className="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0"
+ >
+ <SavedSoundsView />
+ </TabsContent>
+ <TabsContent
+ value="songs"
+ className="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0"
+ >
+ <SongsView />
+ </TabsContent>
+ </Tabs>
+ </div>
+ );
+}
+
+function SoundEffectsView() {
+ const {
+ topSoundEffects,
+ isLoading,
+ searchQuery,
+ setSearchQuery,
+ scrollPosition,
+ setScrollPosition,
+ loadSavedSounds,
+ showCommercialOnly,
+ toggleCommercialFilter,
+ hasLoaded,
+ setTopSoundEffects,
+ setLoading,
+ setError,
+ setHasLoaded,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ } = useSoundsStore();
+ const {
+ results: searchResults,
+ isLoading: isSearching,
+ loadMore,
+ hasNextPage,
+ isLoadingMore,
+ } = useSoundSearch({
+ query: searchQuery,
+ commercialOnly: showCommercialOnly,
+ });
+
+ const [playingId, setPlayingId] = useState<number | null>(null);
+ const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(
+ null,
+ );
+
+ const { scrollAreaRef, handleScroll } = useInfiniteScroll({
+ onLoadMore: loadMore,
+ hasMore: hasNextPage,
+ isLoading: isLoadingMore || isSearching,
+ });
+
+ useEffect(() => {
+ loadSavedSounds();
+ }, [loadSavedSounds]);
+
+ useEffect(() => {
+ if (hasLoaded) {
+ return;
+ }
+
+ let shouldIgnore = false;
+
+ const fetchTopSounds = async () => {
+ try {
+ if (!shouldIgnore) {
+ setLoading({ loading: true });
+ setError({ error: null });
+ }
+
+ const response = await fetch(
+ "/api/sounds/search?page_size=50&sort=downloads",
+ );
+
+ if (!shouldIgnore) {
+ if (!response.ok) {
+ throw new Error(`Failed to fetch: ${response.status}`);
+ }
+
+ const data = await response.json();
+ setTopSoundEffects({ sounds: data.results });
+ setHasLoaded({ loaded: true });
+
+ setCurrentPage({ page: 1 });
+ setHasNextPage({ hasNext: !!data.next });
+ setTotalCount({ count: data.count });
+ }
+ } catch (error) {
+ if (!shouldIgnore) {
+ console.error("Failed to fetch top sounds:", error);
+ setError({
+ error:
+ error instanceof Error ? error.message : "Failed to load sounds",
+ });
+ }
+ } finally {
+ if (!shouldIgnore) {
+ setLoading({ loading: false });
+ }
+ }
+ };
+
+ const timeoutId = setTimeout(fetchTopSounds, 100, {});
+
+ return () => {
+ shouldIgnore = true;
+ clearTimeout(timeoutId);
+ };
+ }, [
+ hasLoaded,
+ setTopSoundEffects,
+ setLoading,
+ setError,
+ setHasLoaded,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ ]);
+
+ useEffect(() => {
+ if (!scrollAreaRef.current || scrollPosition <= 0) {
+ return;
+ }
+
+ const restoreScrollPosition = () => {
+ scrollAreaRef.current?.scrollTo({ top: scrollPosition });
+ };
+
+ const timeoutId = setTimeout(restoreScrollPosition, 100, {});
+
+ return () => clearTimeout(timeoutId);
+ }, [scrollPosition, scrollAreaRef]);
+
+ const handleScrollWithPosition = ({
+ currentTarget,
+ }: React.UIEvent<HTMLDivElement>) => {
+ const { scrollTop } = currentTarget;
+ setScrollPosition({ position: scrollTop });
+ handleScroll({ currentTarget } as React.UIEvent<HTMLDivElement>);
+ };
+
+ const displayedSounds = searchQuery ? searchResults : topSoundEffects;
+
+ const playSound = ({ sound }: { sound: SoundEffect }) => {
+ if (playingId === sound.id) {
+ audioElement?.pause();
+ setPlayingId(null);
+ return;
+ }
+
+ audioElement?.pause();
+
+ if (sound.previewUrl) {
+ const audio = new Audio(sound.previewUrl);
+ audio.addEventListener("ended", () => {
+ setPlayingId(null);
+ });
+ audio.addEventListener("error", () => {
+ setPlayingId(null);
+ });
+ audio.play().catch((error) => {
+ console.error("Failed to play sound preview:", error);
+ setPlayingId(null);
+ });
+
+ setAudioElement(audio);
+ setPlayingId(sound.id);
+ }
+ };
+
+ return (
+ <div className="mt-1 flex h-full flex-col gap-5">
+ <div className="flex items-center gap-3">
+ <Input
+ placeholder="Search sound effects"
+ className="bg-panel-accent w-full"
+ containerClassName="w-full"
+ value={searchQuery}
+ onChange={({ currentTarget }) =>
+ setSearchQuery({ query: currentTarget.value })
+ }
+ showClearIcon
+ onClear={() => setSearchQuery({ query: "" })}
+ />
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ className={cn(showCommercialOnly && "text-primary")}
+ >
+ <HugeiconsIcon icon={FilterMailIcon} />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-56">
+ <DropdownMenuCheckboxItem
+ checked={showCommercialOnly}
+ onCheckedChange={() => toggleCommercialFilter()}
+ >
+ Show only commercially licensed
+ </DropdownMenuCheckboxItem>
+ <div className="text-muted-foreground px-2 py-1.5 text-xs">
+ {showCommercialOnly
+ ? "Only showing sounds licensed for commercial use"
+ : "Showing all sounds regardless of license"}
+ </div>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+
+ <div className="relative h-full overflow-hidden">
+ <ScrollArea
+ className="h-full flex-1"
+ ref={scrollAreaRef}
+ onScrollCapture={handleScrollWithPosition}
+ >
+ <div className="flex flex-col gap-4">
+ {isLoading && !searchQuery && (
+ <div className="text-muted-foreground text-sm">
+ Loading sounds...
+ </div>
+ )}
+ {isSearching && searchQuery && (
+ <div className="text-muted-foreground text-sm">Searching...</div>
+ )}
+ {displayedSounds.map((sound) => (
+ <AudioItem
+ key={sound.id}
+ sound={sound}
+ isPlaying={playingId === sound.id}
+ onPlay={playSound}
+ />
+ ))}
+ {!isLoading && !isSearching && displayedSounds.length === 0 && (
+ <div className="text-muted-foreground text-sm">
+ {searchQuery ? "No sounds found" : "No sounds available"}
+ </div>
+ )}
+ {isLoadingMore && (
+ <div className="text-muted-foreground py-4 text-center text-sm">
+ Loading more sounds...
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+ </div>
+ );
+}
+
+function SavedSoundsView() {
+ const {
+ savedSounds,
+ isLoadingSavedSounds,
+ savedSoundsError,
+ loadSavedSounds,
+ clearSavedSounds,
+ } = useSoundsStore();
+
+ const [playingId, setPlayingId] = useState<number | null>(null);
+ const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(
+ null,
+ );
+
+ const [showClearDialog, setShowClearDialog] = useState(false);
+
+ useEffect(() => {
+ loadSavedSounds();
+ }, [loadSavedSounds]);
+
+ const playSound = ({ sound }: { sound: SoundEffect }) => {
+ if (playingId === sound.id) {
+ audioElement?.pause();
+ setPlayingId(null);
+ return;
+ }
+
+ audioElement?.pause();
+
+ if (sound.previewUrl) {
+ const audio = new Audio(sound.previewUrl);
+ audio.addEventListener("ended", () => {
+ setPlayingId(null);
+ });
+ audio.addEventListener("error", () => {
+ setPlayingId(null);
+ });
+ audio.play().catch((error) => {
+ console.error("Failed to play sound preview:", error);
+ setPlayingId(null);
+ });
+
+ setAudioElement(audio);
+ setPlayingId(sound.id);
+ }
+ };
+
+ const convertToSoundEffect = ({
+ savedSound,
+ }: {
+ savedSound: SavedSound;
+ }): SoundEffect => ({
+ id: savedSound.id,
+ name: savedSound.name,
+ description: "",
+ url: "",
+ previewUrl: savedSound.previewUrl,
+ downloadUrl: savedSound.downloadUrl,
+ duration: savedSound.duration,
+ filesize: 0,
+ type: "audio",
+ channels: 0,
+ bitrate: 0,
+ bitdepth: 0,
+ samplerate: 0,
+ username: savedSound.username,
+ tags: savedSound.tags,
+ license: savedSound.license,
+ created: savedSound.savedAt,
+ downloads: 0,
+ rating: 0,
+ ratingCount: 0,
+ });
+
+ if (isLoadingSavedSounds) {
+ return (
+ <div className="flex h-full items-center justify-center">
+ <div className="text-muted-foreground text-sm">
+ Loading saved sounds...
+ </div>
+ </div>
+ );
+ }
+
+ if (savedSoundsError) {
+ return (
+ <div className="flex h-full items-center justify-center">
+ <div className="text-destructive text-sm">
+ Error: {savedSoundsError}
+ </div>
+ </div>
+ );
+ }
+
+ if (savedSounds.length === 0) {
+ return (
+ <div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
+ <HugeiconsIcon
+ icon={FavouriteIcon}
+ className="text-muted-foreground size-10"
+ />
+ <div className="flex flex-col gap-2 text-center">
+ <p className="text-lg font-medium">No saved sounds</p>
+ <p className="text-muted-foreground text-sm text-balance">
+ Click the heart icon on any sound to save it here
+ </p>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="mt-1 flex h-full flex-col gap-5">
+ <div className="flex items-center justify-between">
+ <p className="text-muted-foreground text-sm">
+ {savedSounds.length} saved{" "}
+ {savedSounds.length === 1 ? "sound" : "sounds"}
+ </p>
+ <Dialog open={showClearDialog} onOpenChange={setShowClearDialog}>
+ <DialogTrigger asChild>
+ <Button
+ variant="text"
+ size="sm"
+ className="text-muted-foreground hover:text-destructive h-auto !opacity-100"
+ >
+ Clear all
+ </Button>
+ </DialogTrigger>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Clear all saved sounds?</DialogTitle>
+ <DialogDescription>
+ This will permanently remove all {savedSounds.length} saved
+ sounds from your collection. This action cannot be undone.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button variant="text" onClick={() => setShowClearDialog(false)}>
+ Cancel
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={async ({
+ stopPropagation,
+ }: React.MouseEvent<HTMLButtonElement>) => {
+ stopPropagation();
+ await clearSavedSounds();
+ setShowClearDialog(false);
+ }}
+ >
+ Clear all sounds
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+
+ <div className="relative h-full overflow-hidden">
+ <ScrollArea className="h-full flex-1">
+ <div className="flex flex-col gap-4">
+ {savedSounds.map((sound) => (
+ <AudioItem
+ key={sound.id}
+ sound={convertToSoundEffect({ savedSound: sound })}
+ isPlaying={playingId === sound.id}
+ onPlay={playSound}
+ />
+ ))}
+ </div>
+ </ScrollArea>
+ </div>
+ </div>
+ );
+}
+
+function SongsView() {
+ return <div>Songs</div>;
+}
+
+interface AudioItemProps {
+ sound: SoundEffect;
+ isPlaying: boolean;
+ onPlay: ({ sound }: { sound: SoundEffect }) => void;
+}
+
+function AudioItem({ sound, isPlaying, onPlay }: AudioItemProps) {
+ const { addSoundToTimeline, isSoundSaved, toggleSavedSound } =
+ useSoundsStore();
+ const isSaved = isSoundSaved({ soundId: sound.id });
+
+ const handleClick = () => {
+ onPlay({ sound });
+ };
+
+ const handleSaveClick = ({
+ stopPropagation,
+ }: React.MouseEvent<HTMLButtonElement>) => {
+ stopPropagation();
+ toggleSavedSound({ soundEffect: sound });
+ };
+
+ const handleAddToTimeline = async ({
+ stopPropagation,
+ }: React.MouseEvent<HTMLButtonElement>) => {
+ stopPropagation();
+ await addSoundToTimeline({ sound });
+ };
+
+ return (
+ <div className="group flex items-center gap-3 opacity-100 hover:opacity-75">
+ <button
+ type="button"
+ className="flex min-w-0 flex-1 items-center gap-3 text-left"
+ onClick={handleClick}
+ >
+ <div className="bg-accent relative flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-md">
+ <div className="from-primary/20 absolute inset-0 bg-gradient-to-br to-transparent" />
+ {isPlaying ? (
+ <HugeiconsIcon icon={PauseIcon} className="size-5" />
+ ) : (
+ <HugeiconsIcon icon={PlayIcon} className="size-5" />
+ )}
+ </div>
+
+ <div className="min-w-0 flex-1 overflow-hidden">
+ <p className="truncate text-sm font-medium">{sound.name}</p>
+ <span className="text-muted-foreground block truncate text-xs">
+ {sound.username}
+ </span>
+ </div>
+ </button>
+
+ <div className="flex items-center gap-3 pr-2">
+ <Button
+ variant="text"
+ size="icon"
+ className="text-muted-foreground hover:text-foreground w-auto !opacity-100"
+ onClick={handleAddToTimeline}
+ title="Add to timeline"
+ >
+ <HugeiconsIcon icon={PlusSignIcon} />
+ </Button>
+ <Button
+ variant="text"
+ size="icon"
+ className={`hover:text-foreground w-auto !opacity-100 ${
+ isSaved
+ ? "text-red-500 hover:text-red-600"
+ : "text-muted-foreground"
+ }`}
+ onClick={handleSaveClick}
+ title={isSaved ? "Remove from saved" : "Save sound"}
+ >
+ <HugeiconsIcon
+ icon={FavouriteIcon}
+ className={`${isSaved ? "fill-current" : ""}`}
+ />
+ </Button>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/stickers.tsx b/apps/web/src/components/editor/panels/assets/views/stickers.tsx
new file mode 100644
index 00000000..6b48e775
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx
@@ -0,0 +1,615 @@
+"use client";
+
+import Image from "next/image";
+import type { CSSProperties } from "react";
+import { useEffect, useMemo, useState } from "react";
+import { toast } from "sonner";
+import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
+import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
+import { Button } from "@/components/ui/button";
+import { InputWithBack } from "@/components/ui/input-with-back";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { STICKER_CATEGORIES } from "@/constants/stickers-constants";
+import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
+import {
+ buildIconSvgUrl,
+ getIconSvgUrl,
+ ICONIFY_HOSTS,
+ POPULAR_COLLECTIONS,
+} from "@/lib/iconify-api";
+import { useStickersStore } from "@/stores/stickers-store";
+import type { StickerCategory } from "@/types/stickers";
+import { cn } from "@/utils/ui";
+import {
+ ArrowRightIcon,
+ HappyIcon,
+ ClockIcon,
+ LayoutGridIcon,
+ MultiplicationSignIcon,
+ SparklesIcon,
+ HashtagIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { Spinner } from "@/components/ui/spinner";
+
+function isStickerCategory(value: string): value is StickerCategory {
+ return STICKER_CATEGORIES.includes(value as StickerCategory);
+}
+
+export function StickersView() {
+ const { selectedCategory, setSelectedCategory } = useStickersStore();
+
+ return (
+ <BaseView
+ value={selectedCategory}
+ onValueChange={(v) => {
+ if (isStickerCategory(v)) {
+ setSelectedCategory({ category: v });
+ }
+ }}
+ tabs={[
+ {
+ value: "all",
+ label: "All",
+ icon: <HugeiconsIcon icon={LayoutGridIcon} className="size-3" />,
+ content: <StickersContentView category="all" />,
+ },
+ {
+ value: "general",
+ label: "Icons",
+ icon: <HugeiconsIcon icon={SparklesIcon} className="size-3" />,
+ content: <StickersContentView category="general" />,
+ },
+ {
+ value: "brands",
+ label: "Brands",
+ icon: <HugeiconsIcon icon={HashtagIcon} className="size-3" />,
+ content: <StickersContentView category="brands" />,
+ },
+ {
+ value: "emoji",
+ label: "Emoji",
+ icon: <HugeiconsIcon icon={HappyIcon} className="size-3" />,
+ content: <StickersContentView category="emoji" />,
+ },
+ ]}
+ className="flex h-full flex-col overflow-hidden p-0"
+ />
+ );
+}
+
+function StickerGrid({
+ icons,
+ onAdd,
+ addingSticker,
+ capSize = false,
+}: {
+ icons: string[];
+ onAdd: (iconName: string) => void;
+ addingSticker: string | null;
+ capSize?: boolean;
+}) {
+ const gridStyle: CSSProperties & {
+ "--sticker-min": string;
+ "--sticker-max"?: string;
+ } = {
+ gridTemplateColumns: capSize
+ ? "repeat(auto-fill, minmax(var(--sticker-min, 96px), var(--sticker-max, 160px)))"
+ : "repeat(auto-fit, minmax(var(--sticker-min, 96px), 1fr))",
+ "--sticker-min": "96px",
+ ...(capSize ? { "--sticker-max": "160px" } : {}),
+ };
+
+ return (
+ <div className="grid gap-2" style={gridStyle}>
+ {icons.map((iconName) => (
+ <StickerItem
+ key={iconName}
+ iconName={iconName}
+ onAdd={onAdd}
+ isAdding={addingSticker === iconName}
+ capSize={capSize}
+ />
+ ))}
+ </div>
+ );
+}
+
+function CollectionGrid({
+ collections,
+ onSelectCollection,
+}: {
+ collections: Array<{
+ prefix: string;
+ name: string;
+ total: number;
+ category?: string;
+ }>;
+ onSelectCollection: ({ prefix }: { prefix: string }) => void;
+}) {
+ return (
+ <div className="grid grid-cols-1 gap-2">
+ {collections.map((collection) => (
+ <CollectionItem
+ key={collection.prefix}
+ title={collection.name}
+ subtitle={`${collection.total.toLocaleString()} icons${collection.category ? ` • ${collection.category}` : ""}`}
+ onClick={() => onSelectCollection({ prefix: collection.prefix })}
+ />
+ ))}
+ </div>
+ );
+}
+
+function EmptyView({ message }: { message: string }) {
+ return (
+ <div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
+ <HugeiconsIcon
+ icon={HappyIcon}
+ className="text-muted-foreground size-10"
+ />
+ <div className="flex flex-col gap-2 text-center">
+ <p className="text-lg font-medium">No stickers found</p>
+ <p className="text-muted-foreground text-sm text-balance">{message}</p>
+ </div>
+ </div>
+ );
+}
+
+function StickersContentView({ category }: { category: StickerCategory }) {
+ const {
+ searchQuery,
+ selectedCollection,
+ viewMode,
+ collections,
+ currentCollection,
+ searchResults,
+ recentStickers,
+ isLoadingCollections,
+ isLoadingCollection,
+ isSearching,
+ setSearchQuery,
+ setSelectedCollection,
+ loadCollections,
+ searchStickers,
+ addStickerToTimeline,
+ clearRecentStickers,
+ setSelectedCategory,
+ addingSticker,
+ } = useStickersStore();
+
+ const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
+ const [collectionsToShow, setCollectionsToShow] = useState(20);
+ const [showCollectionItems, setShowCollectionItems] = useState(false);
+
+ const filteredCollections = useMemo(() => {
+ if (category === "all") {
+ return Object.entries(collections).map(([prefix, collection]) => ({
+ prefix,
+ name: collection.name,
+ total: collection.total,
+ category: collection.category,
+ }));
+ }
+
+ const collectionList =
+ POPULAR_COLLECTIONS[category as keyof typeof POPULAR_COLLECTIONS];
+ if (!collectionList) return [];
+
+ return collectionList
+ .map((c) => {
+ const collection = collections[c.prefix];
+ return collection
+ ? {
+ prefix: c.prefix,
+ name: c.name,
+ total: collection.total,
+ }
+ : null;
+ })
+ .filter(Boolean) as Array<{
+ prefix: string;
+ name: string;
+ total: number;
+ }>;
+ }, [collections, category]);
+
+ const { scrollAreaRef, handleScroll } = useInfiniteScroll({
+ onLoadMore: () => setCollectionsToShow((prev) => prev + 20),
+ hasMore: filteredCollections.length > collectionsToShow,
+ isLoading: isLoadingCollections,
+ enabled: viewMode === "browse" && !selectedCollection && category === "all",
+ });
+
+ useEffect(() => {
+ if (Object.keys(collections).length === 0) {
+ loadCollections();
+ }
+ }, [collections, loadCollections]);
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ if (localSearchQuery !== searchQuery) {
+ setSearchQuery({ query: localSearchQuery });
+ if (localSearchQuery.trim()) {
+ searchStickers({ query: localSearchQuery });
+ }
+ }
+ }, 500);
+
+ return () => clearTimeout(timer);
+ }, [localSearchQuery, searchQuery, searchStickers, setSearchQuery]);
+
+ const handleAddSticker = async (iconName: string) => {
+ try {
+ await addStickerToTimeline({ iconName });
+ } catch (error) {
+ console.error("Failed to add sticker:", error);
+ toast.error("Failed to add sticker to timeline");
+ }
+ };
+
+ const iconsToDisplay = useMemo(() => {
+ if (viewMode === "search" && searchResults) {
+ return searchResults.icons;
+ }
+
+ if (viewMode === "collection" && currentCollection) {
+ const icons: string[] = [];
+
+ if (currentCollection.uncategorized) {
+ icons.push(
+ ...currentCollection.uncategorized.map(
+ (name) => `${currentCollection.prefix}:${name}`,
+ ),
+ );
+ }
+
+ if (currentCollection.categories) {
+ Object.values(currentCollection.categories).forEach((categoryIcons) => {
+ icons.push(
+ ...categoryIcons.map(
+ (name) => `${currentCollection.prefix}:${name}`,
+ ),
+ );
+ });
+ }
+
+ return icons.slice(0, 100);
+ }
+
+ return [];
+ }, [viewMode, searchResults, currentCollection]);
+
+ const isInCollection = viewMode === "collection" && !!selectedCollection;
+
+ useEffect(() => {
+ if (isInCollection) {
+ setShowCollectionItems(false);
+ const timer = setTimeout(() => setShowCollectionItems(true), 350);
+ return () => clearTimeout(timer);
+ } else {
+ setShowCollectionItems(false);
+ }
+ }, [isInCollection]);
+
+ return (
+ <div className="mt-1 flex h-full flex-col gap-5 p-4">
+ <div className="space-y-3">
+ <InputWithBack
+ isExpanded={isInCollection}
+ setIsExpanded={(expanded) => {
+ if (!expanded && isInCollection) {
+ setSelectedCollection({ collection: null });
+ }
+ }}
+ placeholder={
+ category === "all"
+ ? "Search all stickers"
+ : category === "general"
+ ? "Search icons"
+ : category === "brands"
+ ? "Search brands"
+ : "Search Emojis"
+ }
+ value={localSearchQuery}
+ onChange={setLocalSearchQuery}
+ disableAnimation={true}
+ />
+ </div>
+
+ <div className="relative h-full overflow-hidden">
+ <ScrollArea
+ className="h-full flex-1"
+ ref={scrollAreaRef}
+ onScrollCapture={handleScroll}
+ >
+ <div className="flex h-full flex-col gap-4">
+ {recentStickers.length > 0 && viewMode === "browse" && (
+ <div className="h-full">
+ <div className="mb-2 flex items-center gap-2">
+ <HugeiconsIcon
+ icon={ClockIcon}
+ className="text-muted-foreground size-4"
+ />
+ <span className="text-sm font-medium">Recent</span>
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <button
+ type="button"
+ onClick={clearRecentStickers}
+ className="hover:bg-accent ml-auto flex size-5 items-center justify-center rounded p-0"
+ >
+ <HugeiconsIcon
+ icon={MultiplicationSignIcon}
+ className="text-muted-foreground size-3"
+ />
+ </button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>Clear recent stickers</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ <StickerGrid
+ icons={recentStickers.slice(0, 12)}
+ onAdd={handleAddSticker}
+ addingSticker={addingSticker}
+ capSize
+ />
+ </div>
+ )}
+
+ {viewMode === "collection" && selectedCollection && (
+ <div className="h-full">
+ {isLoadingCollection ? (
+ <div className="flex items-center justify-center py-8">
+ <Spinner className="text-muted-foreground size-6" />
+ </div>
+ ) : showCollectionItems ? (
+ <StickerGrid
+ icons={iconsToDisplay}
+ onAdd={handleAddSticker}
+ addingSticker={addingSticker}
+ />
+ ) : (
+ <div className="flex items-center justify-center py-8">
+ <Spinner className="text-muted-foreground size-6" />
+ </div>
+ )}
+ </div>
+ )}
+
+ {viewMode === "search" && (
+ <div className="h-full">
+ {isSearching ? (
+ <div className="flex items-center justify-center py-8">
+ <Spinner className="text-muted-foreground size-6" />
+ </div>
+ ) : searchResults?.icons.length ? (
+ <>
+ <div className="mb-3 flex items-center justify-between">
+ <span className="text-muted-foreground text-sm">
+ {searchResults.total} results
+ </span>
+ </div>
+ <StickerGrid
+ icons={iconsToDisplay}
+ onAdd={handleAddSticker}
+ addingSticker={addingSticker}
+ capSize
+ />
+ </>
+ ) : searchQuery ? (
+ <div className="flex flex-col items-center justify-center gap-3 py-8">
+ <EmptyView
+ message={`No stickers found for "${searchQuery}"`}
+ />
+ {category !== "all" && (
+ <Button
+ variant="outline"
+ onClick={() => {
+ const q = localSearchQuery || searchQuery;
+ if (q) {
+ setSearchQuery({ query: q });
+ }
+ setSelectedCategory({ category: "all" });
+ if (q) {
+ searchStickers({ query: q });
+ }
+ }}
+ >
+ Search in all icons
+ </Button>
+ )}
+ </div>
+ ) : null}
+ </div>
+ )}
+
+ {viewMode === "browse" && !selectedCollection && (
+ <div className="h-full space-y-4">
+ {isLoadingCollections ? (
+ <div className="flex items-center justify-center py-8">
+ <Spinner className="text-muted-foreground size-6" />
+ </div>
+ ) : (
+ <>
+ {category !== "all" && (
+ <div className="h-full">
+ <CollectionGrid
+ collections={filteredCollections}
+ onSelectCollection={({ prefix }) =>
+ setSelectedCollection({ collection: prefix })
+ }
+ />
+ </div>
+ )}
+
+ {category === "all" && filteredCollections.length > 0 && (
+ <div className="h-full">
+ <CollectionGrid
+ collections={filteredCollections.slice(
+ 0,
+ collectionsToShow,
+ )}
+ onSelectCollection={({ prefix }) =>
+ setSelectedCollection({ collection: prefix })
+ }
+ />
+ </div>
+ )}
+ </>
+ )}
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+ </div>
+ );
+}
+
+interface CollectionItemProps {
+ title: string;
+ subtitle: string;
+ onClick: () => void;
+}
+
+function CollectionItem({ title, subtitle, onClick }: CollectionItemProps) {
+ return (
+ <Button
+ variant="outline"
+ className="h-auto justify-between rounded-md py-2"
+ onClick={onClick}
+ >
+ <div className="text-left">
+ <p className="font-medium">{title}</p>
+ <p className="text-muted-foreground text-xs">{subtitle}</p>
+ </div>
+ <HugeiconsIcon icon={ArrowRightIcon} className="size-4" />
+ </Button>
+ );
+}
+
+interface StickerItemProps {
+ iconName: string;
+ onAdd: (iconName: string) => void;
+ isAdding?: boolean;
+ capSize?: boolean;
+}
+
+function StickerItem({
+ iconName,
+ onAdd,
+ isAdding,
+ capSize = false,
+}: StickerItemProps) {
+ const [imageError, setImageError] = useState(false);
+ const [hostIndex, setHostIndex] = useState(0);
+
+ useEffect(() => {
+ if (!iconName) {
+ return;
+ }
+ setImageError(false);
+ setHostIndex(0);
+ }, [iconName]);
+
+ const displayName = iconName.split(":")[1] || iconName;
+ const collectionPrefix = iconName.split(":")[0];
+
+ const preview = imageError ? (
+ <div className="flex size-full items-center justify-center p-2">
+ <span className="text-muted-foreground text-center text-xs break-all">
+ {displayName}
+ </span>
+ </div>
+ ) : (
+ <div className="flex size-full items-center justify-center p-4">
+ <Image
+ src={
+ hostIndex === 0
+ ? getIconSvgUrl(iconName, { width: 64, height: 64 })
+ : buildIconSvgUrl(
+ ICONIFY_HOSTS[Math.min(hostIndex, ICONIFY_HOSTS.length - 1)],
+ iconName,
+ { width: 64, height: 64 },
+ )
+ }
+ alt={displayName}
+ width={64}
+ height={64}
+ className="size-full object-contain"
+ style={
+ capSize
+ ? {
+ maxWidth: "var(--sticker-max, 160px)",
+ maxHeight: "var(--sticker-max, 160px)",
+ }
+ : undefined
+ }
+ onError={() => {
+ const next = hostIndex + 1;
+ if (next < ICONIFY_HOSTS.length) {
+ setHostIndex(next);
+ } else {
+ setImageError(true);
+ }
+ }}
+ loading="lazy"
+ unoptimized
+ />
+ </div>
+ );
+
+ return (
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <div
+ className={cn(
+ "relative",
+ isAdding && "pointer-events-none opacity-50",
+ )}
+ >
+ <DraggableItem
+ name={displayName}
+ preview={preview}
+ dragData={{
+ id: iconName,
+ type: "sticker",
+ name: displayName,
+ iconName,
+ }}
+ onAddToTimeline={() => onAdd(iconName)}
+ aspectRatio={1}
+ shouldShowLabel={false}
+ isRounded={true}
+ variant="card"
+ className=""
+ containerClassName="w-full"
+ />
+ {isAdding && (
+ <div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60">
+ <Spinner className="size-6 text-white" />
+ </div>
+ )}
+ </div>
+ </TooltipTrigger>
+ <TooltipContent>
+ <div className="space-y-1">
+ <p className="font-medium">{displayName}</p>
+ <p className="text-muted-foreground text-xs">{collectionPrefix}</p>
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/text.tsx b/apps/web/src/components/editor/panels/assets/views/text.tsx
new file mode 100644
index 00000000..6ccf5d98
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/text.tsx
@@ -0,0 +1,46 @@
+import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
+import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
+import { useEditor } from "@/hooks/use-editor";
+import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
+import { buildTextElement } from "@/lib/timeline/element-utils";
+
+export function TextView() {
+ const editor = useEditor();
+
+ const handleAddToTimeline = ({ currentTime }: { currentTime: number }) => {
+ const activeScene = editor.scenes.getActiveScene();
+ if (!activeScene) return;
+
+ const element = buildTextElement({
+ raw: DEFAULT_TEXT_ELEMENT,
+ startTime: currentTime,
+ });
+
+ editor.timeline.insertElement({
+ element,
+ placement: { mode: "auto" },
+ });
+ };
+
+ return (
+ <BaseView>
+ <DraggableItem
+ name="Default text"
+ preview={
+ <div className="bg-panel-accent flex size-full items-center justify-center rounded">
+ <span className="text-xs select-none">Default text</span>
+ </div>
+ }
+ dragData={{
+ id: "temp-text-id",
+ type: DEFAULT_TEXT_ELEMENT.type,
+ name: DEFAULT_TEXT_ELEMENT.name,
+ content: DEFAULT_TEXT_ELEMENT.content,
+ }}
+ aspectRatio={1}
+ onAddToTimeline={handleAddToTimeline}
+ shouldShowLabel={false}
+ />
+ </BaseView>
+ );
+}