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%
StockImagesPanel.tsx 347 lines (12 KB)
'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