Merge branch 'staging' of https://github.com/mazeincoding/AppCut into staging
This commit is contained in:
commit
7f91c9ca00
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 { 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...
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ export interface MediaFileData {
|
|||
width?: number;
|
||||
height?: number;
|
||||
duration?: number;
|
||||
ephemeral?: boolean;
|
||||
sourceStickerIconName?: string;
|
||||
// File will be stored separately in OPFS
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: [] }),
|
||||
}));
|
||||
Loading…
Reference in New Issue