summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/assets
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components/editor/panels/assets')
-rw-r--r--apps/web/src/components/editor/panels/assets/drag-overlay.tsx2
-rw-r--r--apps/web/src/components/editor/panels/assets/draggable-item.tsx17
-rw-r--r--apps/web/src/components/editor/panels/assets/tabbar.tsx15
-rw-r--r--apps/web/src/components/editor/panels/assets/views/assets.tsx153
-rw-r--r--apps/web/src/components/editor/panels/assets/views/base-panel.tsx (renamed from apps/web/src/components/editor/panels/assets/views/base-view.tsx)11
-rw-r--r--apps/web/src/components/editor/panels/assets/views/captions.tsx141
-rw-r--r--apps/web/src/components/editor/panels/assets/views/effects.tsx15
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx332
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings.tsx168
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings/background.tsx227
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings/index.tsx369
-rw-r--r--apps/web/src/components/editor/panels/assets/views/sounds.tsx2
-rw-r--r--apps/web/src/components/editor/panels/assets/views/stickers.tsx538
-rw-r--r--apps/web/src/components/editor/panels/assets/views/text.tsx12
14 files changed, 1143 insertions, 859 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 c6b0861c..9d9ae903 100644
--- a/apps/web/src/components/editor/panels/assets/drag-overlay.tsx
+++ b/apps/web/src/components/editor/panels/assets/drag-overlay.tsx
@@ -29,7 +29,7 @@ export function MediaDragOverlay({
return (
<button
- className="bg-foreground/5 hover:bg-foreground/10 flex size-full flex-col items-center justify-center gap-4 rounded-lg p-8 text-center"
+ 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 })}
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 fae74e84..8c9acce8 100644
--- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx
+++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx
@@ -12,7 +12,7 @@ import {
} from "@/components/ui/tooltip";
import { useEditor } from "@/hooks/use-editor";
import { clearDragData, setDragData } from "@/lib/drag-data";
-import type { TimelineDragData } from "@/types/drag";
+import type { TimelineDragData } from "@/lib/timeline/drag";
import { cn } from "@/utils/ui";
export interface DraggableItemProps {
@@ -29,7 +29,6 @@ export interface DraggableItemProps {
isRounded?: boolean;
variant?: "card" | "compact";
isDraggable?: boolean;
- isHighlighted?: boolean;
}
export function DraggableItem({
@@ -46,13 +45,11 @@ export function DraggableItem({
isRounded = true,
variant = "card",
isDraggable = true,
- isHighlighted = false,
}: DraggableItemProps) {
const [isDragging, setIsDragging] = useState(false);
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
const dragRef = useRef<HTMLDivElement>(null);
const editor = useEditor();
- const highlightClassName = `ring-2 ring-primary bg-primary/10 ${isRounded ? "rounded-sm" : ""}`;
const handleAddToTimeline = () => {
onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() });
@@ -102,9 +99,8 @@ export function DraggableItem({
>
<div
className={cn(
- "relative flex h-auto w-full cursor-default flex-col gap-1 p-1",
+ "relative flex h-auto w-full cursor-default flex-col gap-1 p-",
className,
- isHighlighted && highlightClassName,
)}
>
<AspectRatio
@@ -144,15 +140,12 @@ export function DraggableItem({
) : (
<div
ref={dragRef}
- className={cn(
- "group relative w-full",
- isHighlighted && highlightClassName,
- )}
+ 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",
+ "flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none",
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
className,
)}
@@ -160,7 +153,7 @@ export function DraggableItem({
onDragStart={isDraggable ? handleDragStart : undefined}
onDragEnd={isDraggable ? handleDragEnd : undefined}
>
- <div className="size-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]">
+ <div className="size-6 shrink-0 overflow-hidden rounded-sm">
{preview}
</div>
<span className="w-full flex-1 truncate text-sm text-left">
diff --git a/apps/web/src/components/editor/panels/assets/tabbar.tsx b/apps/web/src/components/editor/panels/assets/tabbar.tsx
index 61777a7c..a4e072f1 100644
--- a/apps/web/src/components/editor/panels/assets/tabbar.tsx
+++ b/apps/web/src/components/editor/panels/assets/tabbar.tsx
@@ -49,7 +49,7 @@ export function TabBar() {
<div className="relative flex">
<div
ref={scrollRef}
- className="scrollbar-hidden relative flex size-full p-2 flex-col items-center justify-start gap-1.5 overflow-y-auto"
+ 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];
@@ -57,12 +57,13 @@ export function TabBar() {
<Tooltip key={tabKey} delayDuration={10}>
<TooltipTrigger asChild>
<Button
- variant={activeTab === tabKey ? "secondary" : "text"}
+ variant={activeTab === tabKey ? "secondary" : "ghost"}
+ size="icon"
aria-label={tab.label}
className={cn(
- "flex-col !p-1.5 !rounded-sm !h-auto [&_svg]:size-4.5",
- activeTab !== tabKey &&
- "border border-transparent text-muted-foreground",
+ "shrink-0",
+ "h-8 w-8",
+ activeTab !== tabKey && "text-muted-foreground",
)}
onClick={() => setActiveTab(tabKey)}
>
@@ -102,8 +103,8 @@ function FadeOverlay({
className={cn(
"pointer-events-none absolute right-0 left-0 h-6",
direction === "top" && show
- ? "from-background top-0 bg-gradient-to-b to-transparent"
- : "from-background bottom-0 bg-gradient-to-t to-transparent",
+ ? "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/assets.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx
index 3b35c022..e17e4d4a 100644
--- a/apps/web/src/components/editor/panels/assets/views/assets.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx
@@ -2,11 +2,11 @@
import Image from "next/image";
import { useMemo, useState } from "react";
-import { toast } from "sonner";
-import { PanelView } from "@/components/editor/panels/assets/views/base-view";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
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 { toast } from "sonner";
import {
ContextMenu,
ContextMenuContent,
@@ -28,8 +28,15 @@ import {
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 { invokeAction } from "@/lib/actions";
import { processMediaAssets } from "@/lib/media/processing";
+import { showMediaUploadToast } from "@/lib/media/upload-toast";
+import {
+ SelectableItem,
+ SelectableSurface,
+ useSelection,
+ useSelectionScope,
+} from "@/lib/selection";
import { buildElementFromMedia } from "@/lib/timeline/element-utils";
import {
type MediaSortKey,
@@ -37,7 +44,8 @@ import {
type MediaViewMode,
useAssetsPanelStore,
} from "@/stores/assets-panel-store";
-import type { MediaAsset } from "@/types/assets";
+import { MASKABLE_ELEMENT_TYPES } from "@/lib/timeline";
+import type { MediaAsset } from "@/lib/media/types";
import { cn } from "@/utils/ui";
import {
CloudUploadIcon,
@@ -52,8 +60,8 @@ import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
export function MediaView() {
const editor = useEditor();
- const mediaFiles = editor.media.getAssets();
- const activeProject = editor.project.getActive();
+ const mediaFiles = useEditor((e) => e.media.getAssets());
+ const activeProject = useEditor((e) => e.project.getActive());
const {
mediaViewMode,
@@ -64,15 +72,11 @@ export function MediaView() {
mediaSortOrder,
setMediaSort,
} = useAssetsPanelStore();
- const { highlightedId, registerElement } = useRevealItem(
- highlightMediaId,
- clearHighlight,
- );
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
- const processFiles = async ({ files }: { files: FileList }) => {
+ const processFiles = async ({ files }: { files: File[] }) => {
if (!files || files.length === 0) return;
if (!activeProject) {
toast.error("No active project");
@@ -82,20 +86,28 @@ export function MediaView() {
setIsProcessing(true);
setProgress(0);
try {
- const processedAssets = await processMediaAssets({
- files,
- onProgress: (progress: { progress: number }) =>
- setProgress(progress.progress),
+ await showMediaUploadToast({
+ filesCount: files.length,
+ promise: async () => {
+ 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,
+ });
+ }
+ return {
+ uploadedCount: processedAssets.length,
+ assetNames: processedAssets.map((asset) => asset.name),
+ };
+ },
});
- 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);
@@ -109,23 +121,18 @@ export function MediaView() {
onFilesSelected: (files) => processFiles({ files }),
});
- const handleRemove = async ({
+ const handleRemove = ({
event,
- id,
+ ids,
}: {
event: React.MouseEvent;
- id: string;
+ ids: string[];
}) => {
event.stopPropagation();
- if (!activeProject) {
- toast.error("No active project");
- return;
- }
-
- await editor.media.removeMediaAsset({
+ invokeAction("remove-media-assets", {
projectId: activeProject.metadata.id,
- id,
+ assetIds: ids,
});
};
@@ -172,6 +179,9 @@ export function MediaView() {
return filtered;
}, [mediaFiles, mediaSortBy, mediaSortOrder]);
+ const orderedMediaIds = useMemo(() => {
+ return filteredMediaItems.map((item) => item.id);
+ }, [filteredMediaItems]);
return (
<>
@@ -191,6 +201,7 @@ export function MediaView() {
/>
}
className={cn(isDragOver && "bg-accent/30")}
+ contentClassName="h-full"
{...dragProps}
>
{isDragOver || filteredMediaItems.length === 0 ? (
@@ -201,29 +212,38 @@ export function MediaView() {
onClick={openFilePicker}
/>
) : (
- <MediaItemList
- items={filteredMediaItems}
- mode={mediaViewMode}
- onRemove={handleRemove}
- highlightedId={highlightedId}
- registerElement={registerElement}
- />
+ <SelectableSurface
+ ariaLabel="Assets"
+ orderedIds={orderedMediaIds}
+ revealId={highlightMediaId}
+ onRevealComplete={clearHighlight}
+ >
+ <MediaScopeRegistrar />
+ <MediaItemList
+ items={filteredMediaItems}
+ mode={mediaViewMode}
+ onRemove={handleRemove}
+ />
+ </SelectableSurface>
)}
</PanelView>
</>
);
}
+function MediaScopeRegistrar() {
+ useSelectionScope();
+ return null;
+}
+
function MediaAssetDraggable({
item,
preview,
- isHighlighted,
variant,
isRounded,
}: {
item: MediaAsset;
preview: React.ReactNode;
- isHighlighted: boolean;
variant: "card" | "compact";
isRounded?: boolean;
}) {
@@ -261,7 +281,7 @@ function MediaAssetDraggable({
mediaType: item.type,
name: item.name,
...(item.type !== "audio" && {
- targetElementTypes: ["video", "image"] as const,
+ targetElementTypes: [...MASKABLE_ELEMENT_TYPES],
}),
}}
shouldShowPlusOnDrag={false}
@@ -270,7 +290,6 @@ function MediaAssetDraggable({
}
variant={variant}
isRounded={isRounded}
- isHighlighted={isHighlighted}
/>
);
}
@@ -282,18 +301,31 @@ function MediaItemWithContextMenu({
}: {
item: MediaAsset;
children: React.ReactNode;
- onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
+ onRemove: ({
+ event,
+ ids,
+ }: {
+ event: React.MouseEvent;
+ ids: string[];
+ }) => void;
}) {
+ const { isSelected, selectedIds } = useSelection();
+ const idsToDelete = isSelected(item.id) ? selectedIds : [item.id];
+ const deleteLabel =
+ idsToDelete.length > 1 ? `Delete ${idsToDelete.length} items` : "Delete";
+
return (
<ContextMenu>
- <ContextMenuTrigger>{children}</ContextMenuTrigger>
+ <ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>Export clips</ContextMenuItem>
<ContextMenuItem
variant="destructive"
- onClick={(event) => onRemove({ event, id: item.id })}
+ onClick={(event: React.MouseEvent<HTMLDivElement>) =>
+ onRemove({ event, ids: idsToDelete })
+ }
>
- Delete
+ {deleteLabel}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
@@ -304,27 +336,29 @@ function MediaItemList({
items,
mode,
onRemove,
- highlightedId,
- registerElement,
}: {
items: MediaAsset[];
mode: MediaViewMode;
- onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
- highlightedId: string | null;
- registerElement: (id: string, element: HTMLElement | null) => void;
+ onRemove: ({
+ event,
+ ids,
+ }: {
+ event: React.MouseEvent;
+ ids: string[];
+ }) => void;
}) {
const isGrid = mode === "grid";
return (
<div
- className={cn(isGrid ? "grid gap-4" : "flex flex-col gap-1")}
+ className={cn(isGrid ? "grid gap-4" : "flex flex-col gap-1.5")}
style={
isGrid ? { gridTemplateColumns: "repeat(auto-fill, 7rem)" } : undefined
}
>
{items.map((item) => (
- <div key={item.id} ref={(element) => registerElement(item.id, element)}>
- <MediaItemWithContextMenu item={item} onRemove={onRemove}>
+ <MediaItemWithContextMenu item={item} onRemove={onRemove} key={item.id}>
+ <SelectableItem className={cn(!isGrid && "w-full")} id={item.id}>
<MediaAssetDraggable
item={item}
preview={
@@ -335,16 +369,15 @@ function MediaItemList({
}
variant={isGrid ? "card" : "compact"}
isRounded={isGrid ? false : undefined}
- isHighlighted={highlightedId === item.id}
/>
- </MediaItemWithContextMenu>
- </div>
+ </SelectableItem>
+ </MediaItemWithContextMenu>
))}
</div>
);
}
-export function formatDuration({ duration }: { duration: number }) {
+function formatDuration({ duration }: { duration: number }) {
const min = Math.floor(duration / 60);
const sec = Math.floor(duration % 60);
return `${min}:${sec.toString().padStart(2, "0")}`;
@@ -406,7 +439,7 @@ function MediaPreview({
if (item.type === "image") {
return (
- <div className="relative flex size-full items-center justify-center">
+ <div className="relative flex size-full items-center justify-center bg-muted">
<Image
src={item.url ?? ""}
alt={item.name}
diff --git a/apps/web/src/components/editor/panels/assets/views/base-view.tsx b/apps/web/src/components/editor/panels/assets/views/base-panel.tsx
index c613bc2a..0ece9b19 100644
--- a/apps/web/src/components/editor/panels/assets/views/base-view.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/base-panel.tsx
@@ -5,6 +5,7 @@ interface PanelViewProps extends React.HTMLAttributes<HTMLDivElement> {
actions?: React.ReactNode;
children: React.ReactNode;
contentClassName?: string;
+ scrollClassName?: string;
hideHeader?: boolean;
ref?: React.Ref<HTMLDivElement>;
onScroll?: React.UIEventHandler<HTMLDivElement>;
@@ -17,6 +18,7 @@ export function PanelView({
children,
className,
contentClassName,
+ scrollClassName,
hideHeader = false,
ref,
onScroll,
@@ -30,15 +32,18 @@ export function PanelView({
{...rest}
>
{!hideHeader && (
- <div className="bg-background h-11 shrink-0 px-4 pr-2 flex items-center justify-between border-b">
- <span className="text-muted-foreground text-sm">{title}</span>
+ <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-thin size-full overflow-y-auto",
+ "scrollbar-hidden size-full overflow-y-auto",
hideHeader ? "pt-4" : "pt-2",
+ scrollClassName,
)}
ref={scrollRef}
onScroll={onScroll}
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 a73fd693..023e1578 100644
--- a/apps/web/src/components/editor/panels/assets/views/captions.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/captions.tsx
@@ -1,5 +1,5 @@
import { Button } from "@/components/ui/button";
-import { PanelView } from "@/components/editor/panels/assets/views/base-view";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
import {
Select,
SelectContent,
@@ -10,7 +10,6 @@ import {
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 {
DEFAULT_TRANSCRIPTION_SAMPLE_RATE,
TRANSCRIPTION_LANGUAGES,
@@ -18,12 +17,23 @@ import {
import type {
TranscriptionLanguage,
TranscriptionProgress,
-} from "@/types/transcription";
+} 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 { Label } from "@/components/ui/label";
+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] =
@@ -69,26 +79,29 @@ export function Captions() {
setProcessingStep("Generating captions...");
const captionChunks = buildCaptionChunks({ segments: result.segments });
- const captionTrackId = editor.timeline.addTrack({
- type: "text",
- index: 0,
- });
+ 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",
+ },
+ }),
+ );
- 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",
- },
- });
- }
+ editor.command.execute({
+ command: new BatchCommand([addTrackCommand, ...insertCommands]),
+ });
} catch (error) {
console.error("Transcription failed:", error);
setError(
@@ -114,43 +127,53 @@ export function Captions() {
};
return (
- <PanelView title="Captions" ref={containerRef}>
- <div className="flex flex-col gap-3">
- <Label>Language</Label>
- <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>
- </div>
-
- <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>
- )}
+ <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>
- <Button
- className="w-full"
- onClick={handleGenerateTranscript}
- disabled={isProcessing}
- >
- {isProcessing && <Spinner className="mr-1" />}
- {isProcessing ? processingStep : "Generate transcript"}
- </Button>
- </div>
+ {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/effects.tsx b/apps/web/src/components/editor/panels/assets/views/effects.tsx
index c9b8b2dd..74128df6 100644
--- a/apps/web/src/components/editor/panels/assets/views/effects.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/effects.tsx
@@ -1,19 +1,16 @@
"use client";
import { useEffect, useRef, useCallback } from "react";
-import { PanelView } from "@/components/editor/panels/assets/views/base-view";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
-import { getAllEffects, EFFECT_TARGET_ELEMENT_TYPES } from "@/lib/effects";
-import {
- effectPreviewService,
- onPreviewImageReady,
-} from "@/services/renderer/effect-preview";
+import { effectsRegistry, EFFECT_TARGET_ELEMENT_TYPES } from "@/lib/effects";
+import { effectPreviewService } from "@/services/renderer/effect-preview";
import { useEditor } from "@/hooks/use-editor";
import { buildEffectElement } from "@/lib/timeline/element-utils";
-import type { EffectDefinition } from "@/types/effects";
+import type { EffectDefinition } from "@/lib/effects/types";
export function EffectsView() {
- const effects = getAllEffects();
+ const effects = effectsRegistry.getAll();
return (
<PanelView title="Effects">
@@ -50,7 +47,7 @@ function EffectPreviewCanvas({ effectType }: { effectType: string }) {
};
render();
- return onPreviewImageReady({ callback: render });
+ return effectPreviewService.onPreviewImageReady({ callback: render });
}, [effectType]);
return <canvas ref={canvasRef} className="size-full" />;
diff --git a/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx b/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx
deleted file mode 100644
index 457c08a9..00000000
--- a/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx
+++ /dev/null
@@ -1,332 +0,0 @@
-// @ts-nocheck
-
-"use client";
-
-import Image from "next/image";
-import { memo, useCallback, useMemo } from "react";
-import { PanelView } from "@/components/editor/panels/assets/views/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 { dimensionToAspectRatio } from "@/utils/geometry";
-import { cn } from "@/utils/ui";
-import {
- PropertyGroup,
- PropertyItem,
- PropertyItemLabel,
- PropertyItemValue,
-} from "@/components/editor/panels/properties/section";
-
-export function SettingsView() {
- return (
- <PanelView title="Project settings" contentClassName="px-0">
- <div className="flex flex-col gap-8">
- <ProjectInfoView />
- <BackgroundView />
- </div>
- </PanelView>
- );
-}
-
-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 = activeProject.settings.canvasSize;
- 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>
- <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>
- <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 size-20 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 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={() => 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],
- );
-
- const backgroundSections = [
- { title: "Colors", backgrounds: colors, useBackgroundColor: true },
- { title: "Pattern craft", backgrounds: patternCraftGradients },
- { title: "Syntax UI", backgrounds: syntaxUIGradients },
- ];
-
- return (
- <div className="flex flex-col">
- <PropertyGroup title="Blur" hasBorderTop={false} defaultExpanded={false}>
- <div className="flex flex-wrap gap-2">{blurPreviews}</div>
- </PropertyGroup>
-
- {backgroundSections.map((section) => (
- <PropertyGroup
- key={section.title}
- title={section.title}
- defaultExpanded={false}
- >
- <div className="flex flex-wrap gap-2">
- <BackgroundPreviews
- backgrounds={section.backgrounds}
- currentBackgroundColor={currentBackgroundColor}
- isColorBackground={isColorBackground}
- handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
- useBackgroundColor={section.useBackgroundColor}
- />
- </div>
- </PropertyGroup>
- ))}
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/assets/views/settings.tsx b/apps/web/src/components/editor/panels/assets/views/settings.tsx
deleted file mode 100644
index d46e9d1f..00000000
--- a/apps/web/src/components/editor/panels/assets/views/settings.tsx
+++ /dev/null
@@ -1,168 +0,0 @@
-"use client";
-
-import { PanelView } from "@/components/editor/panels/assets/views/base-view";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { FPS_PRESETS } from "@/constants/project-constants";
-import { useEditor } from "@/hooks/use-editor";
-import { useEditorStore } from "@/stores/editor-store";
-import { dimensionToAspectRatio } from "@/utils/geometry";
-import {
- Section,
- SectionContent,
- SectionHeader,
- SectionTitle,
-} from "@/components/editor/panels/properties/section";
-import { Label } from "@/components/ui/label";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover";
-
-const ORIGINAL_PRESET_VALUE = "original";
-
-export function 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;
-}
-
-export function SettingsView() {
- return (
- <PanelView contentClassName="px-0" hideHeader>
- <div className="flex flex-col">
- <Section showTopBorder={false}>
- <SectionContent>
- <ProjectInfoContent />
- </SectionContent>
- </Section>
- <Popover>
- <Section className="cursor-pointer">
- <PopoverTrigger asChild>
- <div>
- <SectionHeader
- trailing={<div className="size-4 rounded-sm bg-red-500" />}
- >
- <SectionTitle>Background</SectionTitle>
- </SectionHeader>
- </div>
- </PopoverTrigger>
- </Section>
- <PopoverContent>
- <div className="size-4 rounded-sm bg-red-500" />
- </PopoverContent>
- </Popover>
- </div>
- </PanelView>
- );
-}
-
-function ProjectInfoContent() {
- const editor = useEditor();
- const activeProject = editor.project.getActive();
- const { canvasPresets } = useEditorStore();
-
- const currentCanvasSize = activeProject.settings.canvasSize;
- const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize);
- const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null;
- const presetIndex = findPresetIndexByAspectRatio({
- presets: canvasPresets,
- targetAspectRatio: currentAspectRatio,
- });
- const selectedPresetValue =
- presetIndex !== -1 ? presetIndex.toString() : ORIGINAL_PRESET_VALUE;
-
- const handleAspectRatioChange = ({ value }: { value: string }) => {
- if (value === ORIGINAL_PRESET_VALUE) {
- 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 }: { value: string }) => {
- const fps = parseFloat(value);
- editor.project.updateSettings({ settings: { fps } });
- };
-
- return (
- <div className="flex flex-col gap-4">
- <div className="flex flex-col gap-2">
- <Label>Name</Label>
- <span className="leading-none text-sm">
- {activeProject.metadata.name}
- </span>
- </div>
- <div className="flex flex-col gap-2">
- <Label>Aspect ratio</Label>
- <Select
- value={selectedPresetValue}
- onValueChange={(value) => handleAspectRatioChange({ value })}
- >
- <SelectTrigger className="w-fit">
- <SelectValue placeholder="Select an aspect ratio" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value={ORIGINAL_PRESET_VALUE}>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>
- </div>
- <div className="flex flex-col gap-2">
- <Label>Frame rate</Label>
- <Select
- value={activeProject.settings.fps.toString()}
- onValueChange={(value) => handleFpsChange({ value })}
- >
- <SelectTrigger className="w-fit">
- <SelectValue placeholder="Select a frame rate" />
- </SelectTrigger>
- <SelectContent>
- {FPS_PRESETS.map((preset) => (
- <SelectItem key={preset.value} value={preset.value}>
- {preset.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </div>
- </div>
- );
-}
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
new file mode 100644
index 00000000..b3ef1eb1
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx
@@ -0,0 +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>
+ );
+}
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
new file mode 100644
index 00000000..e0229ea9
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx
@@ -0,0 +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"
+ />
+ );
+}
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 b1c57c3d..4fb43ac2 100644
--- a/apps/web/src/components/editor/panels/assets/views/sounds.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/sounds.tsx
@@ -24,7 +24,7 @@ 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 type { SavedSound, SoundEffect } from "@/lib/sounds/types";
import { cn } from "@/utils/ui";
import {
FavouriteIcon,
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 671effec..af489630 100644
--- a/apps/web/src/components/editor/panels/assets/views/stickers.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx
@@ -2,81 +2,110 @@
import Image from "next/image";
import type { CSSProperties } from "react";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useState } from "react";
import { toast } from "sonner";
-import { PanelView } from "@/components/editor/panels/assets/views/base-view";
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 {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-import {
- resolveStickerId,
- type StickerItem as StickerData,
+ 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,
- ClockIcon,
- MultiplicationSignIcon,
- Search01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
-import { Spinner } from "@/components/ui/spinner";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { OcSlidersVerticalIcon } from "@opencut/ui/icons";
-import type { StickerCategory } from "@/types/stickers";
-import { STICKER_CATEGORIES } from "@/constants/sticker-constants";
-import { parseStickerId } from "@/lib/stickers/sticker-id";
export function StickersView() {
- const { selectedCategory, setSelectedCategory } = useStickersStore();
+ const {
+ browseContent,
+ browseStickers,
+ searchQuery,
+ searchStickers,
+ selectedCategory,
+ setSearchQuery,
+ setSelectedCategory,
+ viewMode,
+ } = useStickersStore();
+
+ useEffect(() => {
+ if (viewMode === "browse" && !browseContent) {
+ void browseStickers();
+ }
+ }, [browseContent, browseStickers, viewMode]);
return (
- <PanelView
- title="Stickers"
- actions={
- <div className="flex items-center">
- <Select
- value={selectedCategory}
- onValueChange={(value: StickerCategory) =>
- setSelectedCategory({ category: value })
- }
+ <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"
>
- <SelectTrigger variant="outline" size="sm" className="mr-1.5">
- <SelectValue placeholder="All" />
- </SelectTrigger>
- <SelectContent>
- {Object.entries(STICKER_CATEGORIES).map(([category, label]) => (
- <SelectItem key={category} value={category}>
+ {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}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
-
- <Button variant="ghost" size="icon">
- <HugeiconsIcon icon={Search01Icon} className="!size-3.5" />
- </Button>
-
- <Button variant="ghost" size="icon">
- <OcSlidersVerticalIcon className="!size-3.5" />
- </Button>
+ </button>
+ );
+ })}
+ </div>
</div>
- }
- >
- <StickersContentView />
- </PanelView>
+ <div className="min-h-0 flex-1 overflow-y-auto px-4 pt-4">
+ <StickersContentView />
+ </div>
+ </div>
+ </div>
);
}
@@ -92,10 +121,10 @@ function StickerGrid({
"--sticker-max"?: string;
} = {
gridTemplateColumns: shouldCapSize
- ? "repeat(auto-fill, minmax(var(--sticker-min, 96px), var(--sticker-max, 160px)))"
- : "repeat(auto-fit, minmax(var(--sticker-min, 96px), 1fr))",
- "--sticker-min": "96px",
- ...(shouldCapSize ? { "--sticker-max": "160px" } : {}),
+ ? "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 (
@@ -107,6 +136,18 @@ function StickerGrid({
);
}
+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">
@@ -122,88 +163,170 @@ function EmptyView({ message }: { message: string }) {
);
}
+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,
- viewMode,
searchResults,
- recentStickers,
- isSearching,
- clearRecentStickers,
+ selectedCategory,
+ setSelectedCategory,
+ viewMode,
} = useStickersStore();
- const itemsToDisplay = useMemo(() => {
- if (viewMode === "search" && searchResults) {
- return searchResults.items;
+ if (viewMode === "search") {
+ if (isSearching) {
+ return (
+ <div className="flex items-center justify-center py-8">
+ <Spinner className="text-muted-foreground size-6" />
+ </div>
+ );
}
- return [];
- }, [viewMode, searchResults]);
+ if (searchResults?.items.length) {
+ const normalizedQuery = searchQuery.trim().toLowerCase();
+ const isRegionSearch =
+ selectedCategory === "flags" &&
+ resolveQueryToRegions({ query: normalizedQuery }) !== null;
+ const regionLabel = getRegionLabel({ query: normalizedQuery });
- const recentStickerItems = useMemo(() => {
- const items: StickerData[] = [];
- for (const stickerId of recentStickers) {
- const recentStickerItem = toRecentStickerItem({ stickerId });
- if (recentStickerItem) {
- items.push(recentStickerItem);
- }
+ 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>
+ );
}
- return items;
- }, [recentStickers]);
+
+ // "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 h-full flex-col gap-4">
- {recentStickerItems.length > 0 && viewMode === "browse" && (
- <div className="flex h-full flex-col gap-2">
- <div className="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 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>
- <StickerGrid items={recentStickerItems.slice(0, 12)} shouldCapSize />
</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?.items.length ? (
- <div className="flex flex-col gap-3">
- <div className="flex items-center justify-between">
- <span className="text-muted-foreground text-sm">
- {searchResults.total} results
- </span>
- </div>
- <StickerGrid items={itemsToDisplay} shouldCapSize />
- </div>
- ) : searchQuery ? (
- <EmptyView message={`No stickers found for "${searchQuery}"`} />
- ) : null}
- </div>
+ {section.layout === "row" ? (
+ <StickerRow items={section.items} />
+ ) : (
+ <StickerGrid items={section.items} />
)}
</div>
);
@@ -212,65 +335,116 @@ function StickersContentView() {
interface StickerItemProps {
item: StickerData;
shouldCapSize?: boolean;
+ containerClassName?: string;
}
-function StickerItem({ item, shouldCapSize = false }: StickerItemProps) {
- const { addingSticker, addStickerToTimeline } = useStickersStore();
- const isAdding = addingSticker === item.id;
+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 {
- await addStickerToTimeline({
- stickerId: item.id,
- name: item.name,
+ 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 = hasImageError ? (
- <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={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
- />
+ 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")}
@@ -278,18 +452,13 @@ function StickerItem({ item, shouldCapSize = false }: StickerItemProps) {
<DraggableItem
name={displayName}
preview={preview}
- dragData={{
- id: item.id,
- type: "sticker",
- name: displayName,
- stickerId: item.id,
- }}
+ dragData={dragData}
onAddToTimeline={handleAdd}
aspectRatio={1}
shouldShowLabel={false}
isRounded
variant="card"
- containerClassName="w-full"
+ containerClassName={containerClassName ?? "w-full"}
/>
{isAdding && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60">
@@ -299,36 +468,3 @@ function StickerItem({ item, shouldCapSize = false }: StickerItemProps) {
</div>
);
}
-
-function getStickerNameFromId({ stickerId }: { stickerId: string }): string {
- const stickerIdParts = stickerId.split(":");
- if (stickerIdParts.length <= 1) {
- return stickerId;
- }
- return (
- stickerIdParts.slice(1).join(":").split(":").pop()?.replaceAll("-", " ") ??
- stickerId
- );
-}
-
-function toRecentStickerItem({
- stickerId,
-}: {
- stickerId: string;
-}): StickerData | null {
- try {
- const { providerId } = parseStickerId({ stickerId });
- return {
- id: stickerId,
- provider: providerId,
- name: getStickerNameFromId({ stickerId }),
- previewUrl: resolveStickerId({
- stickerId,
- options: { width: 64, height: 64 },
- }),
- metadata: {},
- };
- } catch {
- return null;
- }
-}
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 3673acc3..075cc25b 100644
--- a/apps/web/src/components/editor/panels/assets/views/text.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/text.tsx
@@ -1,7 +1,7 @@
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
-import { PanelView } from "@/components/editor/panels/assets/views/base-view";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
import { useEditor } from "@/hooks/use-editor";
-import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
+import { DEFAULTS } from "@/lib/timeline/defaults";
import { buildTextElement } from "@/lib/timeline/element-utils";
export function TextView() {
@@ -12,7 +12,7 @@ export function TextView() {
if (!activeScene) return;
const element = buildTextElement({
- raw: DEFAULT_TEXT_ELEMENT,
+ raw: DEFAULTS.text.element,
startTime: currentTime,
});
@@ -33,9 +33,9 @@ export function TextView() {
}
dragData={{
id: "temp-text-id",
- type: DEFAULT_TEXT_ELEMENT.type,
- name: DEFAULT_TEXT_ELEMENT.name,
- content: DEFAULT_TEXT_ELEMENT.content,
+ type: DEFAULTS.text.element.type,
+ name: DEFAULTS.text.element.name,
+ content: DEFAULTS.text.element.content,
}}
aspectRatio={1}
onAddToTimeline={handleAddToTimeline}