← back to IWasCute

src/components/licensor/PhotoUpload.tsx

798 lines

'use client'

import { useCallback, useRef, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
  Upload,
  X,
  ImageIcon,
  Plus,
  AlertCircle,
  FolderOpen,
  Calendar,
  MapPin,
  Camera,
  Info,
} from 'lucide-react'
import clsx from 'clsx'
import exifr from 'exifr'

export interface PhotoMetadata {
  dateTaken?: string
  year?: number
  latitude?: number
  longitude?: number
  locationName?: string
  cameraMake?: string
  cameraModel?: string
  width?: number
  height?: number
}

export interface PhotoFile {
  id: string
  file: File
  preview: string
  progress: number
  error?: string
  metadata?: PhotoMetadata
  directory?: string
}

interface PhotoUploadProps {
  photos: PhotoFile[]
  onPhotosChange: React.Dispatch<React.SetStateAction<PhotoFile[]>>
  onNext: () => void
}

const MAX_FILE_SIZE_MB = 25
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
const ACCEPTED_TYPES = [
  'image/jpeg',
  'image/png',
  'image/webp',
  'image/heic',
  'image/gif',
  'image/tiff',
]

function generateId() {
  return Math.random().toString(36).slice(2) + Date.now().toString(36)
}

function simulateUpload(
  id: string,
  onProgress: (id: string, progress: number) => void
): Promise<void> {
  return new Promise((resolve) => {
    let progress = 0
    const interval = setInterval(() => {
      progress += Math.random() * 30 + 10
      if (progress >= 100) {
        clearInterval(interval)
        onProgress(id, 100)
        resolve()
      } else {
        onProgress(id, Math.min(Math.round(progress), 99))
      }
    }, 120)
  })
}

async function extractMetadata(file: File): Promise<PhotoMetadata> {
  const meta: PhotoMetadata = {}

  try {
    const exif = await exifr.parse(file, {
      pick: [
        'DateTimeOriginal',
        'CreateDate',
        'GPSLatitude',
        'GPSLongitude',
        'Make',
        'Model',
        'ImageWidth',
        'ImageHeight',
        'ExifImageWidth',
        'ExifImageHeight',
      ],
      gps: true,
    })

    if (!exif) return meta

    // Date
    const dateVal = exif.DateTimeOriginal || exif.CreateDate
    if (dateVal instanceof Date) {
      meta.dateTaken = dateVal.toLocaleDateString('en-US', {
        year: 'numeric',
        month: 'short',
        day: 'numeric',
      })
      meta.year = dateVal.getFullYear()
    }

    // GPS
    if (typeof exif.latitude === 'number' && typeof exif.longitude === 'number') {
      meta.latitude = exif.latitude
      meta.longitude = exif.longitude
    }

    // Camera
    if (exif.Make) meta.cameraMake = String(exif.Make).trim()
    if (exif.Model) meta.cameraModel = String(exif.Model).trim()

    // Dimensions
    meta.width = exif.ExifImageWidth || exif.ImageWidth
    meta.height = exif.ExifImageHeight || exif.ImageHeight
  } catch {
    // EXIF parsing can fail for PNGs, WEBPs without metadata — that's fine
  }

  return meta
}

function MetadataBadge({
  icon: Icon,
  label,
}: {
  icon: typeof Calendar
  label: string
}) {
  return (
    <span
      className="inline-flex items-center gap-1 px-2 py-0.5 rounded-lg text-[10px] font-medium"
      style={{ background: 'rgba(61, 64, 91, 0.7)', color: 'white' }}
    >
      <Icon size={9} />
      {label}
    </span>
  )
}

