From defff2fc46978072f2ead822cf8bbd34f7629192 Mon Sep 17 00:00:00 2001 From: vadi25 <57663552+vadi25@users.noreply.github.com> Date: Thu, 14 Aug 2025 14:30:22 +0200 Subject: [PATCH 1/2] fix: changed mute track icon to more understandable ones (#550) --- apps/web/src/components/editor/timeline/index.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index d8261d9b..c48075d1 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, @@ -803,12 +803,12 @@ export function Timeline() { >
{track.muted ? ( - toggleTrackMute(track.id)} /> ) : ( - toggleTrackMute(track.id)} /> From c3f3345d7b1ac820b411a5adbca3172378642953 Mon Sep 17 00:00:00 2001 From: enkei64 Date: Fri, 15 Aug 2025 10:11:21 +1000 Subject: [PATCH 2/2] feat: stickers panel (#539) * Stickers panel base complete * Improve dark mode stickers background for visibility * Prevent stickers from being too small or too big * Improve UI, added credit for Iconify API * Allow manually loading more collections * Add a maximum width of 200px so stickers aren't too big * cleanup * style: hover state of button * refactor: input component * fix: mark input component as client * so much stuff --------- Co-authored-by: Maze Winther --- apps/web/next.config.ts | 10 +- apps/web/src/app/animation/page.tsx | 24 + .../components/editor/media-panel/index.tsx | 7 +- .../editor/media-panel/views/media.tsx | 1 + .../editor/media-panel/views/sounds.tsx | 32 +- .../editor/media-panel/views/stickers.tsx | 618 ++++++++++++++++++ .../src/components/editor/timeline/index.tsx | 2 + apps/web/src/components/ui/button.tsx | 2 +- apps/web/src/components/ui/draggable-item.tsx | 27 +- .../web/src/components/ui/input-with-back.tsx | 86 +++ apps/web/src/components/ui/input.tsx | 12 +- apps/web/src/hooks/use-infinite-scroll.ts | 35 + apps/web/src/lib/iconify-api.ts | 242 +++++++ apps/web/src/lib/storage/storage-service.ts | 21 +- apps/web/src/lib/storage/types.ts | 2 + apps/web/src/stores/media-store.ts | 2 + apps/web/src/stores/stickers-store.ts | 178 +++++ 17 files changed, 1257 insertions(+), 44 deletions(-) create mode 100644 apps/web/src/app/animation/page.tsx create mode 100644 apps/web/src/components/editor/media-panel/views/stickers.tsx create mode 100644 apps/web/src/components/ui/input-with-back.tsx create mode 100644 apps/web/src/hooks/use-infinite-scroll.ts create mode 100644 apps/web/src/lib/iconify-api.ts create mode 100644 apps/web/src/stores/stickers-store.ts 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 ( +
+
+ +
+
+

setIsExpanded(!isExpanded)} + className="cursor-pointer hover:opacity-75 transition-opacity" + > + {isExpanded ? "Collapse" : "Expand"} +

+
+
+ ); +} 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: , sounds: , text: , - stickers: ( -
- Stickers view coming soon... -
- ), + stickers: , effects: (
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(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) => { - const { scrollTop, scrollHeight, clientHeight } = event.currentTarget; + const handleScrollWithPosition = (event: React.UIEvent) => { + 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() {
{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 ( +
+ { + if (["all", "general", "brands", "emoji"].includes(v)) { + setSelectedCategory(v as StickerCategory); + } + }} + className="flex flex-col h-full" + > +
+ + + + All + + + + Icons + + + + Brands + + + + Emoji + + +
+ + + + + + + + + + + + + +
+
+ ); +} + +function StickerGrid({ + icons, + onAdd, + addingSticker, +}: { + icons: string[]; + onAdd: (iconName: string) => void; + addingSticker: string | null; +}) { + return ( +
+ {icons.map((iconName) => ( + + ))} +
+ ); +} + +function CollectionGrid({ + collections, + onSelectCollection, +}: { + collections: Array<{ + prefix: string; + name: string; + total: number; + category?: string; + }>; + onSelectCollection: (prefix: string) => void; +}) { + return ( +
+ {collections.map((collection) => ( + onSelectCollection(collection.prefix)} + /> + ))} +
+ ); +} + +function EmptyView({ message }: { message: string }) { + return ( +
+ +
+

No stickers found

+

{message}

+
+
+ ); +} + +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(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 ( +
+
+ { + if (!expanded && isInCollection) { + setSelectedCollection(null); + } + }} + placeholder="Search icons..." + value={localSearchQuery} + onChange={setLocalSearchQuery} + /> +
+ +
+ +
+ {recentStickers.length > 0 && viewMode === "browse" && ( +
+
+ + Recent + + + + + + +

Clear recent stickers

+
+
+
+
+ +
+ )} + + {viewMode === "collection" && selectedCollection && ( +
+ {isLoadingCollection ? ( +
+ +
+ ) : showCollectionItems ? ( + + ) : ( +
+ +
+ )} +
+ )} + + {viewMode === "search" && ( +
+ {isSearching ? ( +
+ +
+ ) : searchResults?.icons.length ? ( + <> +
+ + {searchResults.total} results + +
+ + + ) : searchQuery ? ( + + ) : null} +
+ )} + + {viewMode === "browse" && !selectedCollection && ( +
+ {isLoadingCollections ? ( +
+ +
+ ) : ( + <> + {category !== "all" && ( +
+

+ Popular{" "} + {category === "general" + ? "Icon Sets" + : category === "brands" + ? "Brand Icons" + : "Emoji Sets"} +

+ +
+ )} + + {category === "all" && filteredCollections.length > 0 && ( +
+ +
+ )} + + )} +
+ )} +
+
+
+
+ ); +} + +interface CollectionItemProps { + title: string; + subtitle: string; + onClick: () => void; +} + +function CollectionItem({ title, subtitle, onClick }: CollectionItemProps) { + return ( + + ); +} + +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 ? ( +
+ + {displayName} + +
+ ) : ( +
+ {displayName} { + const next = hostIndex + 1; + if (next < ICONIFY_HOSTS.length) { + setHostIndex(next); + } else { + setImageError(true); + } + }} + loading="lazy" + unoptimized + /> +
+ ); + + return ( + + +
+ onAdd(iconName)} + aspectRatio={1} + showLabel={false} + rounded={true} + variant="card" + className="" + isDraggable={false} + /> + {isAdding && ( +
+ +
+ )} +
+
+ +
+

