mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Merge branch 'staging' of https://github.com/mazeincoding/AppCut into staging
This commit is contained in:
@@ -32,7 +32,15 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
protocol: "https",
|
protocol: "https",
|
||||||
hostname: "res.cloudinary.com",
|
hostname: "api.iconify.design",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "api.simplesvg.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "api.unisvg.com",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { MediaView } from "./views/media";
|
|||||||
import { useMediaPanelStore, Tab } from "./store";
|
import { useMediaPanelStore, Tab } from "./store";
|
||||||
import { TextView } from "./views/text";
|
import { TextView } from "./views/text";
|
||||||
import { SoundsView } from "./views/sounds";
|
import { SoundsView } from "./views/sounds";
|
||||||
|
import { StickersView } from "./views/stickers";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { SettingsView } from "./views/settings";
|
import { SettingsView } from "./views/settings";
|
||||||
import { Captions } from "./views/captions";
|
import { Captions } from "./views/captions";
|
||||||
@@ -16,11 +17,7 @@ export function MediaPanel() {
|
|||||||
media: <MediaView />,
|
media: <MediaView />,
|
||||||
sounds: <SoundsView />,
|
sounds: <SoundsView />,
|
||||||
text: <TextView />,
|
text: <TextView />,
|
||||||
stickers: (
|
stickers: <StickersView />,
|
||||||
<div className="p-4 text-muted-foreground">
|
|
||||||
Stickers view coming soon...
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
effects: (
|
effects: (
|
||||||
<div className="p-4 text-muted-foreground">
|
<div className="p-4 text-muted-foreground">
|
||||||
Effects view coming soon...
|
Effects view coming soon...
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ export function MediaView() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let filtered = mediaItems.filter((item) => {
|
let filtered = mediaItems.filter((item) => {
|
||||||
|
if (item.ephemeral) return false;
|
||||||
if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
|
if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Input } from "@/components/ui/input";
|
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 { Separator } from "@/components/ui/separator";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
|
||||||
|
|
||||||
export function SoundsView() {
|
export function SoundsView() {
|
||||||
return (
|
return (
|
||||||
@@ -96,35 +97,30 @@ function SoundEffectsView() {
|
|||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
// Scroll position persistence
|
const { scrollAreaRef, handleScroll } = useInfiniteScroll({
|
||||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
onLoadMore: loadMore,
|
||||||
|
hasMore: hasNextPage,
|
||||||
|
isLoading: isLoadingMore || isSearching,
|
||||||
|
});
|
||||||
|
|
||||||
// Load saved sounds and restore scroll position when component mounts
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSavedSounds();
|
loadSavedSounds();
|
||||||
|
|
||||||
if (scrollAreaRef.current && scrollPosition > 0) {
|
if (scrollAreaRef.current && scrollPosition > 0) {
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
scrollAreaRef.current?.scrollTo({ top: scrollPosition });
|
scrollAreaRef.current?.scrollTo({ top: scrollPosition });
|
||||||
}, 100); // Small delay to ensure content is rendered
|
}, 100);
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
}, []); // Only run on mount
|
}, []);
|
||||||
|
|
||||||
// Track scroll position changes and handle infinite scroll
|
const handleScrollWithPosition = (event: React.UIEvent<HTMLDivElement>) => {
|
||||||
const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
|
const { scrollTop } = event.currentTarget;
|
||||||
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
|
|
||||||
setScrollPosition(scrollTop);
|
setScrollPosition(scrollTop);
|
||||||
|
handleScroll(event);
|
||||||
// Trigger loadMore when scrolled to within 200px of bottom
|
|
||||||
const nearBottom = scrollTop + clientHeight >= scrollHeight - 200;
|
|
||||||
if (nearBottom && hasNextPage && !isLoadingMore && !isSearching) {
|
|
||||||
loadMore();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use your existing design, just swap the data source
|
|
||||||
const displayedSounds = useMemo(() => {
|
const displayedSounds = useMemo(() => {
|
||||||
const sounds = searchQuery ? searchResults : topSoundEffects;
|
const sounds = searchQuery ? searchResults : topSoundEffects;
|
||||||
return sounds;
|
return sounds;
|
||||||
@@ -199,7 +195,7 @@ function SoundEffectsView() {
|
|||||||
<ScrollArea
|
<ScrollArea
|
||||||
className="flex-1 h-full"
|
className="flex-1 h-full"
|
||||||
ref={scrollAreaRef}
|
ref={scrollAreaRef}
|
||||||
onScrollCapture={handleScroll}
|
onScrollCapture={handleScrollWithPosition}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{isLoading && !searchQuery && (
|
{isLoading && !searchQuery && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,8 +22,8 @@ import {
|
|||||||
ZoomOut,
|
ZoomOut,
|
||||||
Bookmark,
|
Bookmark,
|
||||||
Eye,
|
Eye,
|
||||||
MicOff,
|
VolumeOff,
|
||||||
Mic,
|
Volume2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -41,8 +41,10 @@ import { useTimelineStore } from "@/stores/timeline-store";
|
|||||||
import { useMediaStore } from "@/stores/media-store";
|
import { useMediaStore } from "@/stores/media-store";
|
||||||
import { usePlaybackStore } from "@/stores/playback-store";
|
import { usePlaybackStore } from "@/stores/playback-store";
|
||||||
import { useProjectStore } from "@/stores/project-store";
|
import { useProjectStore } from "@/stores/project-store";
|
||||||
|
|
||||||
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
|
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
|
||||||
import { processMediaFiles } from "@/lib/media-processing";
|
import { processMediaFiles } from "@/lib/media-processing";
|
||||||
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { TimelineTrackContent } from "./timeline-track";
|
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">
|
<div className="flex items-center justify-end flex-1 min-w-0 gap-2">
|
||||||
{track.muted ? (
|
{track.muted ? (
|
||||||
<MicOff
|
<VolumeOff
|
||||||
className="h-4 w-4 text-destructive cursor-pointer"
|
className="h-4 w-4 text-destructive cursor-pointer"
|
||||||
onClick={() => toggleTrackMute(track.id)}
|
onClick={() => toggleTrackMute(track.id)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Mic
|
<Volume2
|
||||||
className="h-4 w-4 text-muted-foreground cursor-pointer"
|
className="h-4 w-4 text-muted-foreground cursor-pointer"
|
||||||
onClick={() => toggleTrackMute(track.id)}
|
onClick={() => toggleTrackMute(track.id)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const buttonVariants = cva(
|
|||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
|
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
|
||||||
outline:
|
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:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
"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)
|
text: "bg-transparent p-0 rounded-none opacity-100 hover:opacity-50 transition-opacity", // Instead of ghost (matches app better)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface DraggableMediaItemProps {
|
|||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
rounded?: boolean;
|
rounded?: boolean;
|
||||||
variant?: "card" | "compact";
|
variant?: "card" | "compact";
|
||||||
|
isDraggable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DraggableMediaItem({
|
export function DraggableMediaItem({
|
||||||
@@ -39,11 +40,14 @@ export function DraggableMediaItem({
|
|||||||
showLabel = true,
|
showLabel = true,
|
||||||
rounded = true,
|
rounded = true,
|
||||||
variant = "card",
|
variant = "card",
|
||||||
|
isDraggable = true,
|
||||||
}: DraggableMediaItemProps) {
|
}: DraggableMediaItemProps) {
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
||||||
const dragRef = useRef<HTMLDivElement>(null);
|
const dragRef = useRef<HTMLDivElement>(null);
|
||||||
const currentTime = usePlaybackStore((state) => state.currentTime);
|
const currentTime = isDraggable
|
||||||
|
? usePlaybackStore((state) => state.currentTime)
|
||||||
|
: 0;
|
||||||
|
|
||||||
const handleAddToTimeline = () => {
|
const handleAddToTimeline = () => {
|
||||||
onAddToTimeline?.(currentTime);
|
onAddToTimeline?.(currentTime);
|
||||||
@@ -100,11 +104,11 @@ export function DraggableMediaItem({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"bg-panel-accent relative overflow-hidden",
|
"bg-panel-accent relative overflow-hidden",
|
||||||
rounded && "rounded-md",
|
rounded && "rounded-md",
|
||||||
"[&::-webkit-drag-ghost]:opacity-0" // Webkit-specific ghost hiding
|
isDraggable && "[&::-webkit-drag-ghost]:opacity-0" // Webkit-specific ghost hiding
|
||||||
)}
|
)}
|
||||||
draggable={true}
|
draggable={isDraggable}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={isDraggable ? handleDragStart : undefined}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={isDraggable ? handleDragEnd : undefined}
|
||||||
>
|
>
|
||||||
{preview}
|
{preview}
|
||||||
{!isDragging && (
|
{!isDragging && (
|
||||||
@@ -132,12 +136,12 @@ export function DraggableMediaItem({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-10 flex items-center gap-3 cursor-default w-full",
|
"h-10 flex items-center gap-3 cursor-default w-full",
|
||||||
"[&::-webkit-drag-ghost]:opacity-0",
|
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
draggable={true}
|
draggable={isDraggable}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={isDraggable ? handleDragStart : undefined}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={isDraggable ? handleDragEnd : undefined}
|
||||||
>
|
>
|
||||||
<div className="w-6 h-6 flex-shrink-0 rounded overflow-hidden">
|
<div className="w-6 h-6 flex-shrink-0 rounded overflow-hidden">
|
||||||
{preview}
|
{preview}
|
||||||
@@ -148,7 +152,8 @@ export function DraggableMediaItem({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Custom drag preview */}
|
{/* Custom drag preview */}
|
||||||
{isDragging &&
|
{isDraggable &&
|
||||||
|
isDragging &&
|
||||||
typeof document !== "undefined" &&
|
typeof document !== "undefined" &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<div
|
<div
|
||||||
@@ -194,7 +199,7 @@ function PlusButton({
|
|||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import * as React from "react";
|
"use client";
|
||||||
import { Eye, EyeOff, X } from "lucide-react";
|
|
||||||
|
|
||||||
|
import { Eye, EyeOff, X } from "lucide-react";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
import { Button } from "./button";
|
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;
|
showPassword?: boolean;
|
||||||
onShowPasswordChange?: (show: boolean) => void;
|
onShowPasswordChange?: (show: boolean) => void;
|
||||||
showClearIcon?: boolean;
|
showClearIcon?: boolean;
|
||||||
@@ -12,7 +14,7 @@ interface InputProps extends React.ComponentProps<"input"> {
|
|||||||
containerClassName?: string;
|
containerClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
className,
|
className,
|
||||||
@@ -29,7 +31,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
const [isFocused, setIsFocused] = React.useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
|
||||||
const isPassword = type === "password";
|
const isPassword = type === "password";
|
||||||
const showPasswordToggle = isPassword && onShowPasswordChange;
|
const showPasswordToggle = isPassword && onShowPasswordChange;
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -142,6 +142,7 @@ class StorageService {
|
|||||||
width: mediaItem.width,
|
width: mediaItem.width,
|
||||||
height: mediaItem.height,
|
height: mediaItem.height,
|
||||||
duration: mediaItem.duration,
|
duration: mediaItem.duration,
|
||||||
|
ephemeral: mediaItem.ephemeral,
|
||||||
};
|
};
|
||||||
|
|
||||||
await mediaMetadataAdapter.set(mediaItem.id, metadata);
|
await mediaMetadataAdapter.set(mediaItem.id, metadata);
|
||||||
@@ -161,8 +162,22 @@ class StorageService {
|
|||||||
|
|
||||||
if (!file || !metadata) return null;
|
if (!file || !metadata) return null;
|
||||||
|
|
||||||
// Create new object URL for the file
|
let url: string;
|
||||||
const url = URL.createObjectURL(file);
|
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 {
|
return {
|
||||||
id: metadata.id,
|
id: metadata.id,
|
||||||
@@ -173,7 +188,7 @@ class StorageService {
|
|||||||
width: metadata.width,
|
width: metadata.width,
|
||||||
height: metadata.height,
|
height: metadata.height,
|
||||||
duration: metadata.duration,
|
duration: metadata.duration,
|
||||||
// thumbnailUrl would need to be regenerated or cached separately
|
ephemeral: metadata.ephemeral,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export interface MediaFileData {
|
|||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
|
ephemeral?: boolean;
|
||||||
|
sourceStickerIconName?: string;
|
||||||
// File will be stored separately in OPFS
|
// File will be stored separately in OPFS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export interface MediaItem {
|
|||||||
width?: number; // For video/image width
|
width?: number; // For video/image width
|
||||||
height?: number; // For video/image height
|
height?: number; // For video/image height
|
||||||
fps?: number; // For video frame rate
|
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
|
// Text-specific properties
|
||||||
content?: string; // Text content
|
content?: string; // Text content
|
||||||
fontSize?: number; // Font size
|
fontSize?: number; // Font size
|
||||||
|
|||||||
@@ -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: [] }),
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user