Sprint B: mobile filters, touch actions, skeleton grid, progressive viewer.

This commit is contained in:
2026-08-04 21:37:33 -04:00
parent 7a6fe89e06
commit 198bdc69e2
5 changed files with 203 additions and 60 deletions
+4 -3
View File
@@ -20,9 +20,10 @@ npm run build
cd "$ROOT"
if pm2 describe punimtag-viewer >/dev/null 2>&1; then
pm2 restart punimtag-viewer --update-env
else
pm2 start ecosystem.config.js --only punimtag-viewer
# Recreate so script-args / NODE_ENV changes in ecosystem.config.js take effect.
pm2 delete punimtag-viewer
fi
pm2 start ecosystem.config.js --only punimtag-viewer
pm2 save
echo "Viewer deployed (next start). Logs: pm2 logs punimtag-viewer --lines 30"
+10 -3
View File
@@ -1044,7 +1044,7 @@ export function HomePageContent({ initialPhotos, people, tags }: HomePageContent
onDownloadSelected={handleDownloadSelected}
onToggleSelectionMode={handleToggleSelectionMode}
/>
<div className="flex gap-4">
<div className="flex flex-col md:flex-row gap-4">
<CollapsibleSearch
people={people}
tags={tags}
@@ -1053,8 +1053,15 @@ export function HomePageContent({ initialPhotos, people, tags }: HomePageContent
/>
<div className="flex-1 min-w-0">
{loading ? (
<div role="status" aria-label="Loading photos" className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-gray-400 dark:text-gray-500" />
<div role="status" aria-label="Loading photos" aria-busy="true">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{Array.from({ length: 15 }).map((_, i) => (
<div
key={i}
className="aspect-square rounded-lg bg-muted animate-pulse"
/>
))}
</div>
</div>
) : (
<>
+5 -5
View File
@@ -665,7 +665,7 @@ export function PhotoGrid({
<button
type="button"
onClick={(e) => handleDownloadPhoto(e, photo)}
className="absolute left-2 top-2 z-10 p-1.5 rounded-full text-white opacity-0 group-hover:opacity-100 transition-opacity bg-black/50 hover:bg-black/70"
className="absolute left-2 top-2 z-10 p-1.5 rounded-full text-white opacity-90 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity bg-black/50 hover:bg-black/70"
aria-label="Download photo"
title="Download photo"
>
@@ -680,7 +680,7 @@ export function PhotoGrid({
<button
type="button"
onClick={(e) => handleReportButtonClick(e, photo.id)}
className="absolute left-2 bottom-2 z-10 p-1.5 rounded-full text-white opacity-0 group-hover:opacity-100 transition-opacity bg-black/50 hover:bg-black/70"
className="absolute left-2 bottom-2 z-10 p-1.5 rounded-full text-white opacity-90 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity bg-black/50 hover:bg-black/70"
aria-label="Report inappropriate photo"
title="Report inappropriate photo"
>
@@ -717,7 +717,7 @@ export function PhotoGrid({
type="button"
onClick={(e) => handleReportButtonClick(e, photo.id)}
disabled={reportingPhotoId === photo.id || isDismissed}
className={`absolute left-2 bottom-2 z-10 p-1.5 rounded-full text-white opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed ${buttonClass}`}
className={`absolute left-2 bottom-2 z-10 p-1.5 rounded-full text-white opacity-90 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed ${buttonClass}`}
aria-label={tooltipText}
title={tooltipText}
>
@@ -734,7 +734,7 @@ export function PhotoGrid({
<button
type="button"
onClick={(e) => handleToggleFavorite(e, photo.id)}
className="absolute right-2 bottom-2 z-10 p-1.5 rounded-full text-white opacity-0 group-hover:opacity-100 transition-opacity bg-black/50 hover:bg-black/70"
className="absolute right-2 bottom-2 z-10 p-1.5 rounded-full text-white opacity-90 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity bg-black/50 hover:bg-black/70"
aria-label="Add to favorites"
title="Add to favorites (sign in required)"
>
@@ -751,7 +751,7 @@ export function PhotoGrid({
type="button"
onClick={(e) => handleToggleFavorite(e, photo.id)}
disabled={favoritingPhotoId === photo.id}
className={`absolute right-2 bottom-2 z-10 p-1.5 rounded-full text-white opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed ${
className={`absolute right-2 bottom-2 z-10 p-1.5 rounded-full text-white opacity-90 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed ${
isFavorited
? 'bg-red-600/70 hover:bg-red-600/90'
: 'bg-black/50 hover:bg-black/70'
@@ -140,6 +140,7 @@ export function PhotoViewerClient({
const [currentPhoto, setCurrentPhoto] = useState<PhotoWithDetails>(normalizePhoto(initialPhoto));
const [currentIdx, setCurrentIdx] = useState(currentIndex);
const [imageLoading, setImageLoading] = useState(false);
const [fullImageReady, setFullImageReady] = useState(false);
/** Local-file videos: URL after web-playback transcode is ready */
const [webPlaybackSrc, setWebPlaybackSrc] = useState<string | null>(null);
const [webPlaybackError, setWebPlaybackError] = useState<string | null>(null);
@@ -219,6 +220,7 @@ export function PhotoViewerClient({
const newPhoto = allPhotos[currentIdx];
if (newPhoto && newPhoto.id !== currentPhoto.id) {
setImageLoading(true);
setFullImageReady(false);
const normalizedPhoto = normalizePhoto(newPhoto);
setCurrentPhoto(normalizedPhoto);
setHoveredFace(null); // Reset face detection when photo changes
@@ -238,6 +240,15 @@ export function PhotoViewerClient({
}
}, [currentIdx, allPhotos, currentPhoto.id]);
// Progressive load: show thumbnail until full image is ready (local files only)
useEffect(() => {
if (isVideo(currentPhoto) || isUrl(currentPhoto.path)) {
return;
}
setImageLoading(true);
setFullImageReady(false);
}, [currentPhoto.id, currentPhoto.path]);
// Debug: Log photo data structure when currentPhoto changes
useEffect(() => {
console.log('[PhotoViewerClient] Current photo changed:', {
@@ -733,6 +744,7 @@ export function PhotoViewerClient({
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
setImageLoading(false);
setFullImageReady(true);
imageRef.current = e.currentTarget;
console.log('[PhotoViewerClient] Image loaded, imageRef set:', {
hasImageRef: !!imageRef.current,
@@ -1435,8 +1447,8 @@ export function PhotoViewerClient({
}}
>
{imageLoading && (
<div className="absolute inset-0 flex items-center justify-center text-white z-10">
Loading...
<div className="absolute inset-0 flex items-center justify-center text-white z-10 pointer-events-none">
<span className="sr-only">Loading photo</span>
</div>
)}
<div
@@ -1447,12 +1459,29 @@ export function PhotoViewerClient({
}}
className="relative h-full w-full"
>
{!isUrl(currentPhoto.path) && (
<Image
key={`${currentPhoto.id}-thumb`}
src={getImageSrc(currentPhoto, { watermark: !isLoggedIn, thumbnail: true })}
alt=""
aria-hidden
fill
className={`object-contain transition-opacity duration-200 ${
fullImageReady ? 'opacity-0' : 'opacity-100'
}`}
unoptimized
sizes="100vw"
priority
/>
)}
<Image
key={currentPhoto.id}
src={getImageSrc(currentPhoto, { watermark: !isLoggedIn })}
alt={currentPhoto.filename}
fill
className="object-contain"
className={`object-contain transition-opacity duration-300 ${
!isUrl(currentPhoto.path) && !fullImageReady ? 'opacity-0' : 'opacity-100'
}`}
priority
unoptimized={!isUrl(currentPhoto.path)}
sizes="100vw"
@@ -1,10 +1,10 @@
'use client';
import { useState } from 'react';
import { useState, type ReactNode } from 'react';
import { Person, Tag } from '@prisma/client';
import { FilterPanel, SearchFilters } from './FilterPanel';
import { Button } from '@/components/ui/button';
import { Search, ChevronLeft, ChevronRight } from 'lucide-react';
import { Search, ChevronLeft, ChevronRight, X } from 'lucide-react';
import { cn } from '@/lib/utils';
interface CollapsibleSearchProps {
@@ -14,24 +14,35 @@ interface CollapsibleSearchProps {
onFiltersChange: (filters: SearchFilters) => void;
}
export function CollapsibleSearch({ people, tags, filters, onFiltersChange }: CollapsibleSearchProps) {
const [isExpanded, setIsExpanded] = useState(true);
const hasActiveFilters =
filters.people.length > 0 ||
filters.tags.length > 0 ||
filters.dateFrom ||
filters.dateTo;
function filterBadgeCount(filters: SearchFilters) {
return (
<div
className={cn(
'flex flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground transition-all duration-300 sticky top-0 self-start',
isExpanded ? 'w-80' : 'w-16',
'h-[calc(100vh-8rem)]'
)}
>
{/* Collapse/Expand Button */}
filters.people.length +
filters.tags.length +
(filters.dateFrom || filters.dateTo ? 1 : 0) +
(filters.mediaType && filters.mediaType !== 'all' ? 1 : 0) +
(filters.favoritesOnly ? 1 : 0)
);
}
function SidebarChrome({
isExpanded,
onToggleExpanded,
hasActiveFilters,
badgeCount,
children,
onClose,
showClose,
}: {
isExpanded: boolean;
onToggleExpanded: () => void;
hasActiveFilters: boolean;
badgeCount: number;
children: ReactNode;
onClose?: () => void;
showClose?: boolean;
}) {
return (
<>
<div className="flex items-center justify-between border-b border-sidebar-border p-4 flex-shrink-0">
{isExpanded ? (
<>
@@ -40,31 +51,40 @@ export function CollapsibleSearch({ people, tags, filters, onFiltersChange }: Co
<span className="font-semibold text-sidebar-foreground">Search & Filter</span>
{hasActiveFilters && (
<span className="ml-2 rounded-full bg-sidebar-primary px-2 py-0.5 text-xs text-sidebar-primary-foreground">
{[
filters.people.length,
filters.tags.length,
filters.dateFrom || filters.dateTo ? 1 : 0,
].reduce((a, b) => a + b, 0)}
{badgeCount}
</span>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(false)}
className="h-8 w-8 p-0 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
aria-label="Collapse search and filter panel"
aria-expanded="true"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1">
{showClose && onClose && (
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-8 w-8 p-0 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground md:hidden"
aria-label="Close filters"
>
<X className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={onToggleExpanded}
className="hidden md:inline-flex h-8 w-8 p-0 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
aria-label="Collapse search and filter panel"
aria-expanded="true"
>
<ChevronLeft className="h-4 w-4" />
</Button>
</div>
</>
) : (
<div className="flex items-center justify-center w-full">
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(true)}
onClick={onToggleExpanded}
className="h-8 w-8 p-0 relative text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
title="Expand search"
aria-label="Expand search and filter panel"
@@ -78,19 +98,105 @@ export function CollapsibleSearch({ people, tags, filters, onFiltersChange }: Co
</div>
)}
</div>
{/* Expanded Filter Panel */}
{isExpanded && (
<div className="flex-1 overflow-y-auto min-h-0">
<FilterPanel
people={people}
tags={tags}
filters={filters}
onFiltersChange={onFiltersChange}
/>
</div>
<div className="flex-1 overflow-y-auto min-h-0">{children}</div>
)}
</div>
</>
);
}
export function CollapsibleSearch({ people, tags, filters, onFiltersChange }: CollapsibleSearchProps) {
const [isExpanded, setIsExpanded] = useState(true);
const [mobileOpen, setMobileOpen] = useState(false);
const hasActiveFilters =
filters.people.length > 0 ||
filters.tags.length > 0 ||
!!filters.dateFrom ||
!!filters.dateTo ||
(filters.mediaType && filters.mediaType !== 'all') ||
filters.favoritesOnly === true;
const badgeCount = filterBadgeCount(filters);
const panel = (
<FilterPanel
people={people}
tags={tags}
filters={filters}
onFiltersChange={(next) => {
onFiltersChange(next);
}}
/>
);
return (
<>
{/* Mobile: open filters drawer */}
<div className="md:hidden mb-3">
<Button
type="button"
variant="outline"
className="w-full justify-start border-sidebar-border bg-sidebar text-sidebar-foreground"
onClick={() => setMobileOpen(true)}
aria-expanded={mobileOpen}
>
<Search className="mr-2 h-4 w-4 opacity-80" />
Search &amp; Filter
{hasActiveFilters && (
<span className="ml-auto rounded-full bg-sidebar-primary px-2 py-0.5 text-xs text-sidebar-primary-foreground">
{badgeCount}
</span>
)}
</Button>
</div>
{/* Desktop sidebar */}
<div
className={cn(
'hidden md:flex flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground transition-all duration-300 sticky top-0 self-start',
isExpanded ? 'w-80' : 'w-16',
'h-[calc(100vh-8rem)]'
)}
>
<SidebarChrome
isExpanded={isExpanded}
onToggleExpanded={() => setIsExpanded((v) => !v)}
hasActiveFilters={hasActiveFilters}
badgeCount={badgeCount}
>
{panel}
</SidebarChrome>
</div>
{/* Mobile drawer */}
{mobileOpen && (
<div className="md:hidden fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label="Search and filter">
<button
type="button"
className="absolute inset-0 bg-black/50"
aria-label="Close filters"
onClick={() => setMobileOpen(false)}
/>
<div className="absolute left-0 top-0 bottom-0 flex w-[min(20rem,92vw)] flex-col bg-sidebar text-sidebar-foreground shadow-xl">
<SidebarChrome
isExpanded
onToggleExpanded={() => setMobileOpen(false)}
hasActiveFilters={hasActiveFilters}
badgeCount={badgeCount}
showClose
onClose={() => setMobileOpen(false)}
>
{panel}
</SidebarChrome>
<div className="border-t border-sidebar-border p-3">
<Button className="w-full" onClick={() => setMobileOpen(false)}>
Show results
</Button>
</div>
</div>
</div>
)}
</>
);
}