diff options
Diffstat (limited to 'apps/web/src/components/editor/panels/assets/views')
| -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.tsx | 52 | ||||
| -rw-r--r-- | apps/web/src/components/editor/panels/assets/views/captions.tsx | 9 | ||||
| -rw-r--r-- | apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx | 332 | ||||
| -rw-r--r-- | apps/web/src/components/editor/panels/assets/views/settings.tsx | 354 | ||||
| -rw-r--r-- | apps/web/src/components/editor/panels/assets/views/sounds.tsx | 2 | ||||
| -rw-r--r-- | apps/web/src/components/editor/panels/assets/views/stickers.tsx | 689 | ||||
| -rw-r--r-- | apps/web/src/components/editor/panels/assets/views/text.tsx | 6 |
8 files changed, 842 insertions, 968 deletions
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> ); } |