function PhotoCard({
  photo,
  onRemove,
  onSelect,
  isSelected,
}: {
  photo: PhotoFile
  onRemove: () => void
  onSelect: () => void
  isSelected: boolean
}) {
  const m = photo.metadata

  return (
    <motion.div
      layout
      key={photo.id}
      initial={{ opacity: 0, scale: 0.85 }}
      animate={{ opacity: 1, scale: 1 }}
      exit={{ opacity: 0, scale: 0.85 }}
      transition={{ type: 'spring', stiffness: 300, damping: 25 }}
      className={clsx(
        'relative group rounded-2xl overflow-hidden cursor-pointer',
        'transition-all duration-200'
      )}
      style={{
        aspectRatio: '1',
        background: 'var(--color-cream-dark)',
        outline: isSelected ? '3px solid var(--color-peach)' : 'none',
        outlineOffset: '2px',
      }}
      onClick={onSelect}
    >
      {/* Thumbnail */}
      {/* eslint-disable-next-line @next/next/no-img-element */}
      <img
        src={photo.preview}
        alt={photo.file.name}
        className="w-full h-full object-cover"
      />

      {/* Upload progress overlay */}
      {photo.progress < 100 && (
        <div
          className="absolute inset-0 flex flex-col items-center justify-center gap-2"
          style={{ background: 'rgba(61, 64, 91, 0.55)' }}
        >
          <ImageIcon size={20} className="text-white" />
          <div
            className="w-3/4 h-1.5 rounded-full overflow-hidden"
            style={{ background: 'rgba(255,255,255,0.3)' }}
          >
            <motion.div
              className="h-full rounded-full"
              style={{ background: 'var(--color-peach)' }}
              initial={{ width: 0 }}
              animate={{ width: `${photo.progress}%` }}
              transition={{ duration: 0.2 }}
            />
          </div>
          <span className="text-white text-xs font-medium">{photo.progress}%</span>
        </div>
      )}

      {/* Metadata badges (bottom-left) */}
      {photo.progress === 100 && m && (m.dateTaken || m.latitude) && (
        <div className="absolute bottom-1.5 left-1.5 flex flex-col gap-0.5">
          {m.dateTaken && <MetadataBadge icon={Calendar} label={m.dateTaken} />}
          {m.latitude && (
            <MetadataBadge
              icon={MapPin}
              label={`${m.latitude.toFixed(2)}, ${m.longitude?.toFixed(2)}`}
            />
          )}
        </div>
      )}

      {/* Remove button */}
      {photo.progress === 100 && (
        <button
          className="absolute top-1.5 right-1.5 w-6 h-6 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
          style={{ background: 'rgba(61, 64, 91, 0.8)' }}
          onClick={(e) => {
            e.stopPropagation()
            onRemove()
          }}
          aria-label={`Remove ${photo.file.name}`}
        >
          <X size={12} className="text-white" />
        </button>
      )}

      {/* Complete checkmark */}
      {photo.progress === 100 && (
        <div
          className="absolute top-1.5 left-1.5 w-5 h-5 rounded-full flex items-center justify-center"
          style={{ background: 'var(--color-sage-dark)' }}
        >
          <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
            <path
              d="M2 5l2.5 2.5L8 3"
              stroke="white"
              strokeWidth="1.5"
              strokeLinecap="round"
              strokeLinejoin="round"
            />
          </svg>
        </div>
      )}

      {/* Directory label */}
      {photo.directory && (
        <div
          className="absolute top-1.5 right-1.5 flex items-center gap-0.5 px-1.5 py-0.5 rounded-md"
          style={{
            background: 'rgba(61, 64, 91, 0.6)',
            fontSize: '9px',
            color: 'white',
          }}
        >
          <FolderOpen size={8} />
          {photo.directory}
        </div>
      )}
    </motion.div>
  )
}

