← 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'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>
)
}