diff options
Diffstat (limited to 'apps/web')
| -rw-r--r-- | apps/web/next.config.ts | 10 | ||||
| -rw-r--r-- | apps/web/src/app/animation/page.tsx | 24 | ||||
| -rw-r--r-- | apps/web/src/components/editor/media-panel/index.tsx | 7 | ||||
| -rw-r--r-- | apps/web/src/components/editor/media-panel/views/media.tsx | 1 | ||||
| -rw-r--r-- | apps/web/src/components/editor/media-panel/views/sounds.tsx | 32 | ||||
| -rw-r--r-- | apps/web/src/components/editor/media-panel/views/stickers.tsx | 618 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline/index.tsx | 10 | ||||
| -rw-r--r-- | apps/web/src/components/ui/button.tsx | 2 | ||||
| -rw-r--r-- | apps/web/src/components/ui/draggable-item.tsx | 27 | ||||
| -rw-r--r-- | apps/web/src/components/ui/input-with-back.tsx | 86 | ||||
| -rw-r--r-- | apps/web/src/components/ui/input.tsx | 12 | ||||
| -rw-r--r-- | apps/web/src/hooks/use-infinite-scroll.ts | 35 | ||||
| -rw-r--r-- | apps/web/src/lib/iconify-api.ts | 242 | ||||
| -rw-r--r-- | apps/web/src/lib/storage/storage-service.ts | 21 | ||||
| -rw-r--r-- | apps/web/src/lib/storage/types.ts | 2 | ||||
| -rw-r--r-- | apps/web/src/stores/media-store.ts | 2 | ||||
| -rw-r--r-- | apps/web/src/stores/stickers-store.ts | 178 |
17 files changed, 1261 insertions, 48 deletions
diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 5142905b..cd79efb7 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -32,7 +32,15 @@ const nextConfig: NextConfig = { }, { protocol: "https", - hostname: "res.cloudinary.com", + hostname: "api.iconify.design", + }, + { + protocol: "https", + hostname: "api.simplesvg.com", + }, + { + protocol: "https", + hostname: "api.unisvg.com", }, ], }, diff --git a/apps/web/src/app/animation/page.tsx b/apps/web/src/app/animation/page.tsx new file mode 100644 index 00000000..37be1ef0 --- /dev/null +++ b/apps/web/src/app/animation/page.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { InputWithBack } from "@/components/ui/input-with-back"; +import { useState } from "react"; + +export default function AnimationPage() { + const [isExpanded, setIsExpanded] = useState(false); + + return ( + <div className="h-dvh flex"> + <div className="bg-panel p-6 w-[20rem]"> + <InputWithBack isExpanded={isExpanded} setIsExpanded={setIsExpanded} /> + </div> + <div className="p-6 flex items-end justify-end"> + <p + onClick={() => setIsExpanded(!isExpanded)} + className="cursor-pointer hover:opacity-75 transition-opacity" + > + {isExpanded ? "Collapse" : "Expand"} + </p> + </div> + </div> + ); +} diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx index 21df9924..89b53b64 100644 --- a/apps/web/src/components/editor/media-panel/index.tsx +++ b/apps/web/src/components/editor/media-panel/index.tsx @@ -5,6 +5,7 @@ import { MediaView } from "./views/media"; import { useMediaPanelStore, Tab } from "./store"; import { TextView } from "./views/text"; import { SoundsView } from "./views/sounds"; +import { StickersView } from "./views/stickers"; import { Separator } from "@/components/ui/separator"; import { SettingsView } from "./views/settings"; import { Captions } from "./views/captions"; @@ -16,11 +17,7 @@ export function MediaPanel() { media: <MediaView />, sounds: <SoundsView />, text: <TextView />, - stickers: ( - <div className="p-4 text-muted-foreground"> - Stickers view coming soon... - </div> - ), + stickers: <StickersView />, effects: ( <div className="p-4 text-muted-foreground"> Effects view coming soon... 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> + ); +} diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index d8261d9b..34d38234 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -22,8 +22,8 @@ import { ZoomOut, Bookmark, Eye, - MicOff, - Mic, + VolumeOff, + Volume2, } from "lucide-react"; import { Tooltip, @@ -41,8 +41,10 @@ import { useTimelineStore } from "@/stores/timeline-store"; import { useMediaStore } from "@/stores/media-store"; import { usePlaybackStore } from "@/stores/playback-store"; import { useProjectStore } from "@/stores/project-store"; + import { useTimelineZoom } from "@/hooks/use-timeline-zoom"; import { processMediaFiles } from "@/lib/media-processing"; + import { toast } from "sonner"; import { useState, useRef, useEffect, useCallback } from "react"; import { TimelineTrackContent } from "./timeline-track"; @@ -803,12 +805,12 @@ export function Timeline() { > <div className="flex items-center justify-end flex-1 min-w-0 gap-2"> {track.muted ? ( - <MicOff + <VolumeOff className="h-4 w-4 text-destructive cursor-pointer" onClick={() => toggleTrackMute(track.id)} /> ) : ( - <Mic + <Volume2 className="h-4 w-4 text-muted-foreground cursor-pointer" onClick={() => toggleTrackMute(track.id)} /> diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 5558eee0..9096e415 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -18,7 +18,7 @@ const buttonVariants = cva( destructive: "bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90", outline: - "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", + "border border-input bg-background shadow-xs hover:opacity-75 transition-opacity hover:text-accent-foreground", secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", text: "bg-transparent p-0 rounded-none opacity-100 hover:opacity-50 transition-opacity", // Instead of ghost (matches app better) diff --git a/apps/web/src/components/ui/draggable-item.tsx b/apps/web/src/components/ui/draggable-item.tsx index ff6766fd..92aa677b 100644 --- a/apps/web/src/components/ui/draggable-item.tsx +++ b/apps/web/src/components/ui/draggable-item.tsx @@ -25,6 +25,7 @@ export interface DraggableMediaItemProps { showLabel?: boolean; rounded?: boolean; variant?: "card" | "compact"; + isDraggable?: boolean; } export function DraggableMediaItem({ @@ -39,11 +40,14 @@ export function DraggableMediaItem({ showLabel = true, rounded = true, variant = "card", + isDraggable = true, }: DraggableMediaItemProps) { const [isDragging, setIsDragging] = useState(false); const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); const dragRef = useRef<HTMLDivElement>(null); - const currentTime = usePlaybackStore((state) => state.currentTime); + const currentTime = isDraggable + ? usePlaybackStore((state) => state.currentTime) + : 0; const handleAddToTimeline = () => { onAddToTimeline?.(currentTime); @@ -100,11 +104,11 @@ export function DraggableMediaItem({ className={cn( "bg-panel-accent relative overflow-hidden", rounded && "rounded-md", - "[&::-webkit-drag-ghost]:opacity-0" // Webkit-specific ghost hiding + isDraggable && "[&::-webkit-drag-ghost]:opacity-0" // Webkit-specific ghost hiding )} - draggable={true} - onDragStart={handleDragStart} - onDragEnd={handleDragEnd} + draggable={isDraggable} + onDragStart={isDraggable ? handleDragStart : undefined} + onDragEnd={isDraggable ? handleDragEnd : undefined} > {preview} {!isDragging && ( @@ -132,12 +136,12 @@ export function DraggableMediaItem({ <div className={cn( "h-10 flex items-center gap-3 cursor-default w-full", - "[&::-webkit-drag-ghost]:opacity-0", + isDraggable && "[&::-webkit-drag-ghost]:opacity-0", className )} - draggable={true} - onDragStart={handleDragStart} - onDragEnd={handleDragEnd} + draggable={isDraggable} + onDragStart={isDraggable ? handleDragStart : undefined} + onDragEnd={isDraggable ? handleDragEnd : undefined} > <div className="w-6 h-6 flex-shrink-0 rounded overflow-hidden"> {preview} @@ -148,7 +152,8 @@ export function DraggableMediaItem({ )} {/* Custom drag preview */} - {isDragging && + {isDraggable && + isDragging && typeof document !== "undefined" && createPortal( <div @@ -194,7 +199,7 @@ function PlusButton({ <Button size="icon" className={cn( - "absolute bottom-2 right-2 size-4 bg-background text-foreground", + "absolute bottom-2 right-2 size-4 bg-background hover:bg-panel text-foreground", className )} onClick={(e) => { diff --git a/apps/web/src/components/ui/input-with-back.tsx b/apps/web/src/components/ui/input-with-back.tsx new file mode 100644 index 00000000..ea225c14 --- /dev/null +++ b/apps/web/src/components/ui/input-with-back.tsx @@ -0,0 +1,86 @@ +"use client";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { ArrowLeft, Search } from "lucide-react";
+import { motion } from "motion/react";
+import { useState, useEffect } from "react";
+
+interface InputWithBackProps {
+ isExpanded: boolean;
+ setIsExpanded: (isExpanded: boolean) => void;
+ placeholder?: string;
+ value?: string;
+ onChange?: (value: string) => void;
+}
+
+export function InputWithBack({
+ isExpanded,
+ setIsExpanded,
+ placeholder = "Search anything",
+ value,
+ onChange,
+}: InputWithBackProps) {
+ const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null);
+ const [buttonOffset, setButtonOffset] = useState(-60);
+
+ const smoothTransition = {
+ duration: 0.35,
+ ease: [0.25, 0.1, 0.25, 1] as const,
+ };
+
+ useEffect(() => {
+ if (containerRef) {
+ const rect = containerRef.getBoundingClientRect();
+ setButtonOffset(-rect.left - 48);
+ }
+ }, [containerRef]);
+
+ return (
+ <div ref={setContainerRef} className="relative w-full">
+ <motion.div
+ className="absolute left-0 top-1/2 -translate-y-1/2 cursor-pointer hover:opacity-75 transition-opacity z-10"
+ initial={{
+ x: isExpanded ? 0 : buttonOffset,
+ opacity: isExpanded ? 1 : 0.5,
+ }}
+ animate={{
+ x: isExpanded ? 0 : buttonOffset,
+ opacity: isExpanded ? 1 : 0.5,
+ }}
+ transition={smoothTransition}
+ onClick={() => setIsExpanded(!isExpanded)}
+ >
+ <Button
+ variant="outline"
+ className="!size-9 rounded-full bg-panel-accent"
+ >
+ <ArrowLeft />
+ </Button>
+ </motion.div>
+ <div
+ className="relative flex-1"
+ style={{ marginLeft: "0px", paddingLeft: "0px" }}
+ >
+ <motion.div
+ className="relative"
+ initial={{
+ marginLeft: isExpanded ? 50 : 0,
+ }}
+ animate={{
+ marginLeft: isExpanded ? 50 : 0,
+ }}
+ transition={smoothTransition}
+ >
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder={placeholder}
+ className="pl-9 bg-panel-accent w-full"
+ value={value}
+ onChange={(e) => onChange?.(e.target.value)}
+ />
+ </motion.div>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index 65c7660d..8f195a50 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -1,10 +1,12 @@ -import * as React from "react"; -import { Eye, EyeOff, X } from "lucide-react"; +"use client"; +import { Eye, EyeOff, X } from "lucide-react"; import { cn } from "../../lib/utils"; import { Button } from "./button"; +import { forwardRef, ComponentProps } from "react"; +import { useState } from "react"; -interface InputProps extends React.ComponentProps<"input"> { +interface InputProps extends ComponentProps<"input"> { showPassword?: boolean; onShowPasswordChange?: (show: boolean) => void; showClearIcon?: boolean; @@ -12,7 +14,7 @@ interface InputProps extends React.ComponentProps<"input"> { containerClassName?: string; } -const Input = React.forwardRef<HTMLInputElement, InputProps>( +const Input = forwardRef<HTMLInputElement, InputProps>( ( { className, @@ -29,7 +31,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>( }, ref ) => { - const [isFocused, setIsFocused] = React.useState(false); + const [isFocused, setIsFocused] = useState(false); const isPassword = type === "password"; const showPasswordToggle = isPassword && onShowPasswordChange; diff --git a/apps/web/src/hooks/use-infinite-scroll.ts b/apps/web/src/hooks/use-infinite-scroll.ts new file mode 100644 index 00000000..0f8db198 --- /dev/null +++ b/apps/web/src/hooks/use-infinite-scroll.ts @@ -0,0 +1,35 @@ +import { useRef, useCallback } from "react";
+
+interface UseInfiniteScrollOptions {
+ onLoadMore: () => void;
+ hasMore: boolean;
+ isLoading: boolean;
+ threshold?: number;
+ enabled?: boolean;
+}
+
+export function useInfiniteScroll({
+ onLoadMore,
+ hasMore,
+ isLoading,
+ threshold = 200,
+ enabled = true,
+}: UseInfiniteScrollOptions) {
+ const scrollAreaRef = useRef<HTMLDivElement>(null);
+
+ const handleScroll = useCallback(
+ (event: React.UIEvent<HTMLDivElement>) => {
+ if (!enabled) return;
+
+ const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
+ const nearBottom = scrollTop + clientHeight >= scrollHeight - threshold;
+
+ if (nearBottom && hasMore && !isLoading) {
+ onLoadMore();
+ }
+ },
+ [onLoadMore, hasMore, isLoading, threshold, enabled]
+ );
+
+ return { scrollAreaRef, handleScroll };
+}
diff --git a/apps/web/src/lib/iconify-api.ts b/apps/web/src/lib/iconify-api.ts new file mode 100644 index 00000000..9d1bcb97 --- /dev/null +++ b/apps/web/src/lib/iconify-api.ts @@ -0,0 +1,242 @@ + +export const ICONIFY_HOSTS = [ + "https://api.iconify.design", + "https://api.simplesvg.com", + "https://api.unisvg.com", +]; + +let currentHost = ICONIFY_HOSTS[0]; + +async function fetchWithFallback(path: string): Promise<Response> { + for (const host of ICONIFY_HOSTS) { + try { + const response = await fetch(`${host}${path}`, { + signal: AbortSignal.timeout(2000), + }); + if (response.ok) { + currentHost = host; + return response; + } + } catch (error) { + console.warn(`Failed to fetch from ${host}:`, error); + } + } + throw new Error("All API hosts failed"); +} + +export interface IconSet { + prefix: string; + name: string; + total: number; + author?: { + name: string; + url?: string; + }; + license?: { + title: string; + spdx?: string; + url?: string; + }; + samples?: string[]; + category?: string; + palette?: boolean; +} + +export interface IconSearchResult { + icons: string[]; + total: number; + limit: number; + start: number; + collections: Record<string, IconSet>; +} + +export interface CollectionInfo { + prefix: string; + total: number; + title?: string; + uncategorized?: string[]; + categories?: Record<string, string[]>; + hidden?: string[]; + aliases?: Record<string, string>; +} + +export async function getCollections( + category?: string +): Promise<Record<string, IconSet>> { + try { + const response = await fetchWithFallback("/collections?pretty=1"); + const data = (await response.json()) as Record<string, IconSet>; + + if (category) { + const filtered = Object.fromEntries( + Object.entries(data).filter( + ([_key, info]) => info.category === category + ) + ) as Record<string, IconSet>; + return filtered; + } + + return data; + } catch (error) { + console.error("Failed to fetch collections:", error); + return {}; + } +} + +export async function getCollection( + prefix: string +): Promise<CollectionInfo | null> { + try { + const response = await fetchWithFallback( + `/collection?prefix=${prefix}&pretty=1` + ); + return await response.json(); + } catch (error) { + console.error(`Failed to fetch collection ${prefix}:`, error); + return null; + } +} + +export async function searchIcons( + query: string, + limit: number = 64, + prefixes?: string[], + category?: string +): Promise<IconSearchResult> { + const params = new URLSearchParams({ + query, + limit: limit.toString(), + pretty: "1", + }); + + if (prefixes?.length) { + params.append("prefixes", prefixes.join(",")); + } + + if (category) { + params.append("category", category); + } + + try { + const response = await fetchWithFallback(`/search?${params}`); + return await response.json(); + } catch (error) { + console.error("Failed to search icons:", error); + return { + icons: [], + total: 0, + limit, + start: 0, + collections: {}, + }; + } +} + +export function buildIconSvgUrl( + host: string, + iconName: string, + params?: { + color?: string; + width?: number; + height?: number; + flip?: "horizontal" | "vertical" | "horizontal,vertical"; + rotate?: number | string; + } +): string { + const [prefix, name] = iconName.includes(":") + ? iconName.split(":") + : ["", iconName]; + + if (!prefix || !name) { + throw new Error('Invalid icon name format. Expected "prefix:name"'); + } + + const urlParams = new URLSearchParams(); + + if (params?.color) { + urlParams.append("color", params.color.replace("#", "%23")); + } + + if (params?.width) { + urlParams.append("width", params.width.toString()); + } + + if (params?.height) { + urlParams.append("height", params.height.toString()); + } + + if (params?.flip) { + urlParams.append("flip", params.flip); + } + + if (params?.rotate) { + urlParams.append("rotate", params.rotate.toString()); + } + + const queryString = urlParams.toString(); + return `${host}/${prefix}/${name}.svg${queryString ? "?" + queryString : ""}`; +} + +export function getIconSvgUrl( + iconName: string, + params?: Parameters<typeof buildIconSvgUrl>[2] +): string { + return buildIconSvgUrl(currentHost, iconName, params); +} + +export async function downloadSvgAsText( + iconName: string, + params?: Parameters<typeof getIconSvgUrl>[1] +): Promise<string> { + const url = getIconSvgUrl(iconName, params); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to download SVG: ${response.statusText}`); + } + return await response.text(); +} + +export function svgToFile(svgText: string, fileName: string): File { + const blob = new Blob([svgText], { type: "image/svg+xml" }); + return new File([blob], fileName, { type: "image/svg+xml" }); +} + +export const POPULAR_COLLECTIONS = { + general: [ + { prefix: "mdi", name: "Material Design Icons" }, + { prefix: "ic", name: "Google Material Icons" }, + { prefix: "ph", name: "Phosphor" }, + { prefix: "heroicons", name: "Heroicons" }, + { prefix: "lucide", name: "Lucide" }, + { prefix: "tabler", name: "Tabler Icons" }, + { prefix: "fe", name: "Feather Icons" }, + { prefix: "bi", name: "Bootstrap Icons" }, + ], + brands: [ + { prefix: "simple-icons", name: "Simple Icons" }, + { prefix: "logos", name: "SVG Logos" }, + { prefix: "skill-icons", name: "Skill Icons" }, + { prefix: "devicon", name: "Devicon" }, + { prefix: "fa-brands", name: "Font Awesome Brands" }, + ], + emoji: [ + { prefix: "noto", name: "Noto Emoji" }, + { prefix: "twemoji", name: "Twemoji" }, + { prefix: "fluent-emoji", name: "Fluent Emoji" }, + { prefix: "fluent-emoji-flat", name: "Fluent Emoji Flat" }, + { prefix: "emojione", name: "EmojiOne" }, + { prefix: "openmoji", name: "OpenMoji" }, + ], +}; + +export function getCategoriesFromCollections( + collections: Record<string, IconSet> +): string[] { + const categories = new Set<string>(); + Object.values(collections).forEach((collection) => { + if (collection.category) { + categories.add(collection.category); + } + }); + return Array.from(categories).sort(); +} diff --git a/apps/web/src/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts index 00d60c47..b3376a5f 100644 --- a/apps/web/src/lib/storage/storage-service.ts +++ b/apps/web/src/lib/storage/storage-service.ts @@ -142,6 +142,7 @@ class StorageService { width: mediaItem.width, height: mediaItem.height, duration: mediaItem.duration, + ephemeral: mediaItem.ephemeral, }; await mediaMetadataAdapter.set(mediaItem.id, metadata); @@ -161,8 +162,22 @@ class StorageService { if (!file || !metadata) return null; - // Create new object URL for the file - const url = URL.createObjectURL(file); + let url: string; + if (metadata.type === "image" && (!file.type || file.type === "")) { + try { + const text = await file.text(); + if (text.trim().startsWith("<svg")) { + const svgBlob = new Blob([text], { type: "image/svg+xml" }); + url = URL.createObjectURL(svgBlob); + } else { + url = URL.createObjectURL(file); + } + } catch { + url = URL.createObjectURL(file); + } + } else { + url = URL.createObjectURL(file); + } return { id: metadata.id, @@ -173,7 +188,7 @@ class StorageService { width: metadata.width, height: metadata.height, duration: metadata.duration, - // thumbnailUrl would need to be regenerated or cached separately + ephemeral: metadata.ephemeral, }; } diff --git a/apps/web/src/lib/storage/types.ts b/apps/web/src/lib/storage/types.ts index 127b1690..8f593867 100644 --- a/apps/web/src/lib/storage/types.ts +++ b/apps/web/src/lib/storage/types.ts @@ -18,6 +18,8 @@ export interface MediaFileData { width?: number; height?: number; duration?: number; + ephemeral?: boolean; + sourceStickerIconName?: string; // File will be stored separately in OPFS } diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts index 650c0540..95676d75 100644 --- a/apps/web/src/stores/media-store.ts +++ b/apps/web/src/stores/media-store.ts @@ -16,6 +16,8 @@ export interface MediaItem { width?: number; // For video/image width height?: number; // For video/image height fps?: number; // For video frame rate + // Ephemeral items are used by timeline directly and should not appear in the media library or be persisted + ephemeral?: boolean; // Text-specific properties content?: string; // Text content fontSize?: number; // Font size diff --git a/apps/web/src/stores/stickers-store.ts b/apps/web/src/stores/stickers-store.ts new file mode 100644 index 00000000..da8b926b --- /dev/null +++ b/apps/web/src/stores/stickers-store.ts @@ -0,0 +1,178 @@ +import { create } from "zustand"; +import { + getCollections, + getCollection, + searchIcons, + downloadSvgAsText, + svgToFile, + type IconSet, + type CollectionInfo, + type IconSearchResult, +} from "@/lib/iconify-api"; + +export type StickerCategory = "all" | "general" | "brands" | "emoji"; + +interface StickersStore { + searchQuery: string; + selectedCategory: StickerCategory; + selectedCollection: string | null; + viewMode: "search" | "browse" | "collection"; + + collections: Record<string, IconSet>; + currentCollection: CollectionInfo | null; + searchResults: IconSearchResult | null; + recentStickers: string[]; + isLoadingCollections: boolean; + isLoadingCollection: boolean; + isSearching: boolean; + isDownloading: boolean; + + setSearchQuery: (query: string) => void; + setSelectedCategory: (category: StickerCategory) => void; + setSelectedCollection: (collection: string | null) => void; + setViewMode: (mode: "search" | "browse" | "collection") => void; + + loadCollections: () => Promise<void>; + loadCollection: (prefix: string) => Promise<void>; + searchStickers: (query: string) => Promise<void>; + downloadSticker: (iconName: string) => Promise<File | null>; + + addToRecentStickers: (iconName: string) => void; + clearRecentStickers: () => void; +} + +const MAX_RECENT_STICKERS = 50; + +export const useStickersStore = create<StickersStore>((set, get) => ({ + searchQuery: "", + selectedCategory: "all", + selectedCollection: null, + viewMode: "browse", + + collections: {}, + currentCollection: null, + searchResults: null, + recentStickers: [], + + isLoadingCollections: false, + isLoadingCollection: false, + isSearching: false, + isDownloading: false, + + setSearchQuery: (query) => set({ searchQuery: query }), + + setSelectedCategory: (category) => + set({ + selectedCategory: category, + viewMode: "browse", + selectedCollection: null, + currentCollection: null, + }), + + setSelectedCollection: (collection) => { + set({ + selectedCollection: collection, + viewMode: collection ? "collection" : "browse", + currentCollection: null, + }); + + if (collection) { + get().loadCollection(collection); + } + }, + + setViewMode: (mode) => set({ viewMode: mode }), + + loadCollections: async () => { + set({ isLoadingCollections: true }); + try { + const collections = await getCollections(); + set({ collections }); + } catch (error) { + console.error("Failed to load collections:", error); + } finally { + set({ isLoadingCollections: false }); + } + }, + + loadCollection: async (prefix: string) => { + set({ isLoadingCollection: true }); + try { + const collection = await getCollection(prefix); + set({ currentCollection: collection }); + } catch (error) { + console.error(`Failed to load collection ${prefix}:`, error); + set({ currentCollection: null }); + } finally { + set({ isLoadingCollection: false }); + } + }, + + searchStickers: async (query: string) => { + if (!query.trim()) { + set({ searchResults: null, viewMode: "browse" }); + return; + } + + const { selectedCategory } = get(); + + set({ isSearching: true, viewMode: "search" }); + try { + let category: string | undefined; + + if (selectedCategory !== "all") { + if (selectedCategory === "general") { + category = "General"; + } else if (selectedCategory === "brands") { + category = "Brands / Social"; + } else if (selectedCategory === "emoji") { + category = "Emoji"; + } + } + + const results = await searchIcons(query, 100, undefined, category); + set({ searchResults: results }); + } catch (error) { + console.error("Search failed:", error); + set({ searchResults: null }); + } finally { + set({ isSearching: false }); + } + }, + + downloadSticker: async (iconName: string) => { + set({ isDownloading: true }); + try { + const svgText = await downloadSvgAsText(iconName, { + width: 200, + height: 200, + }); + + const fileName = `${iconName.replace(":", "-")}.svg`; + const file = svgToFile(svgText, fileName); + + get().addToRecentStickers(iconName); + + return file; + } catch (error) { + console.error(`Failed to download sticker ${iconName}:`, error); + return null; + } finally { + set({ isDownloading: false }); + } + }, + + addToRecentStickers: (iconName: string) => { + set((state) => { + const recent = [ + iconName, + ...state.recentStickers.filter((s) => s !== iconName), + ]; + return { + recentStickers: recent.slice(0, MAX_RECENT_STICKERS), + }; + }); + }, + + clearRecentStickers: () => set({ recentStickers: [] }), +})); |
