diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
| commit | 3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch) | |
| tree | 57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/components/editor/panels/assets/views/stickers.tsx | |
| parent | 729d10592f4e802f91c555c309ff3ba4b119f843 (diff) | |
| download | opencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz | |
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/components/editor/panels/assets/views/stickers.tsx')
| -rw-r--r-- | apps/web/src/components/editor/panels/assets/views/stickers.tsx | 455 |
1 files changed, 0 insertions, 455 deletions
diff --git a/apps/web/src/components/editor/panels/assets/views/stickers.tsx b/apps/web/src/components/editor/panels/assets/views/stickers.tsx deleted file mode 100644 index 318aaccb..00000000 --- a/apps/web/src/components/editor/panels/assets/views/stickers.tsx +++ /dev/null @@ -1,455 +0,0 @@ -"use client"; - -import Image from "next/image"; -import type { CSSProperties } from "react"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Spinner } from "@/components/ui/spinner"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { useEditor } from "@/hooks/use-editor"; -import { resolveStickerIntrinsicSize } from "@/lib/stickers"; -import { - buildGraphicElement, - buildStickerElement, -} from "@/lib/timeline/element-utils"; -import { STICKER_CATEGORIES } from "@/lib/stickers/categories"; -import { getRegionLabel, resolveQueryToRegions } from "@/lib/stickers"; -import { parseShapeStickerId } from "@/lib/stickers/providers/shapes"; -import type { TimelineDragData } from "@/lib/timeline/drag"; -import type { - StickerBrowseSection, - StickerCategory, - StickerItem as StickerData, -} from "@/lib/stickers"; -import { useStickersStore } from "@/stores/stickers-store"; -import { cn } from "@/utils/ui"; -import { - HappyIcon, -} from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; - -export function StickersView() { - const { - browseContent, - browseStickers, - searchQuery, - searchStickers, - selectedCategory, - setSearchQuery, - setSelectedCategory, - viewMode, - } = useStickersStore(); - - useEffect(() => { - if (viewMode === "browse" && !browseContent) { - void browseStickers(); - } - }, [browseContent, browseStickers, viewMode]); - - return ( - <div className="flex h-full flex-col py-2"> - <div className="px-2"> - <Input - size="sm" - variant="default" - placeholder="Search..." - value={searchQuery} - onChange={(e) => { - setSearchQuery({ query: e.target.value }); - void searchStickers({ query: e.target.value }); - }} - showClearIcon - onClear={() => { - setSearchQuery({ query: "" }); - void searchStickers({ query: "" }); - }} - className="w-full" - containerClassName="w-full" - /> - </div> - - <Tabs - value={selectedCategory} - onValueChange={(value) => { - setSelectedCategory({ category: value as StickerCategory }); - }} - variant="underline" - className="mt-2 flex min-h-0 flex-1 flex-col" - > - <TabsList aria-label="Sticker categories"> - {Object.entries(STICKER_CATEGORIES).map(([key, label]) => ( - <TabsTrigger key={key} value={key}> - {label} - </TabsTrigger> - ))} - </TabsList> - <div className="min-h-0 flex-1 overflow-y-auto px-4 pt-4"> - <StickersContentView /> - </div> - </Tabs> - </div> - ); -} - -function StickerGrid({ - items, - shouldCapSize = false, -}: { - items: StickerData[]; - shouldCapSize?: boolean; -}) { - const gridStyle: CSSProperties & { - "--sticker-min": string; - "--sticker-max"?: string; - } = { - gridTemplateColumns: shouldCapSize - ? "repeat(auto-fill, minmax(var(--sticker-min, 80px), var(--sticker-max, 140px)))" - : "repeat(auto-fill, minmax(var(--sticker-min, 80px), 1fr))", - "--sticker-min": "80px", - ...(shouldCapSize ? { "--sticker-max": "140px" } : {}), - }; - - return ( - <div className="grid gap-2" style={gridStyle}> - {items.map((item) => ( - <StickerItem key={item.id} item={item} shouldCapSize={shouldCapSize} /> - ))} - </div> - ); -} - -function StickerRow({ items }: { items: StickerData[] }) { - return ( - <div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hidden"> - {items.map((item) => ( - <div key={item.id} className="w-20 shrink-0"> - <StickerItem item={item} shouldCapSize containerClassName="w-full" /> - </div> - ))} - </div> - ); -} - -function EmptyView({ message }: { message: string }) { - return ( - <div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4"> - <HugeiconsIcon - icon={HappyIcon} - className="text-muted-foreground size-10" - /> - <div className="flex flex-col gap-2 text-center"> - <p className="text-lg font-medium">No stickers found</p> - <p className="text-muted-foreground text-sm text-balance">{message}</p> - </div> - </div> - ); -} - -function RegionBanner({ region }: { region: string }) { - return ( - <div className="flex h-7 items-center gap-1.5 rounded-lg border border-sky-100 bg-sky-50 px-2"> - <svg - width="12" - height="12" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - strokeWidth="2" - strokeLinecap="round" - strokeLinejoin="round" - className="shrink-0 text-sky-600" - aria-hidden="true" - > - <path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" /> - <circle cx="12" cy="10" r="3" /> - </svg> - <span className="text-xs font-semibold text-sky-600">{region}</span> - </div> - ); -} - -function StickersContentView() { - const { - browseContent, - clearRecentStickers, - isBrowsing, - isSearching, - searchQuery, - searchResults, - selectedCategory, - setSelectedCategory, - viewMode, - } = useStickersStore(); - - if (viewMode === "search") { - if (isSearching) { - return ( - <div className="flex items-center justify-center py-8"> - <Spinner className="text-muted-foreground size-6" /> - </div> - ); - } - - if (searchResults?.items.length) { - const normalizedQuery = searchQuery.trim().toLowerCase(); - const isRegionSearch = - selectedCategory === "flags" && - resolveQueryToRegions({ query: normalizedQuery }) !== null; - const regionLabel = getRegionLabel({ query: normalizedQuery }); - - return ( - <div className="flex flex-col gap-3 pb-4"> - {isRegionSearch && <RegionBanner region={regionLabel} />} - <div className="flex items-center justify-between"> - <span className="text-muted-foreground text-sm"> - {searchResults.total} results - </span> - </div> - <StickerGrid items={searchResults.items} /> - </div> - ); - } - - // "all" tab search — sections are in browseContent, fall through to section rendering below - if (selectedCategory !== "all" && searchQuery) { - return <EmptyView message={`No stickers found for "${searchQuery}"`} />; - } - } - - if (isBrowsing && !browseContent) { - return ( - <div className="flex items-center justify-center py-8"> - <Spinner className="text-muted-foreground size-6" /> - </div> - ); - } - - if (!browseContent?.sections.length) { - const categoryLabel = STICKER_CATEGORIES[selectedCategory]; - return ( - <EmptyView - message={ - viewMode === "search" - ? `No stickers found for "${searchQuery}"` - : selectedCategory === "all" - ? "No stickers available yet." - : `No stickers available in ${categoryLabel.toLowerCase()} yet.` - } - /> - ); - } - - return ( - <div className="flex flex-col gap-4 pb-4"> - {browseContent.sections.map((section) => ( - <StickerSection - key={section.id} - section={section} - onClearRecent={clearRecentStickers} - onSeeAll={(category) => { - setSelectedCategory({ category }); - }} - /> - ))} - </div> - ); -} - -function StickerSection({ - section, - onClearRecent, - onSeeAll, -}: { - section: StickerBrowseSection; - onClearRecent: () => void; - onSeeAll: (category: StickerCategory) => void; -}) { - const hasHeader = - Boolean(section.title) || section.id === "recent" || section.action; - - return ( - <div className="flex flex-col gap-2"> - {hasHeader && ( - <div className="flex items-center justify-between gap-2"> - {section.title ? ( - <p className="text-xs text-muted-foreground">{section.title}</p> - ) : ( - <div /> - )} - - <div className="ml-auto flex items-center gap-2"> - {section.id === "recent" && ( - <Button - onClick={onClearRecent} - variant="text" - size="sm" - className="h-auto gap-1 p-0 text-xs text-muted-foreground" - > - Clear - </Button> - )} - - {section.action?.type === "see-all" && section.action.category && ( - <Button - variant="text" - size="sm" - className="h-auto gap-1 p-0 text-xs text-primary" - onClick={() => { - onSeeAll(section.action?.category as StickerCategory); - }} - > - See all - </Button> - )} - </div> - </div> - )} - - {section.layout === "row" ? ( - <StickerRow items={section.items} /> - ) : ( - <StickerGrid items={section.items} /> - )} - </div> - ); -} - -interface StickerItemProps { - item: StickerData; - shouldCapSize?: boolean; - containerClassName?: string; -} - -function StickerItem({ - item, - shouldCapSize = false, - containerClassName, -}: StickerItemProps) { - const editor = useEditor(); - const { addToRecentStickers } = useStickersStore(); - const [isAdding, setIsAdding] = useState(false); - const [hasImageError, setHasImageError] = useState(false); - - useEffect(() => { - if (!item.id) { - return; - } - - setHasImageError(false); - }, [item.id]); - - const displayName = item.name; - const shapePreset = - item.provider === "shapes" ? parseShapeStickerId({ stickerId: item.id }) : null; - - const handleAdd = async () => { - setIsAdding(true); - try { - const currentTime = editor.playback.getCurrentTime(); - - let element: - | ReturnType<typeof buildGraphicElement> - | ReturnType<typeof buildStickerElement>; - if (shapePreset) { - element = buildGraphicElement({ - definitionId: shapePreset.definitionId, - name: shapePreset.name, - startTime: currentTime, - params: shapePreset.params, - }); - } else { - const { width: intrinsicWidth, height: intrinsicHeight } = - await resolveStickerIntrinsicSize({ stickerId: item.id }); - element = buildStickerElement({ - stickerId: item.id, - name: item.name, - startTime: currentTime, - intrinsicWidth, - intrinsicHeight, - }); - } - - editor.timeline.insertElement({ - placement: { mode: "auto" }, - element, - }); - - addToRecentStickers({ stickerId: item.id }); - } catch (error) { - console.error("Failed to add sticker:", error); - toast.error("Failed to add sticker to timeline"); - } finally { - setIsAdding(false); - } - }; - - const preview = ( - <div className="flex size-full items-center justify-center p-3"> - {hasImageError ? ( - <span className="text-muted-foreground text-center text-xs break-all"> - {displayName} - </span> - ) : ( - <Image - src={item.previewUrl} - alt={displayName} - width={64} - height={64} - className="size-full object-contain" - style={ - shouldCapSize - ? { - maxWidth: "var(--sticker-max, 160px)", - maxHeight: "var(--sticker-max, 160px)", - } - : undefined - } - onError={() => setHasImageError(true)} - loading="lazy" - unoptimized - /> - )} - </div> - ); - - const dragData: TimelineDragData = shapePreset - ? { - id: item.id, - type: "graphic", - name: displayName, - definitionId: shapePreset.definitionId, - params: shapePreset.params ?? {}, - } - : { - id: item.id, - type: "sticker", - name: displayName, - stickerId: item.id, - }; - - return ( - <div - className={cn("relative", isAdding && "pointer-events-none opacity-50")} - > - <DraggableItem - name={displayName} - preview={preview} - dragData={dragData} - onAddToTimeline={handleAdd} - aspectRatio={1} - shouldShowLabel={false} - isRounded - variant="card" - containerClassName={containerClassName ?? "w-full"} - /> - {isAdding && ( - <div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60"> - <Spinner className="size-6 text-white" /> - </div> - )} - </div> - ); -} |