function MetadataPanel({ photo }: { photo: PhotoFile }) {
  const m = photo.metadata
  if (!m) return null

  const hasAny = m.dateTaken || m.cameraMake || m.latitude || m.width

  if (!hasAny) {
    return (
      <div
        className="flex items-center gap-2 p-4 rounded-2xl"
        style={{ background: 'rgba(255,255,255,0.5)', border: '1px solid var(--color-cream-dark)' }}
      >
        <Info size={14} style={{ color: 'var(--color-warm-gray)' }} />
        <p className="text-xs" style={{ color: 'var(--color-warm-gray)' }}>
          No EXIF metadata found. You can manually enter the year in the next step.
        </p>
      </div>
    )
  }

  return (
    <div
      className="rounded-2xl overflow-hidden"
      style={{ border: '1px solid var(--color-cream-dark)' }}
    >
      <div
        className="flex items-center gap-2 px-4 py-2.5 border-b"
        style={{
          borderColor: 'var(--color-cream-dark)',
          background: 'rgba(255,255,255,0.4)',
        }}
      >
        <Info size={12} style={{ color: 'var(--color-warm-gray)' }} />
        <p className="text-xs font-semibold" style={{ color: 'var(--color-brown)' }}>
          {photo.file.name}
        </p>
      </div>
      <div className="flex flex-wrap gap-x-6 gap-y-2 px-4 py-3" style={{ background: 'rgba(255,255,255,0.5)' }}>
        {m.dateTaken && (
          <div className="flex items-center gap-1.5">
            <Calendar size={12} style={{ color: 'var(--color-peach-dark)' }} />
            <span className="text-xs" style={{ color: 'var(--color-brown)' }}>
              {m.dateTaken}
            </span>
          </div>
        )}
        {m.cameraMake && (
          <div className="flex items-center gap-1.5">
            <Camera size={12} style={{ color: 'var(--color-sage-dark)' }} />
            <span className="text-xs" style={{ color: 'var(--color-brown)' }}>
              {m.cameraMake} {m.cameraModel || ''}
            </span>
          </div>
        )}
        {m.latitude && m.longitude && (
          <div className="flex items-center gap-1.5">
            <MapPin size={12} style={{ color: 'var(--color-warm-gray)' }} />
            <span className="text-xs" style={{ color: 'var(--color-brown)' }}>
              {m.latitude.toFixed(4)}, {m.longitude.toFixed(4)}
            </span>
          </div>
        )}
        {m.width && m.height && (
          <div className="flex items-center gap-1.5">
            <ImageIcon size={12} style={{ color: 'var(--color-warm-gray)' }} />
            <span className="text-xs" style={{ color: 'var(--color-brown)' }}>
              {m.width} x {m.height}
            </span>
          </div>
        )}
      </div>
    </div>
  )
}

