Sprint B: mobile filters, touch actions, skeleton grid, progressive viewer.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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 & 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user