Ctrl K
ComunidadGuías…

components/ImageSection.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { getImageUrlOrFile } from '@/utils/image';
import {
  TrashIcon,
  PhotoIcon,
  XMarkIcon,
  ChevronLeftIcon,
  ChevronRightIcon,
  MagnifyingGlassIcon,
} from '@heroicons/react/24/outline';
import Image from 'next/image';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useState, useEffect } from 'react';
import Loader from '@/components/Loader';
import { cn } from '@/lib/utils';
import { useTranslations } from 'next-intl';

type UnsplashImage = {
  id: string;
  description: string | null;
  user: { name: string; username: string };
  urls: { small: string; regular: string; thumb: string };
  links: { download: string };
};

type ImageSectionProps = {
  currentImageUrl?: string | null;
  onImageFileChange: (file?: File) => void;
  onImageUrlChange: (url: string) => void;
  onDeleteImage?: () => Promise<void>;
  isUnsplashAllowed?: boolean;
  altText: string;
};

export default function ImageSection({
  currentImageUrl,
  onImageFileChange,
  onImageUrlChange,
  onDeleteImage,
  isUnsplashAllowed = false,
  altText,
}: ImageSectionProps) {
  const t = useTranslations('ImageSection');
  const [previewUrl, setPreviewUrl] = useState<string | null>(null);
  const [selectedFileName, setSelectedFileName] = useState<string | null>(null);
  const [isUnsplashMode, setIsUnsplashMode] = useState(false);
  const [isDeleting, setIsDeleting] = useState(false);

  // Unsplash States
  const [images, setImages] = useState<UnsplashImage[]>([]);
  const [loading, setLoading] = useState<boolean>(false);
  const [keyword, setKeyword] = useState<string>('');
  const [page, setPage] = useState<number>(1);
  const [error, setError] = useState<string | null>(null);
  const [debounceTimer, setDebounceTimer] = useState<NodeJS.Timeout | null>(
    null,
  );

  useEffect(() => {
    if (isDeleting && !currentImageUrl && !previewUrl) {
      setIsDeleting(false);
    }
  }, [currentImageUrl, previewUrl, isDeleting]);

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    onImageFileChange(file);
    if (file) {
      setSelectedFileName(file.name);
      const url = URL.createObjectURL(file);
      setPreviewUrl(url);
      onImageUrlChange('');
      setIsUnsplashMode(false);
    } else {
      setSelectedFileName(null);
      setPreviewUrl(null);
    }
  };

  const handleUnsplashSelect = (url: string) => {
    onImageUrlChange(url);
    if (url) {
      setPreviewUrl(url);
      onImageFileChange(undefined);
      setSelectedFileName(null);
    } else {
      setPreviewUrl(null);
    }
    setIsUnsplashMode(false);
  };

  const debounceSearch = (
    keyword: string,
    page: number,
    time: number = 1000,
  ) => {
    if (debounceTimer) clearTimeout(debounceTimer);
    const timeout = setTimeout(async () => {
      await fetchImages(keyword, page);
    }, time);
    setDebounceTimer(timeout);
  };

  const fetchImages = async (keyword: string, page: number) => {
    if (!keyword || keyword.length < 2) {
      setImages([]);
      setError(null);
      return;
    }
    setLoading(true);
    setError(null);
    try {
      const res = await fetch(
        `/api/v1/images/unsplash?keyword=${encodeURIComponent(keyword)}&page=${page}&per_page=12`,
      );

      if (!res.ok) {
        const text = await res.text();
        if (res.status === 500 && text.includes('UNSPLASH_ACCESS_KEY')) {
          setError(t('errors.unsplashKey'));
        } else {
          setError(t('errors.unsplashFetch'));
        }
        setImages([]);
        return;
      }

      const data = await res.json();
      setImages(data.results || []);
      setKeyword(keyword);
      setPage(page);
    } catch (error) {
      console.error('Unsplash fetch error:', error);
      setError(t('errors.searchUnexpected'));
    } finally {
      setLoading(false);
    }
  };

      const handleDeleteImage = async () => {
    if (!onDeleteImage) return;
    setIsDeleting(true);
    try {
      await onDeleteImage();
      setPreviewUrl(null);
      setSelectedFileName(null);
    } catch (error) {
      console.error('Delete image error:', error);
      setIsDeleting(false);
    }
  };

  const displayUrl =
    previewUrl || (currentImageUrl ? getImageUrlOrFile(currentImageUrl) : null);

  return (
    <div className="space-y-4 pt-2">
      <div className="flex items-center justify-between">
        <Label className="text-[12px] font-bold uppercase text-muted-foreground">
          {t('coverImage')}
        </Label>
        <div className="flex gap-2">
          {isUnsplashMode && (
            <Button
              type="button"
              variant="ghost"
              size="sm"
              className="h-7 px-2 text-[11px] font-bold uppercase tracking-wider hover:bg-muted transition-colors"
              onClick={() => setIsUnsplashMode(false)}
            >
              <XMarkIcon className="h-3.5 w-3.5 mr-1" />
              {t('cancelSearch')}
            </Button>
          )}
          {onDeleteImage &&
            currentImageUrl &&
            !previewUrl &&
            !isUnsplashMode && (
              <Button
                type="button"
                variant="ghost"
                size="sm"
                disabled={isDeleting}
                className="h-7 px-2 text-destructive hover:text-destructive hover:bg-destructive/10 text-[11px] font-bold uppercase tracking-wider"
                onClick={handleDeleteImage}
              >
                {isDeleting ? (
                  <svg
                    className="animate-spin -ml-1 mr-2 h-3.5 w-3.5 text-white"
                    xmlns="http://www.w3.org/2000/svg"
                    fill="none"
                    viewBox="0 0 24 24"
                  >
                    <circle
                      className="opacity-25"
                      cx="12"
                      cy="12"
                      r="10"
                      stroke="currentColor"
                      strokeWidth="4"
                    ></circle>
                    <path
                      className="opacity-75"
                      fill="currentColor"
                      d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
                    ></path>
                  </svg>
                ) : (
                  <TrashIcon className="h-3.5 w-3.5 mr-1" />
                )}
                {isDeleting ? t('removing') : t('removeCurrent')}
              </Button>
            )}
        </div>
      </div>

      <div
        className={cn(
          'relative flex flex-col justify-center rounded-xl overflow-hidden w-full border border-border bg-muted transition-all duration-300',
          isUnsplashMode
            ? 'h-[450px] sm:h-[340px]'
            : 'min-h-[240px] sm:min-h-[auto] sm:aspect-[21/9]',
        )}
      >
        {isDeleting ? (
          <div className="flex h-full items-center justify-center py-12">
            <Loader caption={t('removingImage')} />
          </div>
        ) : isUnsplashMode ? (
          <div className="absolute inset-0 p-4 flex flex-col gap-4 overflow-hidden">
            <div className="relative">
              <MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/70" />
              <Input
                autoFocus
                type="text"
                placeholder={t('searchPlaceholder')}
                className="pl-10 h-10 rounded-xl bg-background border-border focus-visible:ring-ring"
                onKeyDown={(e) => {
                  if (e.key === 'Enter') {
                    e.preventDefault();
                    void fetchImages(e.currentTarget.value, 1);
                  }
                }}
                onChange={(e) => debounceSearch(e.target.value, 1)}
              />
            </div>

            <div className="flex-1 overflow-y-auto min-h-0 custom-scrollbar pr-1">
              {loading ? (
                <div className="flex h-full items-center justify-center py-12">
                  <Loader caption={t('searchingBeauty')} />
                </div>
              ) : error ? (
                <div className="flex h-full items-center justify-center py-12 px-8 text-center">
                  <div className="bg-destructive/10 border border-destructive/30 rounded-xl p-4 animate-in fade-in zoom-in-95 duration-300">
                    <div className="p-2 rounded-full bg-destructive/15 w-fit mx-auto mb-3">
                      <XMarkIcon className="h-6 w-6 text-destructive" />
                    </div>
                    <p className="text-sm font-bold text-destructive mb-1">
                      {t('searchFailed')}
                    </p>
                    <p className="text-xs text-destructive/80 leading-relaxed">
                      {error}
                    </p>
                  </div>
                </div>
              ) : images.length > 0 ? (
                <div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
                  {images.map((image) => (
                    <button
                      key={image.id}
                      type="button"
                      className="group/item relative aspect-[4/3] rounded-lg overflow-hidden border border-border transition-all hover:border-foreground"
                      onClick={() => handleUnsplashSelect(image.urls.regular)}
                    >
                      <Image
                        fill
                        src={image.urls.thumb}
                        alt={image.description || t('unsplashImageAlt')}
                        className="object-cover transition-transform duration-500 group-hover/item:scale-110"
                      />
                      <div className="absolute inset-0 bg-black/0 group-hover/item:bg-black/20 transition-colors" />
                    </button>
                  ))}
                </div>
              ) : keyword ? (
                <div className="flex flex-col items-center justify-center h-full text-muted-foreground/70 py-8">
                  <PhotoIcon className="h-10 w-10 mb-2 opacity-20" />
                  <p className="text-sm font-medium">
                    {t('noResults', { keyword })}
                  </p>
                </div>
              ) : (
                <div className="flex flex-col items-center justify-center h-full text-muted-foreground/70 opacity-50 space-y-2">
                  <PhotoIcon className="h-12 w-12" />
                  <p className="text-xs font-bold uppercase tracking-widest text-center">
                    {t('enterKeyword')}
                  </p>
                </div>
              )}
            </div>

            {images.length > 0 && (
              <div className="flex items-center justify-between pt-2 border-t border-border">
                <p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/70">
                  {t('pageResults', { page })}
                </p>
                <div className="flex gap-1">
                  <Button
                    type="button"
                    variant="outline"
                    size="sm"
                    className="h-8 w-8 p-0 rounded-lg"
                    disabled={loading || page <= 1}
                    onClick={() => fetchImages(keyword, page - 1)}
                  >
                    <ChevronLeftIcon className="h-4 w-4" />
                  </Button>
                  <Button
                    type="button"
                    variant="outline"
                    size="sm"
                    className="h-8 w-8 p-0 rounded-lg"
                    disabled={loading}
                    onClick={() => fetchImages(keyword, page + 1)}
                  >
                    <ChevronRightIcon className="h-4 w-4" />
                  </Button>
                </div>
              </div>
            )}
          </div>
        ) : displayUrl ? (
          <div className="group relative h-full w-full">
            <Image
              fill
              src={displayUrl}
              alt={altText}
              className="object-cover transition-transform duration-500 group-hover:scale-105"
              priority={false}
            />
            <div className="absolute inset-0 bg-black/40 backdrop-blur-[2px] opacity-100 lg:opacity-0 group-hover:opacity-100 transition-all flex items-center justify-center p-4">
              <div className="flex w-full max-w-xs flex-col items-center justify-center gap-2 sm:flex-row">
                <label className="cursor-pointer bg-background text-foreground h-10 px-4 rounded-xl flex items-center justify-center font-bold text-sm hover:bg-background/90 transition-colors shadow-lg shrink-0">
                  <PhotoIcon className="h-5 w-5 mr-2" />
                  {t('changeImage')}
                  <input
                    type="file"
                    accept="image/*"
                    className="hidden"
                    onChange={handleFileChange}
                  />
                </label>
                {isUnsplashAllowed && (
                  <Button
                    type="button"
                    className="h-10 rounded-xl bg-white/20 border-white/30 text-white hover:bg-white/30 backdrop-blur-md font-bold text-sm"
                    onClick={() => setIsUnsplashMode(true)}
                  >
                    {t('selectFromUnsplash')}
                  </Button>
                )}
              </div>
            </div>
          </div>
        ) : (
          <div className="flex flex-col items-center gap-3 py-8">
            <div className="p-4 rounded-full bg-background shadow-sm text-muted-foreground border border-border">
              <PhotoIcon className="h-8 w-8" />
            </div>
            <div className="flex flex-col items-center gap-2">
              <div className="flex flex-col sm:flex-row gap-2 w-full px-4">
                <label className="cursor-pointer bg-primary text-primary-foreground h-10 px-6 rounded-xl flex items-center justify-center font-bold text-sm hover:bg-primary/90 transition-colors shadow-lg shrink-0 whitespace-nowrap">
                  {t('uploadFromDevice')}
                  <input
                    type="file"
                    accept="image/*"
                    className="hidden"
                    onChange={handleFileChange}
                  />
                </label>
                {isUnsplashAllowed && (
                  <Button
                    type="button"
                    variant="outline"
                    className="h-10 rounded-xl font-bold text-sm bg-background hover:bg-accent transition-colors"
                    onClick={() => setIsUnsplashMode(true)}
                  >
                    {t('searchUnsplash')}
                  </Button>
                )}
              </div>
              <p className="text-[11px] text-muted-foreground/70 font-medium uppercase tracking-widest mt-1">
                {t('recommendation')}
              </p>
            </div>
          </div>
        )}
      </div>

      {selectedFileName && !isUnsplashMode && (
        <div className="flex items-center gap-2 text-[12px] text-muted-foreground bg-accent px-3 py-1.5 rounded-lg w-fit animate-in fade-in slide-in-from-left-2">
          <PhotoIcon className="h-3.5 w-3.5" />
          <span className="font-medium truncate max-w-[200px]">
            {selectedFileName}
          </span>
          <button
            type="button"
            onClick={() => {
              onImageFileChange(undefined);
              setSelectedFileName(null);
              setPreviewUrl(null);
            }}
            className="ml-1 p-0.5 hover:bg-gray-200 rounded-full transition-colors"
          >
            <TrashIcon className="h-3 w-3" />
          </button>
        </div>
      )}
    </div>
  );
}