export default function PhotoUpload({
  photos,
  onPhotosChange,
  onNext,
}: PhotoUploadProps) {
  const [isDragOver, setIsDragOver] = useState(false)
  const [errors, setErrors] = useState<string[]>([])
  const [selectedId, setSelectedId] = useState<string | null>(null)
  const fileInputRef = useRef<HTMLInputElement>(null)
  const dirInputRef = useRef<HTMLInputElement>(null)

  const processFiles = useCallback(
    async (files: File[], directoryName?: string) => {
      const newErrors: string[] = []
      const validFiles: File[] = []

      for (const file of files) {
        if (
          !ACCEPTED_TYPES.includes(file.type) &&
          !file.name.match(/\.(jpe?g|png|webp|heic|gif|tiff?)$/i)
        ) {
          newErrors.push(`"${file.name}" is not a supported image type.`)
          continue
        }
        if (file.size > MAX_FILE_SIZE_BYTES) {
          newErrors.push(
            `"${file.name}" exceeds the ${MAX_FILE_SIZE_MB}MB limit.`
          )
          continue
        }
        validFiles.push(file)
      }

      if (newErrors.length > 0) {
        setErrors(newErrors)
        setTimeout(() => setErrors([]), 5000)
      }

      if (validFiles.length === 0) return

      // Build photo objects + extract EXIF metadata in parallel
      const newPhotos: PhotoFile[] = await Promise.all(
        validFiles.map(async (file) => {
          const metadata = await extractMetadata(file)
          // Extract directory from webkitRelativePath
          const relPath = (file as File & { webkitRelativePath?: string })
            .webkitRelativePath
          const dir =
            directoryName ||
            (relPath ? relPath.split('/').slice(0, -1).join('/') : undefined)

          return {
            id: generateId(),
            file,
            preview: URL.createObjectURL(file),
            progress: 0,
            metadata,
            directory: dir || undefined,
          }
        })
      )

      // Merge with existing
      onPhotosChange((prev) => [...prev, ...newPhotos])

      // Simulate upload progress
      await Promise.all(
        newPhotos.map((photo) =>
          simulateUpload(photo.id, (id, progress) => {
            onPhotosChange((prev) =>
              prev.map((p) => (p.id === id ? { ...p, progress } : p))
            )
          })
        )
      )

      // Auto-select first photo
      if (newPhotos.length > 0) {
        setSelectedId(newPhotos[0].id)
      }
    },
    [onPhotosChange]
  )

  const handleDrop = useCallback(
    async (e: React.DragEvent<HTMLDivElement>) => {
      e.preventDefault()
      setIsDragOver(false)

      // Check for directory drops via DataTransferItemList
      const items = e.dataTransfer.items
      const allFiles: File[] = []

      if (items) {
        const entries: FileSystemEntry[] = []
        for (let i = 0; i < items.length; i++) {
          const entry = items[i].webkitGetAsEntry?.()
          if (entry) entries.push(entry)
        }

        // Recursively read directories
        for (const entry of entries) {
          if (entry.isFile) {
            const file = await new Promise<File>((resolve) =>
              (entry as FileSystemFileEntry).file(resolve)
            )
            allFiles.push(file)
          } else if (entry.isDirectory) {
            const dirReader = (
              entry as FileSystemDirectoryEntry
            ).createReader()
            const dirFiles = await new Promise<FileSystemEntry[]>(
              (resolve) => dirReader.readEntries(resolve)
            )
            for (const dirEntry of dirFiles) {
              if (dirEntry.isFile) {
                const file = await new Promise<File>((resolve) =>
                  (dirEntry as FileSystemFileEntry).file(resolve)
                )
                allFiles.push(file)
              }
            }
          }
        }
      }

      if (allFiles.length > 0) {
        processFiles(allFiles)
      } else {
        // Fallback to standard files
        processFiles(Array.from(e.dataTransfer.files))
      }
    },
    [processFiles]
  )

  const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault()
    setIsDragOver(true)
  }

  const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
    if (!e.currentTarget.contains(e.relatedTarget as Node)) {
      setIsDragOver(false)
    }
  }

  const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
    const files = Array.from(e.target.files ?? [])
    processFiles(files)
    if (fileInputRef.current) fileInputRef.current.value = ''
  }

  const handleDirInput = (e: React.ChangeEvent<HTMLInputElement>) => {
    const files = Array.from(e.target.files ?? [])
    // Extract directory name from first file's path
    const firstPath = (files[0] as File & { webkitRelativePath?: string })
      ?.webkitRelativePath
    const dirName = firstPath ? firstPath.split('/')[0] : undefined
    processFiles(files, dirName)
    if (dirInputRef.current) dirInputRef.current.value = ''
  }

  const removePhoto = (id: string) => {
    const photo = photos.find((p) => p.id === id)
    if (photo) URL.revokeObjectURL(photo.preview)
    onPhotosChange((prev) => prev.filter((p) => p.id !== id))
    if (selectedId === id) setSelectedId(null)
  }

  const selectedPhoto = photos.find((p) => p.id === selectedId)
  const canProceed =
    photos.length >= 1 && photos.every((p) => p.progress === 100)

  // Count unique directories
  const directories = new Set(photos.map((p) => p.directory).filter(Boolean))

  return (
    <div className="flex flex-col gap-6">
      {/* Header */}
      <div>
        <h2
          className="text-2xl md:text-3xl font-bold tracking-tight"
          style={{ color: 'var(--color-brown)' }}
        >
          Upload Your Photos
        </h2>
        <p className="mt-2 text-base" style={{ color: 'var(--color-warm-gray)' }}>
          Add childhood photos or entire folders. We&apos;ll extract date, location, and
          camera info automatically.
        </p>
      </div>

      {/* Drop zone */}
      <div
        role="button"
        tabIndex={0}
        aria-label="Click or drag photos to upload"
        onDrop={handleDrop}
        onDragOver={handleDragOver}
        onDragLeave={handleDragLeave}
        onKeyDown={(e) => {
          if (e.key === 'Enter' || e.key === ' ') fileInputRef.current?.click()
        }}
        onClick={() => fileInputRef.current?.click()}
        className={clsx(
          'relative flex flex-col items-center justify-center gap-4 px-8 py-12 rounded-3xl',
          'border-2 border-dashed cursor-pointer',
          'transition-all duration-200',
          'outline-none focus-visible:ring-2',
          isDragOver && 'drag-over scale-[1.01]'
        )}
        style={{
          borderColor: isDragOver
            ? 'var(--color-peach-dark)'
            : 'var(--color-cream-dark)',
          background: isDragOver
            ? 'rgba(255, 181, 167, 0.08)'
            : 'rgba(255, 255, 255, 0.6)',
        }}
      >
        <input
          ref={fileInputRef}
          type="file"
          multiple
          accept="image/*,.heic"
          onChange={handleFileInput}
          aria-hidden="true"
        />
        <input
          ref={dirInputRef}
          type="file"
          // @ts-expect-error webkitdirectory is non-standard but widely supported
          webkitdirectory=""
          onChange={handleDirInput}
          aria-hidden="true"
          style={{
            position: 'absolute',
            width: '1px',
            height: '1px',
            overflow: 'hidden',
            clip: 'rect(0,0,0,0)',
          }}
        />

        <motion.div
          animate={{ scale: isDragOver ? 1.15 : 1 }}
          transition={{ type: 'spring', stiffness: 300, damping: 25 }}
          className="w-16 h-16 rounded-2xl flex items-center justify-center"
          style={{ background: 'var(--color-peach)' }}
        >
          <Upload size={28} style={{ color: 'var(--color-brown)' }} />
        </motion.div>

        <div className="text-center">
          <p
            className="text-lg font-semibold tracking-wide"
            style={{ color: 'var(--color-brown)' }}
          >
            {isDragOver
              ? 'Drop them here!'
              : 'Drag & drop photos or folders'}
          </p>
          <p
            className="mt-1 text-sm"
            style={{ color: 'var(--color-warm-gray)' }}
          >
            or{' '}
            <span
              className="underline font-medium"
              style={{ color: 'var(--color-peach-dark)' }}
            >
              browse to choose files
            </span>
          </p>
          <p
            className="mt-2 text-xs"
            style={{ color: 'var(--color-warm-gray)' }}
          >
            JPG, PNG, WEBP, HEIC up to {MAX_FILE_SIZE_MB}MB each
          </p>
        </div>
      </div>

      {/* Upload folder button */}
      <div className="flex gap-3">
        <button
          onClick={(e) => {
            e.stopPropagation()
            dirInputRef.current?.click()
          }}
          className="flex items-center gap-2 px-4 py-2.5 rounded-2xl text-sm font-medium transition-all hover:scale-105"
          style={{
            background: 'var(--color-cream-dark)',
            color: 'var(--color-warm-gray)',
          }}
        >
          <FolderOpen size={14} />
          Upload entire folder
        </button>
        {directories.size > 0 && (
          <span
            className="flex items-center text-xs"
            style={{ color: 'var(--color-warm-gray)' }}
          >
            {directories.size} folder{directories.size !== 1 ? 's' : ''} loaded
          </span>
        )}
      </div>

      {/* Errors */}
      <AnimatePresence>
        {errors.length > 0 && (
          <motion.div
            initial={{ opacity: 0, y: -8 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: -8 }}
            className="flex flex-col gap-2 p-4 rounded-2xl"
            style={{
              background: 'rgba(255, 181, 167, 0.2)',
              border: '1px solid var(--color-peach)',
            }}
            role="alert"
          >
            {errors.map((err, i) => (
              <div key={i} className="flex items-start gap-2">
                <AlertCircle
                  size={16}
                  className="mt-0.5 shrink-0"
                  style={{ color: 'var(--color-peach-dark)' }}
                />
                <p className="text-sm" style={{ color: 'var(--color-brown)' }}>
                  {err}
                </p>
              </div>
            ))}
          </motion.div>
        )}
      </AnimatePresence>

      {/* Photo grid + metadata panel */}
      {photos.length > 0 && (
        <div className="flex flex-col gap-4">
          <div className="flex items-center justify-between">
            <p
              className="text-sm font-semibold"
              style={{ color: 'var(--color-brown)' }}
            >
              {photos.length} photo{photos.length !== 1 ? 's' : ''} selected
              {photos.filter((p) => p.metadata?.dateTaken).length > 0 && (
                <span
                  className="ml-2 font-normal"
                  style={{ color: 'var(--color-warm-gray)' }}
                >
                  ({photos.filter((p) => p.metadata?.dateTaken).length} with
                  date metadata)
                </span>
              )}
            </p>
            <button
              onClick={(e) => {
                e.stopPropagation()
                fileInputRef.current?.click()
              }}
              className="flex items-center gap-1.5 text-sm font-medium px-4 py-2 rounded-2xl transition-all hover:scale-105"
              style={{
                background: 'var(--color-cream-dark)',
                color: 'var(--color-warm-gray)',
              }}
              aria-label="Add more photos"
            >
              <Plus size={14} />
              Add more
            </button>
          </div>

          <div
            className="grid gap-3"
            style={{
              gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))',
            }}
          >
            <AnimatePresence>
              {photos.map((photo) => (
                <PhotoCard
                  key={photo.id}
                  photo={photo}
                  onRemove={() => removePhoto(photo.id)}
                  onSelect={() => setSelectedId(photo.id)}
                  isSelected={selectedId === photo.id}
                />
              ))}
            </AnimatePresence>
          </div>

          {/* Metadata panel for selected photo */}
          <AnimatePresence>
            {selectedPhoto && selectedPhoto.progress === 100 && (
              <motion.div
                initial={{ opacity: 0, height: 0 }}
                animate={{ opacity: 1, height: 'auto' }}
                exit={{ opacity: 0, height: 0 }}
                transition={{ duration: 0.2 }}
                className="overflow-hidden"
              >
                <MetadataPanel photo={selectedPhoto} />
              </motion.div>
            )}
          </AnimatePresence>
        </div>
      )}

      {/* Proceed */}
      <div className="flex justify-end pt-2">
        <motion.button
          whileHover={canProceed ? { scale: 1.03 } : {}}
          whileTap={canProceed ? { scale: 0.97 } : {}}
          onClick={onNext}
          disabled={!canProceed}
          className={clsx(
            'px-8 py-3.5 rounded-3xl text-sm font-semibold tracking-wide',
            'transition-all duration-200',
            !canProceed && 'opacity-40 cursor-not-allowed'
          )}
          style={{
            background: canProceed
              ? 'var(--color-peach)'
              : 'var(--color-cream-dark)',
            color: 'var(--color-brown)',
          }}
          aria-disabled={!canProceed}
        >
          {photos.length === 0
            ? 'Upload at least 1 photo'
            : photos.some((p) => p.progress < 100)
              ? 'Uploading…'
              : 'Continue to Copyright Info'}
        </motion.button>
      </div>
    </div>
  )
}