{displayName}

+

{collectionPrefix}

+
+
+
+ ); +} diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index c48075d1..34d38234 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -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"; 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(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({
{preview} @@ -148,7 +152,8 @@ export function DraggableMediaItem({ )} {/* Custom drag preview */} - {isDragging && + {isDraggable && + isDragging && typeof document !== "undefined" && createPortal(
{ 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(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 ( +
+ setIsExpanded(!isExpanded)} + > + + +
+ + + onChange?.(e.target.value)} + /> + +
+
+ ); +} 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( +const Input = forwardRef( ( { className, @@ -29,7 +31,7 @@ const Input = React.forwardRef( }, 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(null); + + const handleScroll = useCallback( + (event: React.UIEvent) => { + 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 { + 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; +} + +export interface CollectionInfo { + prefix: string; + total: number; + title?: string; + uncategorized?: string[]; + categories?: Record; + hidden?: string[]; + aliases?: Record; +} + +export async function getCollections( + category?: string +): Promise> { + try { + const response = await fetchWithFallback("/collections?pretty=1"); + const data = (await response.json()) as Record; + + if (category) { + const filtered = Object.fromEntries( + Object.entries(data).filter( + ([_key, info]) => info.category === category + ) + ) as Record; + return filtered; + } + + return data; + } catch (error) { + console.error("Failed to fetch collections:", error); + return {}; + } +} + +export async function getCollection( + prefix: string +): Promise { + 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 { + 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[2] +): string { + return buildIconSvgUrl(currentHost, iconName, params); +} + +export async function downloadSvgAsText( + iconName: string, + params?: Parameters[1] +): Promise { + 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[] { + const categories = new Set(); + 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("; + 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; + loadCollection: (prefix: string) => Promise; + searchStickers: (query: string) => Promise; + downloadSticker: (iconName: string) => Promise; + + addToRecentStickers: (iconName: string) => void; + clearRecentStickers: () => void; +} + +const MAX_RECENT_STICKERS = 50; + +export const useStickersStore = create((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: [] }), +}));