Merge branch 'staging' of https://github.com/mazeincoding/AppCut into staging

This commit is contained in:
Maze Winther 2025-08-15 02:12:16 +02:00
commit 7f91c9ca00
17 changed files with 1261 additions and 48 deletions

View File

@ -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",
},
],
},

View File

@ -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>
);
}

View File

@ -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...

View File

@ -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;
}

View File

@ -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 && (

View File

@ -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>
);
}

View File

@ -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)}
/>

View File

@ -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)

View File

@ -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) => {

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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 };
}

View File

@ -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();
}

View File

@ -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,
};
}

View File

@ -18,6 +18,8 @@ export interface MediaFileData {
width?: number;
height?: number;
duration?: number;
ephemeral?: boolean;
sourceStickerIconName?: string;
// File will be stored separately in OPFS
}

View File

@ -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

View File

@ -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: [] }),
}));