Tanya b6a9765315
Some checks failed
CI / skip-ci-check (push) Successful in 1m27s
CI / skip-ci-check (pull_request) Successful in 1m27s
CI / lint-and-type-check (pull_request) Has been cancelled
CI / python-lint (pull_request) Has been cancelled
CI / test-backend (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / secret-scanning (pull_request) Has been cancelled
CI / dependency-scan (pull_request) Has been cancelled
CI / sast-scan (pull_request) Has been cancelled
CI / workflow-summary (pull_request) Has been cancelled
CI / lint-and-type-check (push) Successful in 2m4s
CI / python-lint (push) Successful in 1m53s
CI / test-backend (push) Successful in 2m37s
CI / build (push) Failing after 2m13s
CI / secret-scanning (push) Successful in 1m40s
CI / dependency-scan (push) Successful in 1m34s
CI / sast-scan (push) Successful in 2m42s
CI / workflow-summary (push) Successful in 1m26s
chore: Update project configuration and enhance code quality
This commit modifies the `.gitignore` file to exclude Python library directories while ensuring the viewer-frontend's `lib` directory is not ignored. It also updates the `package.json` to activate the virtual environment during backend tests, improving the testing process. Additionally, the CI workflow is enhanced to prevent duplicate runs for branches with open pull requests. Various components in the viewer frontend are updated to ensure consistent naming conventions and improve type safety. These changes contribute to a cleaner codebase and a more efficient development workflow.
2026-01-07 12:29:17 -05:00

173 lines
4.9 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import { Photo, Person } from '@prisma/client';
import { ChevronLeft, ChevronRight, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { isUrl, getImageSrc } from '@/lib/photo-utils';
interface PhotoWithDetails extends Photo {
faces?: Array<{
person: Person | null;
}>;
photoTags?: Array<{
tag: {
tagName: string;
};
}>;
}
interface PhotoViewerProps {
photo: PhotoWithDetails;
previousId: number | null;
nextId: number | null;
}
export function PhotoViewer({ photo, previousId, nextId }: PhotoViewerProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const { data: session } = useSession();
const isLoggedIn = Boolean(session);
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft' && previousId) {
navigateToPhoto(previousId);
} else if (e.key === 'ArrowRight' && nextId) {
navigateToPhoto(nextId);
} else if (e.key === 'Escape') {
router.back();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [previousId, nextId, router]);
const navigateToPhoto = (photoId: number) => {
setLoading(true);
router.push(`/photo/${photoId}`);
};
const handlePrevious = () => {
if (previousId) {
navigateToPhoto(previousId);
}
};
const handleNext = () => {
if (nextId) {
navigateToPhoto(nextId);
}
};
const handleClose = () => {
// Use router.back() to return to the previous page without reloading
// This preserves filters, pagination, and scroll position
router.back();
};
const peopleNames = (photo as any).faces
?.map((face: any) => face.Person)
.filter((person: any): person is Person => person !== null)
.map((person: Person) => `${person.first_name} ${person.last_name}`.trim()) || [];
const tags = (photo as any).PhotoTagLinkage?.map((pt: any) => pt.Tag.tag_name) || [];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black">
{/* Close Button */}
<Button
variant="ghost"
size="icon"
className="absolute top-4 right-4 z-10 text-white hover:bg-white/20"
onClick={handleClose}
aria-label="Close"
>
<X className="h-6 w-6" />
</Button>
{/* Previous Button */}
{previousId && (
<Button
variant="ghost"
size="icon"
className="absolute left-4 z-10 text-white hover:bg-white/20 disabled:opacity-30"
onClick={handlePrevious}
disabled={loading}
aria-label="Previous photo"
>
<ChevronLeft className="h-8 w-8" />
</Button>
)}
{/* Next Button */}
{nextId && (
<Button
variant="ghost"
size="icon"
className="absolute right-4 z-10 text-white hover:bg-white/20 disabled:opacity-30"
onClick={handleNext}
disabled={loading}
aria-label="Next photo"
>
<ChevronRight className="h-8 w-8" />
</Button>
)}
{/* Photo Container */}
<div className="relative h-full w-full flex items-center justify-center p-4">
{loading ? (
<div className="text-white">Loading...</div>
) : (
<div className="relative h-full w-full max-h-[90vh] max-w-full">
<Image
src={getImageSrc(photo, { watermark: !isLoggedIn })}
alt={photo.filename}
fill
className="object-contain"
priority
unoptimized={!isUrl(photo.path)}
sizes="100vw"
/>
</div>
)}
</div>
{/* Photo Info Overlay */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-6 text-white">
<div className="container mx-auto">
<h2 className="text-xl font-semibold mb-2">{photo.filename}</h2>
{photo.date_taken && (
<p className="text-sm text-gray-300 mb-2">
{new Date(photo.date_taken).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
)}
{peopleNames.length > 0 && (
<p className="text-sm text-gray-300 mb-1">
<span className="font-medium">People: </span>
{peopleNames.join(', ')}
</p>
)}
{tags.length > 0 && (
<p className="text-sm text-gray-300">
<span className="font-medium">Tags: </span>
{tags.join(', ')}
</p>
)}
</div>
</div>
</div>
);
}