summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/media-panel/views
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components/editor/media-panel/views')
-rw-r--r--apps/web/src/components/editor/media-panel/views/media.tsx1
-rw-r--r--apps/web/src/components/editor/media-panel/views/sounds.tsx32
-rw-r--r--apps/web/src/components/editor/media-panel/views/stickers.tsx618
3 files changed, 633 insertions, 18 deletions
diff --git a/apps/web/src/components/editor/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx
index 772ef4ab..0f0ed92d 100644
--- a/apps/web/src/components/editor/media-panel/views/media.tsx
+++ b/apps/web/src/components/editor/media-panel/views/media.tsx
@@ -146,6 +146,7 @@ export function MediaView() {
useEffect(() => {
let filtered = mediaItems.filter((item) => {
+ if (item.ephemeral) return false;
if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
return false;
}
diff --git a/apps/web/src/components/editor/media-panel/views/sounds.tsx b/apps/web/src/components/editor/media-panel/views/sounds.tsx
index ed0db8ab..303eb858 100644
--- a/apps/web/src/components/editor/media-panel/views/sounds.tsx
+++ b/apps/web/src/components/editor/media-panel/views/sounds.tsx
@@ -1,7 +1,7 @@
"use client";
import { Input } from "@/components/ui/input";
-import { useState, useMemo, useRef, useEffect } from "react";
+import { useState, useMemo, useEffect } from "react";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -32,6 +32,7 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
+import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
export function SoundsView() {
return (
@@ -96,35 +97,30 @@ function SoundEffectsView() {
null
);
- // Scroll position persistence
- const scrollAreaRef = useRef<HTMLDivElement>(null);
+ const { scrollAreaRef, handleScroll } = useInfiniteScroll({
+ onLoadMore: loadMore,
+ hasMore: hasNextPage,
+ isLoading: isLoadingMore || isSearching,
+ });
- // Load saved sounds and restore scroll position when component mounts
useEffect(() => {
loadSavedSounds();
if (scrollAreaRef.current && scrollPosition > 0) {
const timeoutId = setTimeout(() => {
scrollAreaRef.current?.scrollTo({ top: scrollPosition });
- }, 100); // Small delay to ensure content is rendered
+ }, 100);
return () => clearTimeout(timeoutId);
}
- }, []); // Only run on mount
+ }, []);
- // Track scroll position changes and handle infinite scroll
- const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
- const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
+ const handleScrollWithPosition = (event: React.UIEvent<HTMLDivElement>) => {
+ const { scrollTop } = event.currentTarget;
setScrollPosition(scrollTop);
-
- // Trigger loadMore when scrolled to within 200px of bottom
- const nearBottom = scrollTop + clientHeight >= scrollHeight - 200;
- if (nearBottom && hasNextPage && !isLoadingMore && !isSearching) {
- loadMore();
- }
+ handleScroll(event);
};
-
- // Use your existing design, just swap the data source
+
const displayedSounds = useMemo(() => {
const sounds = searchQuery ? searchResults : topSoundEffects;
return sounds;
@@ -199,7 +195,7 @@ function SoundEffectsView() {
<ScrollArea
className="flex-1 h-full"
ref={scrollAreaRef}
- onScrollCapture={handleScroll}
+ onScrollCapture={handleScrollWithPosition}
>
<div className="flex flex-col gap-4">
{isLoading && !searchQuery && (
diff --git a/apps/web/src/components/editor/media-panel/views/stickers.tsx b/apps/web/src/components/editor/media-panel/views/stickers.tsx
new file mode 100644
index 00000000..937a968e
--- /dev/null
+++ b/apps/web/src/components/editor/media-panel/views/stickers.tsx
@@ -0,0 +1,618 @@
+"use client";
+
+import { useEffect, useState, useMemo } from "react";
+import { useStickersStore } from "@/stores/stickers-store";
+import { useMediaStore } from "@/stores/media-store";
+import { useProjectStore } from "@/stores/project-store";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { usePlaybackStore } from "@/stores/playback-store";
+import {
+ Loader2,
+ Grid3X3,
+ Hash,
+ Smile,
+ Clock,
+ X,
+ Sparkles,
+ ArrowRight,
+ StickerIcon,
+} from "lucide-react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
+import { Separator } from "@/components/ui/separator";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+
+import {
+ getIconSvgUrl,
+ buildIconSvgUrl,
+ ICONIFY_HOSTS,
+ POPULAR_COLLECTIONS,
+} from "@/lib/iconify-api";
+import { cn, generateUUID } from "@/lib/utils";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import Image from "next/image";
+import { DraggableMediaItem } from "@/components/ui/draggable-item";
+import { InputWithBack } from "@/components/ui/input-with-back";
+import { StickerCategory } from "@/stores/stickers-store";
+import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
+
+export function StickersView() {
+ const { selectedCategory, setSelectedCategory } = useStickersStore();
+
+ return (
+ <div className="h-full flex flex-col">
+ <Tabs
+ value={selectedCategory}
+ onValueChange={(v) => {
+ if (["all", "general", "brands", "emoji"].includes(v)) {
+ setSelectedCategory(v as StickerCategory);
+ }
+ }}
+ className="flex flex-col h-full"
+ >
+ <div className="px-3 pt-4 pb-0">
+ <TabsList>
+ <TabsTrigger value="all" className="gap-1">
+ <Grid3X3 className="h-3 w-3" />
+ All
+ </TabsTrigger>
+ <TabsTrigger value="general" className="gap-1">
+ <Sparkles className="h-3 w-3" />
+ Icons
+ </TabsTrigger>
+ <TabsTrigger value="brands" className="gap-1">
+ <Hash className="h-3 w-3" />
+ Brands
+ </TabsTrigger>
+ <TabsTrigger value="emoji" className="gap-1">
+ <Smile className="h-3 w-3" />
+ Emoji
+ </TabsTrigger>
+ </TabsList>
+ </div>
+ <Separator className="my-4" />
+ <TabsContent
+ value="all"
+ className="p-5 pt-0 mt-0 flex-1 flex flex-col min-h-0"
+ >
+ <StickersContentView category="all" />
+ </TabsContent>
+ <TabsContent
+ value="general"
+ className="p-5 pt-0 mt-0 flex-1 flex flex-col min-h-0"
+ >
+ <StickersContentView category="general" />
+ </TabsContent>
+ <TabsContent
+ value="brands"
+ className="p-5 pt-0 mt-0 flex-1 flex flex-col min-h-0"
+ >
+ <StickersContentView category="brands" />
+ </TabsContent>
+ <TabsContent
+ value="emoji"
+ className="p-5 pt-0 mt-0 flex-1 flex flex-col min-h-0"
+ >
+ <StickersContentView category="emoji" />
+ </TabsContent>
+ </Tabs>
+ </div>
+ );
+}
+
+function StickerGrid({
+ icons,
+ onAdd,
+ addingSticker,
+}: {
+ icons: string[];
+ onAdd: (iconName: string) => void;
+ addingSticker: string | null;
+}) {
+ return (
+ <div
+ className="grid gap-2"
+ style={{
+ gridTemplateColumns: "repeat(auto-fill, 112px)",
+ }}
+ >
+ {icons.map((iconName) => (
+ <StickerItem
+ key={iconName}
+ iconName={iconName}
+ onAdd={onAdd}
+ isAdding={addingSticker === iconName}
+ />
+ ))}
+ </div>
+ );
+}
+
+function CollectionGrid({
+ collections,
+ onSelectCollection,
+}: {
+ collections: Array<{
+ prefix: string;
+ name: string;
+ total: number;
+ category?: string;
+ }>;
+ onSelectCollection: (prefix: string) => void;
+}) {
+ return (
+ <div className="grid grid-cols-1 gap-2 h-full overflow-hidden">
+ {collections.map((collection) => (
+ <CollectionItem
+ key={collection.prefix}
+ title={collection.name}
+ subtitle={`${collection.total.toLocaleString()} icons${collection.category ? ` • ${collection.category}` : ""}`}
+ onClick={() => onSelectCollection(collection.prefix)}
+ />
+ ))}
+ </div>
+ );
+}
+
+function EmptyView({ message }: { message: string }) {
+ return (
+ <div className="bg-panel h-full p-4 flex flex-col items-center justify-center gap-3">
+ <StickerIcon
+ className="w-10 h-10 text-muted-foreground"
+ strokeWidth={1.5}
+ />
+ <div className="flex flex-col gap-2 text-center">
+ <p className="text-lg font-medium">No stickers found</p>
+ <p className="text-sm text-muted-foreground text-balance">{message}</p>
+ </div>
+ </div>
+ );
+}
+
+function StickersContentView({ category }: { category: StickerCategory }) {
+ const { activeProject } = useProjectStore();
+ const { addMediaAtTime } = useTimelineStore();
+ const { currentTime } = usePlaybackStore();
+ const { addMediaItem } = useMediaStore();
+ const {
+ searchQuery,
+ selectedCollection,
+ viewMode,
+ collections,
+ currentCollection,
+ searchResults,
+ recentStickers,
+ isLoadingCollections,
+ isLoadingCollection,
+ isSearching,
+ setSearchQuery,
+ setSelectedCollection,
+ loadCollections,
+ searchStickers,
+ downloadSticker,
+ clearRecentStickers,
+ } = useStickersStore();
+
+ const [addingSticker, setAddingSticker] = useState<string | null>(null);
+ 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();
+ }
+ }, []);
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ if (localSearchQuery !== searchQuery) {
+ setSearchQuery(localSearchQuery);
+ if (localSearchQuery.trim()) {
+ searchStickers(localSearchQuery);
+ }
+ }
+ }, 500);
+
+ return () => clearTimeout(timer);
+ }, [localSearchQuery]);
+
+ const handleAddSticker = async (iconName: string) => {
+ if (!activeProject) {
+ toast.error("No active project");
+ return;
+ }
+
+ setAddingSticker(iconName);
+
+ try {
+ const file = await downloadSticker(iconName);
+
+ if (!file) {
+ throw new Error("Failed to download sticker");
+ }
+
+ const mediaItem = {
+ name: iconName.replace(":", "-"),
+ type: "image" as const,
+ file,
+ url: URL.createObjectURL(file),
+ width: 200,
+ height: 200,
+ duration: TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
+ ephemeral: false,
+ };
+
+ await addMediaItem(activeProject.id, mediaItem);
+
+ const added = useMediaStore
+ .getState()
+ .mediaItems.find(
+ (m) => m.url === mediaItem.url && m.name === mediaItem.name
+ );
+ if (!added) throw new Error("Sticker not in media store");
+
+ addMediaAtTime(added, currentTime);
+
+ toast.success(`Added "${iconName}" to timeline`);
+ } catch (error) {
+ console.error("Failed to add sticker:", error);
+ toast.error("Failed to add sticker to timeline");
+ } finally {
+ setAddingSticker(null);
+ }
+ };
+
+ const iconsToDisplay = useMemo(() => {
+ if (viewMode === "search" && searchResults) {
+ return searchResults.icons;
+ }
+
+ if (viewMode === "collection" && currentCollection) {
+ const icons: string[] = [];
+
+ if (currentCollection.uncategorized) {
+ icons.push(
+ ...currentCollection.uncategorized.map(
+ (name) => `${currentCollection.prefix}:${name}`
+ )
+ );
+ }
+
+ if (currentCollection.categories) {
+ Object.values(currentCollection.categories).forEach((categoryIcons) => {
+ icons.push(
+ ...categoryIcons.map(
+ (name) => `${currentCollection.prefix}:${name}`
+ )
+ );
+ });
+ }
+
+ return icons.slice(0, 100);
+ }
+
+ return [];
+ }, [viewMode, searchResults, currentCollection]);
+
+ const isInCollection = viewMode === "collection" && !!selectedCollection;
+
+ useEffect(() => {
+ if (isInCollection) {
+ setShowCollectionItems(false);
+ const timer = setTimeout(() => setShowCollectionItems(true), 350);
+ return () => clearTimeout(timer);
+ } else {
+ setShowCollectionItems(false);
+ }
+ }, [isInCollection]);
+
+ return (
+ <div className="flex flex-col gap-5 mt-1 h-full">
+ <div className="space-y-3">
+ <InputWithBack
+ isExpanded={isInCollection}
+ setIsExpanded={(expanded) => {
+ if (!expanded && isInCollection) {
+ setSelectedCollection(null);
+ }
+ }}
+ placeholder="Search icons..."
+ value={localSearchQuery}
+ onChange={setLocalSearchQuery}
+ />
+ </div>
+
+ <div className="relative h-full overflow-hidden">
+ <ScrollArea
+ className="flex-1 h-full"
+ ref={scrollAreaRef}
+ onScrollCapture={handleScroll}
+ >
+ <div className="flex flex-col gap-4 h-full">
+ {recentStickers.length > 0 && viewMode === "browse" && (
+ <div className="h-full">
+ <div className="flex items-center gap-2 mb-2">
+ <Clock className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm font-medium">Recent</span>
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <button
+ onClick={clearRecentStickers}
+ className="ml-auto h-5 w-5 p-0 rounded hover:bg-accent flex items-center justify-center"
+ >
+ <X className="h-3 w-3 text-muted-foreground" />
+ </button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>Clear recent stickers</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ <StickerGrid
+ icons={recentStickers.slice(0, 12)}
+ onAdd={handleAddSticker}
+ addingSticker={addingSticker}
+ />
+ </div>
+ )}
+
+ {viewMode === "collection" && selectedCollection && (
+ <div className="h-full">
+ {isLoadingCollection ? (
+ <div className="flex items-center justify-center py-8">
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
+ </div>
+ ) : showCollectionItems ? (
+ <StickerGrid
+ icons={iconsToDisplay}
+ onAdd={handleAddSticker}
+ addingSticker={addingSticker}
+ />
+ ) : (
+ <div className="flex items-center justify-center py-8">
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
+ </div>
+ )}
+ </div>
+ )}
+
+ {viewMode === "search" && (
+ <div className="h-full">
+ {isSearching ? (
+ <div className="flex items-center justify-center py-8">
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
+ </div>
+ ) : searchResults?.icons.length ? (
+ <>
+ <div className="flex items-center justify-between mb-3">
+ <span className="text-sm text-muted-foreground">
+ {searchResults.total} results
+ </span>
+ </div>
+ <StickerGrid
+ icons={iconsToDisplay}
+ onAdd={handleAddSticker}
+ addingSticker={addingSticker}
+ />
+ </>
+ ) : searchQuery ? (
+ <EmptyView
+ message={`No stickers found for "${searchQuery}"`}
+ />
+ ) : null}
+ </div>
+ )}
+
+ {viewMode === "browse" && !selectedCollection && (
+ <div className="space-y-4 h-full">
+ {isLoadingCollections ? (
+ <div className="flex items-center justify-center py-8">
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
+ </div>
+ ) : (
+ <>
+ {category !== "all" && (
+ <div className="h-full">
+ <h3 className="text-sm font-medium mb-2">
+ Popular{" "}
+ {category === "general"
+ ? "Icon Sets"
+ : category === "brands"
+ ? "Brand Icons"
+ : "Emoji Sets"}
+ </h3>
+ <CollectionGrid
+ collections={filteredCollections}
+ onSelectCollection={setSelectedCollection}
+ />
+ </div>
+ )}
+
+ {category === "all" && filteredCollections.length > 0 && (
+ <div className="h-full">
+ <CollectionGrid
+ collections={filteredCollections.slice(
+ 0,
+ collectionsToShow
+ )}
+ onSelectCollection={setSelectedCollection}
+ />
+ </div>
+ )}
+ </>
+ )}
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+ </div>
+ );
+}
+
+interface CollectionItemProps {
+ title: string;
+ subtitle: string;
+ onClick: () => void;
+}
+
+function CollectionItem({ title, subtitle, onClick }: CollectionItemProps) {
+ return (
+ <Button
+ variant="outline"
+ className="justify-between h-auto py-2 "
+ onClick={onClick}
+ >
+ <div className="text-left">
+ <p className="font-medium">{title}</p>
+ <p className="text-xs text-muted-foreground">{subtitle}</p>
+ </div>
+ <ArrowRight className="h-4 w-4" />
+ </Button>
+ );
+}
+
+interface StickerItemProps {
+ iconName: string;
+ onAdd: (iconName: string) => void;
+ isAdding?: boolean;
+}
+
+function StickerItem({ iconName, onAdd, isAdding }: StickerItemProps) {
+ const [imageError, setImageError] = useState(false);
+ const [hostIndex, setHostIndex] = useState(0);
+
+ useEffect(() => {
+ setImageError(false);
+ setHostIndex(0);
+ }, [iconName]);
+
+ const displayName = iconName.split(":")[1] || iconName;
+ const collectionPrefix = iconName.split(":")[0];
+
+ const preview = imageError ? (
+ <div className="w-full h-full flex items-center justify-center p-2">
+ <span className="text-xs text-muted-foreground text-center break-all">
+ {displayName}
+ </span>
+ </div>
+ ) : (
+ <div className="w-full h-full p-4 flex items-center justify-center">
+ <Image
+ src={
+ hostIndex === 0
+ ? getIconSvgUrl(iconName, { width: 64, height: 64 })
+ : buildIconSvgUrl(
+ ICONIFY_HOSTS[Math.min(hostIndex, ICONIFY_HOSTS.length - 1)],
+ iconName,
+ { width: 64, height: 64 }
+ )
+ }
+ alt={displayName}
+ width={64}
+ height={64}
+ className="w-full h-full object-contain"
+ onError={() => {
+ const next = hostIndex + 1;
+ if (next < ICONIFY_HOSTS.length) {
+ setHostIndex(next);
+ } else {
+ setImageError(true);
+ }
+ }}
+ loading="lazy"
+ unoptimized
+ />
+ </div>
+ );
+
+ return (
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <div
+ className={cn(
+ "relative",
+ isAdding && "opacity-50 pointer-events-none"
+ )}
+ >
+ <DraggableMediaItem
+ name={displayName}
+ preview={preview}
+ dragData={{
+ type: "sticker",
+ iconName: iconName,
+ name: displayName,
+ }}
+ onAddToTimeline={() => onAdd(iconName)}
+ aspectRatio={1}
+ showLabel={false}
+ rounded={true}
+ variant="card"
+ className=""
+ isDraggable={false}
+ />
+ {isAdding && (
+ <div className="absolute inset-0 bg-black/60 flex items-center justify-center rounded-md z-10">
+ <Loader2 className="h-6 w-6 animate-spin text-white" />
+ </div>
+ )}
+ </div>
+ </TooltipTrigger>
+ <TooltipContent>
+ <div className="space-y-1">
+ <p className="font-medium">{displayName}</p>
+ <p className="text-xs text-muted-foreground">{collectionPrefix}</p>
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ );
+}