A web app to help you design things, local, offline, on device. In your browser.
TypeScript
53.1%
JSON
45.6%
CSS
0.9%
Markdown
0.3%
JavaScript
0.1%
'use client';
import React, { useState, useCallback } from 'react';
import { Search, Image as ImageIcon, Loader2, ExternalLink, AlertCircle } from 'lucide-react';
import { useEditorStore } from '@/lib/store/editor-store';
import { searchPexelsPhotos, getCuratedPhotos, PEXELS_CATEGORIES, type PexelsPhoto } from '@/lib/api/pexels';
import { searchUnsplashPhotos, UNSPLASH_COLLECTIONS, trackDownload, type UnsplashPhoto } from '@/lib/api/unsplash';
import type { ImageLayerData } from '@/types';
type ImageSource = 'pexels' | 'unsplash';
interface StockImage {
id: string;
src: string;
thumb: string;
width: number;
height: number;
photographer: string;
photographerUrl: string;
alt: string;
color: string;
source: ImageSource;
downloadUrl?: string;
}
export function StockImagesPanel() {
const [searchQuery, setSearchQuery] = useState('');
const [activeSource, setActiveSource] = useState<ImageSource>('pexels');
const [images, setImages] = useState<StockImage[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const { addLayer, canvas } = useEditorStore();
const normalizeImage = useCallback((photo: PexelsPhoto | UnsplashPhoto, source: ImageSource): StockImage => {
if (source === 'pexels') {
const p = photo as PexelsPhoto;
return {
id: `pexels-${p.id}`,
src: p.src.large2x,
thumb: p.src.medium,
width: p.width,
height: p.height,
photographer: p.photographer,
photographerUrl: p.photographer_url,
alt: p.alt || 'Pexels photo',
color: p.avg_color,
source: 'pexels',
};
} else {
const u = photo as UnsplashPhoto;
return {
id: `unsplash-${u.id}`,
src: u.urls.regular,
thumb: u.urls.small,
width: u.width,
height: u.height,
photographer: u.user.name,
photographerUrl: u.user.links.html,
alt: u.alt_description || u.description || 'Unsplash photo',
color: u.color,
source: 'unsplash',
downloadUrl: u.links.download_location,
};
}
}, []);
const searchImages = useCallback(async (query: string, pageNum: number = 1, append: boolean = false) => {
setLoading(true);
setError(null);
try {
let results: StockImage[] = [];
if (activeSource === 'pexels') {
if (query) {
const response = await searchPexelsPhotos(query, { page: pageNum, per_page: 20 });
results = response.photos.map(p => normalizeImage(p, 'pexels'));
setHasMore(response.photos.length === 20);
} else {
const response = await getCuratedPhotos({ page: pageNum, per_page: 20 });
results = response.photos.map(p => normalizeImage(p, 'pexels'));
setHasMore(response.photos.length === 20);
}
} else {
if (query) {
const response = await searchUnsplashPhotos(query, { page: pageNum, per_page: 20 });
results = response.results.map(p => normalizeImage(p, 'unsplash'));
setHasMore(pageNum < response.total_pages);
} else {
// Unsplash doesn't have a simple curated endpoint, search for popular
const response = await searchUnsplashPhotos('wallpaper', { page: pageNum, per_page: 20 });
results = response.results.map(p => normalizeImage(p, 'unsplash'));
setHasMore(pageNum < response.total_pages);
}
}
setImages(append ? prev => [...prev, ...results] : results);
setPage(pageNum);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load images';
setError(message);
if (!append) setImages([]);
} finally {
setLoading(false);
}
}, [activeSource, normalizeImage]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
searchImages(searchQuery, 1);
};
const handleCategoryClick = (query: string) => {
setSearchQuery(query);
searchImages(query, 1);
};
const handleLoadMore = () => {
searchImages(searchQuery, page + 1, true);
};
const handleImageClick = async (image: StockImage) => {
// Track download for Unsplash (required by their API guidelines)
if (image.source === 'unsplash' && image.downloadUrl) {
trackDownload(image.downloadUrl);
}
// Load the image to get dimensions
const img = new window.Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
// Calculate size to fit in canvas
const maxWidth = canvas.width * 0.6;
const maxHeight = canvas.height * 0.6;
const scale = Math.min(maxWidth / img.width, maxHeight / img.height);
const width = img.width * scale;
const height = img.height * scale;
const data: ImageLayerData = {
type: 'image',
src: image.src,
};
addLayer({
type: 'image',
name: `Stock: ${image.alt.slice(0, 20)}...`,
visible: true,
locked: false,
opacity: 1,
position: {
x: (canvas.width - width) / 2,
y: (canvas.height - height) / 2,
},
size: { width, height },
rotation: 0,
data,
});
};
img.src = image.src;
};
const categories = activeSource === 'pexels' ? PEXELS_CATEGORIES : UNSPLASH_COLLECTIONS;
return (
<div className="p-4 flex flex-col h-full">
<h2 className="text-lg font-semibold text-white mb-4">Stock Images</h2>
{/* Source toggle */}
<div className="flex gap-2 mb-4">
<button
onClick={() => {
setActiveSource('pexels');
setImages([]);
setError(null);
}}
className={`
flex-1 px-3 py-2 rounded-lg text-sm font-medium transition-colors
${activeSource === 'pexels'
? 'bg-indigo-500 text-white'
: 'bg-neutral-800 text-neutral-400 hover:text-white'
}
`}
>
Pexels
</button>
<button
onClick={() => {
setActiveSource('unsplash');
setImages([]);
setError(null);
}}
className={`
flex-1 px-3 py-2 rounded-lg text-sm font-medium transition-colors
${activeSource === 'unsplash'
? 'bg-indigo-500 text-white'
: 'bg-neutral-800 text-neutral-400 hover:text-white'
}
`}
>
Unsplash
</button>
</div>
{/* Search bar */}
<form onSubmit={handleSearch} className="mb-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search photos..."
className="
w-full pl-10 pr-4 py-2 rounded-lg
bg-neutral-800 border border-neutral-700
text-white text-sm placeholder-neutral-500
focus:outline-none focus:border-indigo-500
"
/>
</div>
</form>
{/* Categories */}
<div className="flex flex-wrap gap-2 mb-4">
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => handleCategoryClick(cat.query)}
className="
px-2 py-1 rounded-full text-xs
bg-neutral-800 text-neutral-400
hover:bg-neutral-700 hover:text-white
transition-colors
"
>
{cat.label}
</button>
))}
</div>
{/* Error message */}
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/30 flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm text-red-400">{error}</p>
<p className="text-xs text-neutral-500 mt-1">
Make sure API keys are set in .env.local
</p>
</div>
</div>
)}
{/* Images grid */}
<div className="flex-1 overflow-y-auto">
{images.length > 0 ? (
<>
<div className="grid grid-cols-2 gap-2">
{images.map((image) => (
<div
key={image.id}
className="relative group aspect-[4/3] rounded-lg overflow-hidden bg-neutral-800 cursor-pointer"
style={{ backgroundColor: image.color }}
onClick={() => handleImageClick(image)}
>
<img
src={image.thumb}
alt={image.alt}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
loading="lazy"
/>
{/* Hover overlay */}
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center p-2">
<ImageIcon className="w-6 h-6 text-white mb-1" />
<span className="text-xs text-white text-center">Click to add</span>
</div>
{/* Photographer credit */}
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/80 to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
<a
href={image.photographerUrl}
target="_blank"
rel="noopener noreferrer"
className="text-[10px] text-white/80 hover:text-white flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
>
{image.photographer}
<ExternalLink className="w-2.5 h-2.5" />
</a>
</div>
</div>
))}
</div>
{/* Load more */}
{hasMore && (
<button
onClick={handleLoadMore}
disabled={loading}
className="
w-full mt-4 py-2 rounded-lg
bg-neutral-800 hover:bg-neutral-700
text-white text-sm
transition-colors disabled:opacity-50
flex items-center justify-center gap-2
"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Loading...
</>
) : (
'Load More'
)}
</button>
)}
</>
) : loading ? (
<div className="flex flex-col items-center justify-center py-12 text-neutral-500">
<Loader2 className="w-8 h-8 animate-spin mb-2" />
<span className="text-sm">Loading images...</span>
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-neutral-500">
<ImageIcon className="w-12 h-12 mb-2 opacity-50" />
<span className="text-sm">Search or browse categories</span>
<span className="text-xs mt-1">Click a category or search above</span>
</div>
)}
</div>
{/* Attribution notice */}
<div className="mt-4 p-2 rounded-lg bg-neutral-800/50 border border-neutral-700/50">
<p className="text-[10px] text-neutral-500 text-center">
Photos from {activeSource === 'pexels' ? 'Pexels' : 'Unsplash'}. Free for commercial use.
</p>
</div>
</div>
);
}
About
A web app to help you design things, local, offline, on device. In your browser.
0 stars
0 forks