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/draggable-item.tsx4
-rw-r--r--apps/web/src/components/editor/panels/assets/index.tsx2
-rw-r--r--apps/web/src/components/editor/panels/assets/tabbar.tsx3
-rw-r--r--apps/web/src/components/editor/panels/assets/views/assets.tsx (renamed from apps/web/src/components/editor/panels/assets/views/media.tsx)366
-rw-r--r--apps/web/src/components/editor/panels/assets/views/base-view.tsx52
-rw-r--r--apps/web/src/components/editor/panels/assets/views/captions.tsx9
-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.tsx354
-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.tsx689
-rw-r--r--apps/web/src/components/editor/panels/assets/views/text.tsx6
11 files changed, 848 insertions, 971 deletions
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 517fdc92..be4176ce 100644
--- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx
+++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx
@@ -163,7 +163,9 @@ export function DraggableItem({
<div className="size-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]">
{preview}
</div>
- <span className="w-full flex-1 truncate text-sm text-left">{name}</span>
+ <span className="w-full flex-1 truncate text-sm text-left">
+ {name}
+ </span>
</button>
</div>
)}
diff --git a/apps/web/src/components/editor/panels/assets/index.tsx b/apps/web/src/components/editor/panels/assets/index.tsx
index e7bd3d4f..3600d00b 100644
--- a/apps/web/src/components/editor/panels/assets/index.tsx
+++ b/apps/web/src/components/editor/panels/assets/index.tsx
@@ -4,7 +4,7 @@ import { Separator } from "@/components/ui/separator";
import { type Tab, useAssetsPanelStore } from "@/stores/assets-panel-store";
import { TabBar } from "./tabbar";
import { Captions } from "./views/captions";
-import { MediaView } from "./views/media";
+import { MediaView } from "./views/assets";
import { SettingsView } from "./views/settings";
import { SoundsView } from "./views/sounds";
import { StickersView } from "./views/stickers";
diff --git a/apps/web/src/components/editor/panels/assets/tabbar.tsx b/apps/web/src/components/editor/panels/assets/tabbar.tsx
index 31842306..61777a7c 100644
--- a/apps/web/src/components/editor/panels/assets/tabbar.tsx
+++ b/apps/web/src/components/editor/panels/assets/tabbar.tsx
@@ -61,7 +61,8 @@ export function TabBar() {
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",
+ activeTab !== tabKey &&
+ "border border-transparent text-muted-foreground",
)}
onClick={() => setActiveTab(tabKey)}
>
diff --git a/apps/web/src/components/editor/panels/assets/views/media.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx
index 59395fd8..808961f1 100644
--- a/apps/web/src/components/editor/panels/assets/views/media.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx
@@ -3,6 +3,7 @@
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 { MediaDragOverlay } from "@/components/editor/panels/assets/drag-overlay";
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
import { Button } from "@/components/ui/button";
@@ -29,14 +30,9 @@ 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 { buildElementFromMedia } 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,
@@ -132,7 +128,15 @@ export function MediaView() {
asset: MediaAsset;
startTime: number;
}): boolean => {
- const element = createElementFromMedia({ asset, startTime });
+ const duration =
+ asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ const element = buildElementFromMedia({
+ mediaId: asset.id,
+ mediaType: asset.type,
+ name: asset.name,
+ duration,
+ startTime,
+ });
editor.timeline.insertElement({
element,
placement: { mode: "auto" },
@@ -194,171 +198,166 @@ export function MediaView() {
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-background h-12 px-4 pr-2 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>
+ const mediaActions = (
+ <div>
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ size="icon"
+ variant="ghost"
+ 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"
- onClick={() =>
- setMediaViewMode(
- mediaViewMode === "grid" ? "list" : "grid",
- )
- }
+ variant="ghost"
disabled={isProcessing}
className="items-center justify-center"
>
- {mediaViewMode === "grid" ? (
- <HugeiconsIcon icon={LeftToRightListDashIcon} />
- ) : (
- <HugeiconsIcon icon={GridViewIcon} />
- )}
+ <HugeiconsIcon icon={SortingOneNineIcon} />
</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 hover:bg-accent px-3"
- >
- <HugeiconsIcon icon={CloudUploadIcon} />
- Import
- </Button>
- </div>
- </div>
+ </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} />
+ Import
+ </Button>
+ </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>
+ return (
+ <>
+ <input {...fileInputProps} />
+
+ <PanelView
+ title="Assets"
+ actions={mediaActions}
+ className={isDragOver ? "bg-accent/30" : ""}
+ {...dragProps}
+ >
+ {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}
+ />
+ )}
+ </PanelView>
</>
);
}
@@ -636,40 +635,3 @@ function SortMenuItem({
</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/base-view.tsx b/apps/web/src/components/editor/panels/assets/views/base-view.tsx
new file mode 100644
index 00000000..c613bc2a
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/base-view.tsx
@@ -0,0 +1,52 @@
+import { cn } from "@/utils/ui";
+
+interface PanelViewProps extends React.HTMLAttributes<HTMLDivElement> {
+ title?: string;
+ actions?: React.ReactNode;
+ children: React.ReactNode;
+ contentClassName?: string;
+ hideHeader?: boolean;
+ ref?: React.Ref<HTMLDivElement>;
+ onScroll?: React.UIEventHandler<HTMLDivElement>;
+ scrollRef?: React.Ref<HTMLDivElement>;
+}
+
+export function PanelView({
+ title,
+ actions,
+ children,
+ className,
+ contentClassName,
+ hideHeader = false,
+ ref,
+ onScroll,
+ scrollRef,
+ ...rest
+}: PanelViewProps) {
+ return (
+ <div
+ className={cn("relative flex h-full flex-col", className)}
+ ref={ref}
+ {...rest}
+ >
+ {!hideHeader && (
+ <div className="bg-background h-11 shrink-0 px-4 pr-2 flex items-center justify-between border-b">
+ <span className="text-muted-foreground text-sm">{title}</span>
+ {actions}
+ </div>
+ )}
+ <div
+ className={cn(
+ "scrollbar-thin size-full overflow-y-auto",
+ hideHeader ? "pt-4" : "pt-2",
+ )}
+ ref={scrollRef}
+ onScroll={onScroll}
+ >
+ <div className={cn("w-full flex-1 px-2 pt-0", contentClassName)}>
+ {children}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/captions.tsx b/apps/web/src/components/editor/panels/assets/views/captions.tsx
index daebf2c6..7a3b16bf 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 { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
+import { PanelView } from "@/components/editor/panels/assets/views/base-view";
import {
Select,
SelectContent,
@@ -108,10 +108,7 @@ export function Captions() {
};
return (
- <BaseView
- ref={containerRef}
- className="flex h-full flex-col justify-between"
- >
+ <PanelView title="Captions" ref={containerRef}>
<div className="flex flex-col gap-3">
<Label>Language</Label>
<Select
@@ -148,6 +145,6 @@ export function Captions() {
{isProcessing ? processingStep : "Generate transcript"}
</Button>
</div>
- </BaseView>
+ </PanelView>
);
}
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
new file mode 100644
index 00000000..457c08a9
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx
@@ -0,0 +1,332 @@
+// @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
index 26afe566..7e10ac9d 100644
--- a/apps/web/src/components/editor/panels/assets/views/settings.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/settings.tsx
@@ -1,8 +1,6 @@
"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 { PanelView } from "@/components/editor/panels/assets/views/base-view";
import {
Select,
SelectContent,
@@ -10,63 +8,54 @@ import {
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 { FPS_PRESETS } from "@/constants/project-constants";
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/property-item";
-import { ColorPicker } from "@/components/ui/color-picker";
+ Section,
+ SectionContent,
+ SectionHeader,
+} from "@/components/editor/panels/properties/section";
+import { Label } from "@/components/ui/label";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { useState } from "react";
export function SettingsView() {
- return <ProjectSettingsTabs />;
-}
+ const [open, setOpen] = useState(false);
-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">
- <BackgroundView />
+ <PanelView contentClassName="px-0" hideHeader>
+ <div className="flex flex-col">
+ <Section hasBorderTop={false}>
+ <SectionContent>
+ <ProjectInfoContent />
+ </SectionContent>
+ </Section>
+ <Popover open={open} onOpenChange={setOpen}>
+ <Section className="cursor-pointer">
+ <PopoverTrigger asChild>
+ <div>
+ <SectionHeader title="Background">
+ <div className="size-4 rounded-sm bg-red-500" />
+ </SectionHeader>
</div>
- </div>
- ),
- },
- ]}
- className="flex h-full flex-col justify-between p-0"
- />
+ </PopoverTrigger>
+ </Section>
+ <PopoverContent>
+ <div className="size-4 rounded-sm bg-red-500" />
+ </PopoverContent>
+ </Popover>
+ </div>
+ </PanelView>
);
}
-function ProjectInfoView() {
+function ProjectInfoContent() {
const editor = useEditor();
const activeProject = editor.project.getActive();
const { canvasPresets } = useEditorStore();
@@ -124,232 +113,55 @@ function ProjectInfoView() {
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}
+ <div className="flex flex-col gap-2">
+ <Label>Name</Label>
+ <span className="leading-none text-sm">
+ {activeProject.metadata.name}
</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 h-full 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-col gap-2">
+ <Label>Aspect ratio</Label>
+ <Select
+ value={selectedPresetValue}
+ onValueChange={(value) => handleAspectRatioChange({ value })}
>
- <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>
- ))}
+ <SelectTrigger className="w-fit">
+ <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>
+ </div>
+ <div className="flex flex-col gap-2">
+ <Label>Frame rate</Label>
+ <Select
+ value={activeProject.settings.fps.toString()}
+ onValueChange={handleFpsChange}
+ >
+ <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/sounds.tsx b/apps/web/src/components/editor/panels/assets/views/sounds.tsx
index 30c70a83..b1c57c3d 100644
--- a/apps/web/src/components/editor/panels/assets/views/sounds.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/sounds.tsx
@@ -235,7 +235,7 @@ function SoundEffectsView() {
<div className="flex items-center gap-3">
<Input
placeholder="Search sound effects"
- className="bg-accent w-full"
+ className="w-full"
containerClassName="w-full"
value={searchQuery}
onChange={({ currentTarget }) =>
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 68ba91d2..671effec 100644
--- a/apps/web/src/components/editor/panels/assets/views/stickers.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx
@@ -4,144 +4,104 @@ import Image from "next/image";
import type { CSSProperties } from "react";
import { useEffect, useMemo, 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 { 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";
+ resolveStickerId,
+ type StickerItem as StickerData,
+} from "@/lib/stickers";
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,
+ Search01Icon,
} 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);
-}
+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();
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"
- />
+ <PanelView
+ title="Stickers"
+ actions={
+ <div className="flex items-center">
+ <Select
+ value={selectedCategory}
+ onValueChange={(value: StickerCategory) =>
+ setSelectedCategory({ category: value })
+ }
+ >
+ <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}>
+ {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>
+ </div>
+ }
+ >
+ <StickersContentView />
+ </PanelView>
);
}
function StickerGrid({
- icons,
- onAdd,
- addingSticker,
- capSize = false,
+ items,
+ shouldCapSize = false,
}: {
- icons: string[];
- onAdd: (iconName: string) => void;
- addingSticker: string | null;
- capSize?: boolean;
+ items: StickerData[];
+ shouldCapSize?: boolean;
}) {
const gridStyle: CSSProperties & {
"--sticker-min": string;
"--sticker-max"?: string;
} = {
- gridTemplateColumns: capSize
+ 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",
- ...(capSize ? { "--sticker-max": "160px" } : {}),
+ ...(shouldCapSize ? { "--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 })}
- />
+ {items.map((item) => (
+ <StickerItem key={item.id} item={item} shouldCapSize={shouldCapSize} />
))}
</div>
);
@@ -162,372 +122,125 @@ function EmptyView({ message }: { message: string }) {
);
}
-function StickersContentView({ category }: { category: StickerCategory }) {
+function StickersContentView() {
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(() => {
+ const itemsToDisplay = 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 searchResults.items;
}
return [];
- }, [viewMode, searchResults, currentCollection]);
+ }, [viewMode, searchResults]);
- const isInCollection = viewMode === "collection" && !!selectedCollection;
-
- useEffect(() => {
- if (isInCollection) {
- setShowCollectionItems(false);
- const timer = setTimeout(() => setShowCollectionItems(true), 350);
- return () => clearTimeout(timer);
- } else {
- setShowCollectionItems(false);
+ const recentStickerItems = useMemo(() => {
+ const items: StickerData[] = [];
+ for (const stickerId of recentStickers) {
+ const recentStickerItem = toRecentStickerItem({ stickerId });
+ if (recentStickerItem) {
+ items.push(recentStickerItem);
+ }
}
- }, [isInCollection]);
+ return items;
+ }, [recentStickers]);
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
+ <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"
/>
- </>
- ) : 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>
- )}
+ </button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>Clear recent stickers</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ <StickerGrid items={recentStickerItems.slice(0, 12)} shouldCapSize />
+ </div>
+ )}
- {category === "all" && filteredCollections.length > 0 && (
- <div className="h-full">
- <CollectionGrid
- collections={filteredCollections.slice(
- 0,
- collectionsToShow,
- )}
- onSelectCollection={({ prefix }) =>
- setSelectedCollection({ collection: prefix })
- }
- />
- </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>
- )}
- </div>
- </ScrollArea>
- </div>
+ <StickerGrid items={itemsToDisplay} shouldCapSize />
+ </div>
+ ) : searchQuery ? (
+ <EmptyView message={`No stickers found for "${searchQuery}"`} />
+ ) : null}
+ </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;
+ item: StickerData;
+ shouldCapSize?: boolean;
}
-function StickerItem({
- iconName,
- onAdd,
- isAdding,
- capSize = false,
-}: StickerItemProps) {
- const [imageError, setImageError] = useState(false);
- const [hostIndex, setHostIndex] = useState(0);
+function StickerItem({ item, shouldCapSize = false }: StickerItemProps) {
+ const { addingSticker, addStickerToTimeline } = useStickersStore();
+ const isAdding = addingSticker === item.id;
+ const [hasImageError, setHasImageError] = useState(false);
useEffect(() => {
- if (!iconName) {
+ if (!item.id) {
return;
}
- setImageError(false);
- setHostIndex(0);
- }, [iconName]);
+ setHasImageError(false);
+ }, [item.id]);
- const displayName = iconName.split(":")[1] || iconName;
- const collectionPrefix = iconName.split(":")[0];
+ const displayName = item.name;
+
+ const handleAdd = async () => {
+ try {
+ await addStickerToTimeline({
+ stickerId: item.id,
+ name: item.name,
+ });
+ } catch (error) {
+ console.error("Failed to add sticker:", error);
+ toast.error("Failed to add sticker to timeline");
+ }
+ };
- const preview = imageError ? (
+ 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}
@@ -536,21 +249,13 @@ function StickerItem({
) : (
<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 },
- )
- }
+ src={item.previewUrl}
alt={displayName}
width={64}
height={64}
className="size-full object-contain"
style={
- capSize
+ shouldCapSize
? {
maxWidth: "var(--sticker-max, 160px)",
maxHeight: "var(--sticker-max, 160px)",
@@ -558,12 +263,7 @@ function StickerItem({
: undefined
}
onError={() => {
- const next = hostIndex + 1;
- if (next < ICONIFY_HOSTS.length) {
- setHostIndex(next);
- } else {
- setImageError(true);
- }
+ setHasImageError(true);
}}
loading="lazy"
unoptimized
@@ -572,44 +272,63 @@ function StickerItem({
);
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
+ className={cn("relative", isAdding && "pointer-events-none opacity-50")}
+ >
+ <DraggableItem
+ name={displayName}
+ preview={preview}
+ dragData={{
+ id: item.id,
+ type: "sticker",
+ name: displayName,
+ stickerId: item.id,
+ }}
+ onAddToTimeline={handleAdd}
+ aspectRatio={1}
+ shouldShowLabel={false}
+ isRounded
+ variant="card"
+ 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>
- </TooltipContent>
- </Tooltip>
+ )}
+ </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 6360c363..3673acc3 100644
--- a/apps/web/src/components/editor/panels/assets/views/text.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/text.tsx
@@ -1,5 +1,5 @@
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
-import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
+import { PanelView } from "@/components/editor/panels/assets/views/base-view";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import { buildTextElement } from "@/lib/timeline/element-utils";
@@ -23,7 +23,7 @@ export function TextView() {
};
return (
- <BaseView>
+ <PanelView title="Text">
<DraggableItem
name="Default text"
preview={
@@ -41,6 +41,6 @@ export function TextView() {
onAddToTimeline={handleAddToTimeline}
shouldShowLabel={false}
/>
- </BaseView>
+ </PanelView>
);
}