photos phase 3
All checks were successful
Build and Push Docker Image / build (push) Successful in 35s

This commit is contained in:
jason
2026-03-30 10:27:50 -05:00
parent 155e7849e1
commit 5431177b7a
3 changed files with 605 additions and 16 deletions

View File

@@ -1,3 +1,471 @@
export default function PhotosPage() {
return <div className="p-6"><h1 className="text-2xl font-bold text-primary">Photo Slideshow</h1><p className="text-secondary mt-2">Phase 3</p></div>;
import { useState, useRef, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'framer-motion';
import {
Image, Upload, Trash2, X, ChevronLeft, ChevronRight,
Settings, CloudUpload, CheckCircle2, AlertCircle,
} from 'lucide-react';
import { Link } from 'react-router-dom';
import { api } from '@/lib/api';
import { Button } from '@/components/ui/Button';
import { clsx } from 'clsx';
// ── Types ──────────────────────────────────────────────────────────────────
interface Photo {
name: string;
rel: string;
url: string;
}
interface PhotosResponse {
configured: boolean;
count: number;
photos: Photo[];
}
interface UploadStatus {
file: File;
state: 'pending' | 'done' | 'error';
message?: string;
}
// ── Helpers ────────────────────────────────────────────────────────────────
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
}
// ── Main component ─────────────────────────────────────────────────────────
export default function PhotosPage() {
const qc = useQueryClient();
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [uploadQueue, setUploadQueue] = useState<UploadStatus[]>([]);
const [uploadOpen, setUploadOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Photo | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// ── Data ────────────────────────────────────────────────────────────────
const { data, isLoading } = useQuery<PhotosResponse>({
queryKey: ['photos'],
queryFn: () => api.get('/photos').then((r) => r.data),
});
const photos = data?.photos ?? [];
const configured = data?.configured ?? true; // optimistic until loaded
// ── Upload ──────────────────────────────────────────────────────────────
const uploadMutation = useMutation({
mutationFn: async (files: File[]) => {
const statuses: UploadStatus[] = files.map((f) => ({ file: f, state: 'pending' }));
setUploadQueue(statuses);
const form = new FormData();
files.forEach((f) => form.append('photos', f));
try {
const res = await api.post('/photos/upload', form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
setUploadQueue(statuses.map((s) => ({ ...s, state: 'done' })));
return res.data;
} catch (err: any) {
const msg = err?.response?.data?.error ?? 'Upload failed';
setUploadQueue(statuses.map((s) => ({ ...s, state: 'error', message: msg })));
throw err;
}
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['photos'] });
},
});
const deleteMutation = useMutation({
mutationFn: (photo: Photo) =>
api.delete(`/photos/file/${encodeURIComponent(photo.rel)}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['photos'] });
if (lightboxIndex !== null && lightboxIndex >= photos.length - 1) {
setLightboxIndex(null);
}
setDeleteTarget(null);
},
});
// ── Drag & drop ─────────────────────────────────────────────────────────
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
if (files.length) triggerUpload(files);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsDragging(false);
}, []);
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []);
if (files.length) triggerUpload(files);
e.target.value = '';
};
const triggerUpload = (files: File[]) => {
setUploadOpen(true);
uploadMutation.mutate(files);
};
// ── Lightbox ─────────────────────────────────────────────────────────────
const prevPhoto = () =>
setLightboxIndex((i) => (i === null ? null : (i - 1 + photos.length) % photos.length));
const nextPhoto = () =>
setLightboxIndex((i) => (i === null ? null : (i + 1) % photos.length));
const handleLightboxKey = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowLeft') prevPhoto();
if (e.key === 'ArrowRight') nextPhoto();
if (e.key === 'Escape') setLightboxIndex(null);
};
// ── Upload summary counts ─────────────────────────────────────────────────
const doneCount = uploadQueue.filter((s) => s.state === 'done').length;
const errorCount = uploadQueue.filter((s) => s.state === 'error').length;
// ── Render ────────────────────────────────────────────────────────────────
return (
<div
className="flex flex-col h-full overflow-hidden relative"
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
{/* Drag overlay */}
<AnimatePresence>
{isDragging && (
<motion.div
className="absolute inset-0 z-40 flex items-center justify-center bg-accent/20 border-4 border-dashed border-accent backdrop-blur-sm pointer-events-none"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<div className="text-center">
<CloudUpload size={48} className="mx-auto mb-3 text-accent" />
<p className="text-xl font-bold text-accent">Drop photos to upload</p>
</div>
</motion.div>
)}
</AnimatePresence>
{/* ── Header ───────────────────────────────────────────────────────── */}
<div className="flex items-center justify-between px-6 py-4 border-b border-theme bg-surface shrink-0">
<div className="flex items-center gap-3">
<Image size={22} className="text-accent" />
<div>
<h1 className="text-xl font-bold text-primary leading-tight">Photos</h1>
{data?.configured && (
<p className="text-xs text-muted">
{data.count} photo{data.count !== 1 ? 's' : ''}
</p>
)}
</div>
</div>
{configured && (
<Button onClick={() => fileInputRef.current?.click()} size="sm">
<Upload size={15} /> Upload Photos
</Button>
)}
</div>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleFileInput}
/>
{/* ── Upload progress banner ────────────────────────────────────────── */}
<AnimatePresence>
{uploadOpen && uploadQueue.length > 0 && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden border-b border-theme bg-surface-raised shrink-0"
>
<div className="px-6 py-3 flex items-center justify-between gap-4">
<div className="flex items-center gap-3 min-w-0">
{uploadMutation.isPending ? (
<>
<svg className="animate-spin h-4 w-4 text-accent shrink-0" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
<span className="text-sm text-primary">
Uploading {uploadQueue.length} photo{uploadQueue.length !== 1 ? 's' : ''}
</span>
</>
) : errorCount > 0 ? (
<>
<AlertCircle size={16} className="text-red-500 shrink-0" />
<span className="text-sm text-red-500">
{errorCount} upload{errorCount !== 1 ? 's' : ''} failed
{doneCount > 0 && ` · ${doneCount} succeeded`}
</span>
</>
) : (
<>
<CheckCircle2 size={16} className="text-green-500 shrink-0" />
<span className="text-sm text-primary">
{doneCount} photo{doneCount !== 1 ? 's' : ''} uploaded successfully
</span>
</>
)}
</div>
<button
onClick={() => { setUploadOpen(false); setUploadQueue([]); }}
className="p-1 rounded-lg text-muted hover:text-primary hover:bg-surface-raised shrink-0"
>
<X size={15} />
</button>
</div>
{/* Per-file list — shown while pending or on error */}
{(uploadMutation.isPending || errorCount > 0) && (
<ul className="px-6 pb-3 space-y-1 max-h-32 overflow-y-auto">
{uploadQueue.map((s, i) => (
<li key={i} className="flex items-center gap-2 text-xs text-secondary">
{s.state === 'pending' && (
<svg className="animate-spin h-3 w-3 text-accent shrink-0" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
)}
{s.state === 'done' && <CheckCircle2 size={12} className="text-green-500 shrink-0" />}
{s.state === 'error' && <AlertCircle size={12} className="text-red-500 shrink-0" />}
<span className="truncate">{s.file.name}</span>
<span className="text-muted shrink-0">({formatBytes(s.file.size)})</span>
{s.message && <span className="text-red-500 truncate">{s.message}</span>}
</li>
))}
</ul>
)}
</motion.div>
)}
</AnimatePresence>
{/* ── Content ───────────────────────────────────────────────────────── */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3 p-6">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="aspect-square rounded-xl bg-surface-raised animate-pulse" />
))}
</div>
) : !data?.configured ? (
/* ── Not configured ─────────────────────────────────────────── */
<div className="flex flex-col items-center justify-center h-full text-center p-8">
<div className="text-5xl mb-4">📂</div>
<p className="text-lg font-semibold text-primary mb-1">Photo folder not configured</p>
<p className="text-secondary text-sm mb-6 max-w-sm">
Set a photo folder path in Settings, then come back to upload and manage your photos.
</p>
<Link
to="/settings"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-surface-raised border border-theme text-sm font-medium text-primary hover:bg-accent-light hover:text-accent transition-colors"
>
<Settings size={15} /> Go to Settings
</Link>
</div>
) : photos.length === 0 ? (
/* ── Empty — drop zone ──────────────────────────────────────── */
<div className="flex flex-col items-center justify-center h-full p-8">
<button
onClick={() => fileInputRef.current?.click()}
className="flex flex-col items-center gap-4 p-10 rounded-2xl border-2 border-dashed border-theme hover:border-accent hover:bg-accent/5 transition-all cursor-pointer group"
>
<CloudUpload size={48} className="text-muted group-hover:text-accent transition-colors" />
<div className="text-center">
<p className="text-lg font-semibold text-primary mb-1">Upload your first photos</p>
<p className="text-secondary text-sm">
Click to browse, or drag &amp; drop images anywhere on this page.
</p>
</div>
<span className="text-xs text-muted">
Supports JPG, PNG, WEBP, GIF, AVIF · Up to 50 MB per file
</span>
</button>
</div>
) : (
/* ── Photo grid ─────────────────────────────────────────────── */
<div className="p-6">
<p className="text-xs text-muted mb-4">
Drag &amp; drop images anywhere on this page, or use the Upload button to add more photos.
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
<AnimatePresence initial={false}>
{photos.map((photo, index) => (
<motion.div
key={photo.rel}
layout
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.15 }}
className="group relative aspect-square rounded-xl overflow-hidden bg-surface-raised cursor-pointer shadow-sm"
onClick={() => setLightboxIndex(index)}
>
<img
src={photo.url}
alt={photo.name}
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
loading="lazy"
/>
{/* Hover overlay */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors duration-200" />
{/* Delete button */}
<button
onClick={(e) => { e.stopPropagation(); setDeleteTarget(photo); }}
className={clsx(
'absolute top-2 right-2 p-1.5 rounded-full bg-black/60 text-white',
'opacity-0 group-hover:opacity-100 transition-opacity duration-150',
'hover:bg-red-500'
)}
aria-label="Delete photo"
>
<Trash2 size={13} />
</button>
{/* Filename on hover */}
<div className={clsx(
'absolute bottom-0 left-0 right-0 px-2 py-1.5 bg-black/60 backdrop-blur-sm',
'opacity-0 group-hover:opacity-100 transition-opacity duration-150'
)}>
<p className="text-white text-xs truncate">{photo.name}</p>
</div>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
)}
</div>
{/* ── Lightbox ──────────────────────────────────────────────────────── */}
<AnimatePresence>
{lightboxIndex !== null && photos[lightboxIndex] && (
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setLightboxIndex(null)}
onKeyDown={handleLightboxKey}
tabIndex={-1}
ref={(el) => el?.focus()}
>
{/* Close */}
<button
onClick={() => setLightboxIndex(null)}
className="absolute top-4 right-4 p-2 rounded-full bg-white/10 text-white hover:bg-white/20 transition-colors z-10"
>
<X size={20} />
</button>
{/* Counter */}
<div className="absolute top-4 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full bg-white/10 text-white text-sm select-none">
{lightboxIndex + 1} / {photos.length}
</div>
{/* Prev */}
{photos.length > 1 && (
<button
onClick={(e) => { e.stopPropagation(); prevPhoto(); }}
className="absolute left-4 p-2 rounded-full bg-white/10 text-white hover:bg-white/20 transition-colors z-10"
>
<ChevronLeft size={24} />
</button>
)}
{/* Image */}
<motion.img
key={photos[lightboxIndex].rel}
src={photos[lightboxIndex].url}
alt={photos[lightboxIndex].name}
className="max-h-[90vh] max-w-[90vw] object-contain rounded-xl shadow-2xl"
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.15 }}
onClick={(e) => e.stopPropagation()}
/>
{/* Next */}
{photos.length > 1 && (
<button
onClick={(e) => { e.stopPropagation(); nextPhoto(); }}
className="absolute right-4 p-2 rounded-full bg-white/10 text-white hover:bg-white/20 transition-colors z-10"
>
<ChevronRight size={24} />
</button>
)}
{/* Filename */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full bg-white/10 text-white text-sm select-none">
{photos[lightboxIndex].name}
</div>
</motion.div>
)}
</AnimatePresence>
{/* ── Delete confirmation ────────────────────────────────────────────── */}
<AnimatePresence>
{deleteTarget && (
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setDeleteTarget(null)}
>
<motion.div
className="bg-surface rounded-2xl border border-theme shadow-xl p-6 max-w-sm w-full"
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-base font-semibold text-primary mb-1">Delete photo?</h2>
<p className="text-sm text-secondary mb-4">
<span className="font-medium text-primary">{deleteTarget.name}</span> will be permanently
deleted from your photo folder. This cannot be undone.
</p>
<div className="flex gap-2 justify-end">
<Button variant="secondary" size="sm" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
variant="danger"
size="sm"
loading={deleteMutation.isPending}
onClick={() => deleteMutation.mutate(deleteTarget)}
>
<Trash2 size={14} /> Delete
</Button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}