diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5614c25..d92ade4 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -7,6 +7,12 @@ on: pull_request: types: [opened, synchronize, reopened] +# Prevent duplicate runs when pushing to a branch with an open PR +# This ensures only one workflow runs at a time for the same branch/PR +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + jobs: # Check if CI should be skipped based on branch name or commit message skip-ci-check: diff --git a/.gitignore b/.gitignore index 268717c..7b7cfb7 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,9 @@ dist/ downloads/ eggs/ .eggs/ +# Python lib directories (but not viewer-frontend/lib/) lib/ +!viewer-frontend/lib/ lib64/ parts/ sdist/ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 674c688..1b3b7af 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -74,7 +74,7 @@ PunimTag is a modern web-based photo management and tagging application with adv │ │ • /api/v1/users • /api/v1/videos │ │ │ └──────────────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────────┘ - ↕ + ↕ ┌─────────────────────────────────────────────────────────────────────────┐ │ BUSINESS LOGIC LAYER │ │ ┌──────────────────┬──────────────────┬──────────────────────────┐ │ @@ -92,7 +92,7 @@ PunimTag is a modern web-based photo management and tagging application with adv │ │ (Multi-criteria)│ (Video Processing)│ (JWT, RBAC) │ │ │ └──────────────────┴──────────────────┴──────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────────┘ - ↕ + ↕ ┌─────────────────────────────────────────────────────────────────────────┐ │ DATA ACCESS LAYER │ │ ┌──────────────────────────────────────────────────────────────────┐ │ @@ -103,7 +103,7 @@ PunimTag is a modern web-based photo management and tagging application with adv │ │ • Query optimization • Data integrity │ │ │ └──────────────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────────┘ - ↕ + ↕ ┌─────────────────────────────────────────────────────────────────────────┐ │ PERSISTENCE LAYER │ │ ┌──────────────────────────────┬──────────────────────────────────┐ │ diff --git a/package.json b/package.json index e398a00..92da120 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "type-check:viewer": "npm run type-check --prefix viewer-frontend", "lint:python": "flake8 backend --max-line-length=100 --ignore=E501,W503 || true", "lint:python:syntax": "find backend -name '*.py' -exec python -m py_compile {} \\;", - "test:backend": "export PYTHONPATH=$(pwd) && python -m pytest tests/ -v", + "test:backend": "export PYTHONPATH=$(pwd) && source venv/bin/activate && python3 -m pytest tests/ -v || python3 -m pytest tests/ -v", "test:all": "npm run test:backend", "ci:local": "npm run lint:all && npm run type-check:viewer && npm run lint:python && npm run test:backend && npm run build:all", "deploy:dev": "npm run build:all && echo '✅ Build complete. Ready for deployment to dev server (10.0.10.121)'", diff --git a/viewer-frontend/app/HomePageContent.tsx b/viewer-frontend/app/HomePageContent.tsx index 3a19e3b..23c8bc6 100644 --- a/viewer-frontend/app/HomePageContent.tsx +++ b/viewer-frontend/app/HomePageContent.tsx @@ -248,9 +248,9 @@ export function HomePageContent({ initialPhotos, people, tags }: HomePageContent // Photo is already loaded, use it directly - no database access! console.log('[HomePageContent] Using existing photo from photos array:', { photoId: existingPhoto.id, - hasFaces: !!existingPhoto.faces, + hasFaces: !!(existingPhoto as any).faces, hasFace: !!(existingPhoto as any).Face, - facesCount: existingPhoto.faces?.length || (existingPhoto as any).Face?.length || 0, + facesCount: (existingPhoto as any).faces?.length || (existingPhoto as any).Face?.length || 0, photoKeys: Object.keys(existingPhoto), }); setModalPhoto(existingPhoto); diff --git a/viewer-frontend/app/login/page.tsx b/viewer-frontend/app/login/page.tsx index ec8195c..e9e605b 100644 --- a/viewer-frontend/app/login/page.tsx +++ b/viewer-frontend/app/login/page.tsx @@ -1,13 +1,13 @@ 'use client'; -import { useState } from 'react'; +import { useState, Suspense } from 'react'; import { signIn } from 'next-auth/react'; import { useRouter, useSearchParams } from 'next/navigation'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import Link from 'next/link'; -export default function LoginPage() { +function LoginForm() { const router = useRouter(); const searchParams = useSearchParams(); const callbackUrl = searchParams.get('callbackUrl') || '/'; @@ -213,3 +213,22 @@ export default function LoginPage() { ); } +export default function LoginPage() { + return ( + +
+
+

+ Sign in to your account +

+

Loading...

+
+
+ + }> + +
+ ); +} + diff --git a/viewer-frontend/app/page.tsx b/viewer-frontend/app/page.tsx index 15134ac..bf43768 100644 --- a/viewer-frontend/app/page.tsx +++ b/viewer-frontend/app/page.tsx @@ -1,3 +1,4 @@ +import { Suspense } from 'react'; import { prisma } from '@/lib/db'; import { HomePageContent } from './HomePageContent'; import { Photo } from '@prisma/client'; @@ -229,7 +230,15 @@ export default async function HomePage() {

) : ( - + +
+

Loading...

+
+ + }> + +
)} ); diff --git a/viewer-frontend/app/photo/[id]/page.tsx b/viewer-frontend/app/photo/[id]/page.tsx index 1fffb17..7bbe307 100644 --- a/viewer-frontend/app/photo/[id]/page.tsx +++ b/viewer-frontend/app/photo/[id]/page.tsx @@ -8,14 +8,14 @@ async function getPhoto(id: number) { const photo = await prisma.photo.findUnique({ where: { id }, include: { - faces: { + Face: { include: { - person: true, + Person: true, }, }, - photoTags: { + PhotoTagLinkage: { include: { - tag: true, + Tag: true, }, }, }, @@ -36,18 +36,18 @@ async function getPhotosByIds(ids: number[]) { processed: true, }, include: { - faces: { + Face: { include: { - person: true, + Person: true, }, }, - photoTags: { + PhotoTagLinkage: { include: { - tag: true, + Tag: true, }, }, }, - orderBy: { dateTaken: 'desc' }, + orderBy: { date_taken: 'desc' }, }); return serializePhotos(photos); diff --git a/viewer-frontend/app/reset-password/page.tsx b/viewer-frontend/app/reset-password/page.tsx index dccaeaf..65b39da 100644 --- a/viewer-frontend/app/reset-password/page.tsx +++ b/viewer-frontend/app/reset-password/page.tsx @@ -1,12 +1,12 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, Suspense } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import Link from 'next/link'; -export default function ResetPasswordPage() { +function ResetPasswordForm() { const router = useRouter(); const searchParams = useSearchParams(); const token = searchParams.get('token'); @@ -177,6 +177,25 @@ export default function ResetPasswordPage() { ); } +export default function ResetPasswordPage() { + return ( + +
+
+

+ Reset your password +

+

Loading...

+
+
+ + }> + +
+ ); +} + diff --git a/viewer-frontend/app/search/page.tsx b/viewer-frontend/app/search/page.tsx index 93f6347..f83f392 100644 --- a/viewer-frontend/app/search/page.tsx +++ b/viewer-frontend/app/search/page.tsx @@ -25,13 +25,16 @@ async function getAllPeople() { if (error?.code === 'P2023' || error?.message?.includes('Conversion failed')) { console.warn('Corrupted person data detected, attempting fallback query'); try { - // Try with minimal fields first + // Try with minimal fields first, but include all required fields for type compatibility return await prisma.person.findMany({ select: { id: true, first_name: true, last_name: true, - // Exclude potentially corrupted optional fields + middle_name: true, + maiden_name: true, + date_of_birth: true, + created_date: true, }, orderBy: [ { first_name: 'asc' }, @@ -64,12 +67,12 @@ async function getAllTags() { if (error?.code === 'P2023' || error?.message?.includes('Conversion failed')) { console.warn('Corrupted tag data detected, attempting fallback query'); try { - // Try with minimal fields + // Try with minimal fields, but include all required fields for type compatibility return await prisma.tag.findMany({ select: { id: true, tag_name: true, - // Exclude potentially corrupted date field + created_date: true, }, orderBy: { tag_name: 'asc' }, }); diff --git a/viewer-frontend/app/test-images/page.tsx b/viewer-frontend/app/test-images/page.tsx index f4a444c..3ea50ab 100644 --- a/viewer-frontend/app/test-images/page.tsx +++ b/viewer-frontend/app/test-images/page.tsx @@ -17,10 +17,9 @@ export default function TestImagesPage() { id: 9991, path: 'https://picsum.photos/800/600?random=1', filename: 'test-direct-url-1.jpg', - dateAdded: new Date(), - dateTaken: null, + date_added: new Date(), + date_taken: null, processed: true, - file_hash: 'test-hash-1', media_type: 'image', }, // Test 2: Another direct URL @@ -28,10 +27,9 @@ export default function TestImagesPage() { id: 9992, path: 'https://picsum.photos/800/600?random=2', filename: 'test-direct-url-2.jpg', - dateAdded: new Date(), - dateTaken: null, + date_added: new Date(), + date_taken: null, processed: true, - file_hash: 'test-hash-2', media_type: 'image', }, // Test 3: File system path (will use API proxy) @@ -39,10 +37,9 @@ export default function TestImagesPage() { id: 9993, path: '/nonexistent/path/test.jpg', filename: 'test-file-system.jpg', - dateAdded: new Date(), - dateTaken: null, + date_added: new Date(), + date_taken: null, processed: true, - file_hash: 'test-hash-3', media_type: 'image', }, ]; diff --git a/viewer-frontend/components/PhotoViewer.tsx b/viewer-frontend/components/PhotoViewer.tsx index adb4e67..6c19370 100644 --- a/viewer-frontend/components/PhotoViewer.tsx +++ b/viewer-frontend/components/PhotoViewer.tsx @@ -72,12 +72,12 @@ export function PhotoViewer({ photo, previousId, nextId }: PhotoViewerProps) { router.back(); }; - const peopleNames = photo.faces - ?.map((face) => face.person) - .filter((person): person is Person => person !== null) - .map((person) => `${person.firstName} ${person.lastName}`.trim()) || []; + 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.photoTags?.map((pt) => pt.tag.tagName) || []; + const tags = (photo as any).PhotoTagLinkage?.map((pt: any) => pt.Tag.tag_name) || []; return (
@@ -143,9 +143,9 @@ export function PhotoViewer({ photo, previousId, nextId }: PhotoViewerProps) {

{photo.filename}

- {photo.dateTaken && ( + {photo.date_taken && (

- {new Date(photo.dateTaken).toLocaleDateString('en-US', { + {new Date(photo.date_taken).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', diff --git a/viewer-frontend/components/PhotoViewerClient.tsx b/viewer-frontend/components/PhotoViewerClient.tsx index 90571a7..e853444 100644 --- a/viewer-frontend/components/PhotoViewerClient.tsx +++ b/viewer-frontend/components/PhotoViewerClient.tsx @@ -758,9 +758,10 @@ export function PhotoViewerClient({ const face = findFaceAtPoint(e.clientX, e.clientY); if (face) { - const personName = face.person - ? `${face.person.firstName} ${face.person.lastName}`.trim() - : null; + const person = face.person as any; + const firstName = person?.first_name || person?.firstName; + const lastName = person?.last_name || person?.lastName; + const personName = firstName && lastName ? `${firstName} ${lastName}`.trim() : null; console.log('[PhotoViewerClient] handleMouseMove: Face detected on hover', { faceId: face.id, @@ -1072,12 +1073,12 @@ export function PhotoViewerClient({ } }; - const peopleNames = currentPhoto.faces - ?.map((face) => face.person) - .filter((person): person is Person => person !== null) - .map((person) => `${person.firstName} ${person.lastName}`.trim()) || []; + const peopleNames = (currentPhoto 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 = currentPhoto.photoTags?.map((pt) => pt.tag.tagName) || []; + const tags = (currentPhoto as any).PhotoTagLinkage?.map((pt: any) => pt.Tag.tag_name) || []; const hasPrevious = allPhotos.length > 0 && currentIdx > 0; const hasNext = allPhotos.length > 0 && currentIdx < allPhotos.length - 1; @@ -1481,9 +1482,9 @@ export function PhotoViewerClient({ >

{currentPhoto.filename}

- {currentPhoto.dateTaken && ( + {currentPhoto.date_taken && (

- {new Date(currentPhoto.dateTaken).toLocaleDateString('en-US', { + {new Date(currentPhoto.date_taken).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', @@ -1522,11 +1523,11 @@ export function PhotoViewerClient({ }} faceId={clickedFace.faceId} existingPerson={clickedFace.person ? { - firstName: clickedFace.person.firstName, - lastName: clickedFace.person.lastName, - middleName: clickedFace.person.middleName, - maidenName: clickedFace.person.maidenName, - dateOfBirth: clickedFace.person.dateOfBirth, + firstName: (clickedFace.person as any).first_name || (clickedFace.person as any).firstName, + lastName: (clickedFace.person as any).last_name || (clickedFace.person as any).lastName, + middleName: (clickedFace.person as any).middle_name || (clickedFace.person as any).middleName, + maidenName: (clickedFace.person as any).maiden_name || (clickedFace.person as any).maidenName, + dateOfBirth: (clickedFace.person as any).date_of_birth || (clickedFace.person as any).dateOfBirth, } : null} onSave={handleSaveFace} /> diff --git a/viewer-frontend/components/TagSelectionDialog.tsx b/viewer-frontend/components/TagSelectionDialog.tsx index ea45fe3..afaa016 100644 --- a/viewer-frontend/components/TagSelectionDialog.tsx +++ b/viewer-frontend/components/TagSelectionDialog.tsx @@ -44,7 +44,7 @@ export function TagSelectionDialog({ return tags; } const query = searchQuery.toLowerCase(); - return tags.filter((tag) => tag.tagName.toLowerCase().includes(query)); + return tags.filter((tag) => tag.tag_name.toLowerCase().includes(query)); }, [searchQuery, tags]); useEffect(() => { @@ -93,9 +93,9 @@ export function TagSelectionDialog({ setCustomTagInput(''); }; - const removeCustomTag = (tagName: string) => { + const removeCustomTag = (tag_name: string) => { setCustomTags((prev) => - prev.filter((tag) => tag.toLowerCase() !== tagName.toLowerCase()) + prev.filter((tag) => tag.toLowerCase() !== tag_name.toLowerCase()) ); }; @@ -197,7 +197,7 @@ export function TagSelectionDialog({ checked={selectedTagIds.includes(tag.id)} onCheckedChange={() => toggleTagSelection(tag.id)} /> - {tag.tagName} + {tag.tag_name} )) )} @@ -214,7 +214,7 @@ export function TagSelectionDialog({ className="flex items-center gap-1" > - {tag.tagName} + {tag.tag_name} ); })} diff --git a/viewer-frontend/components/search/PeopleFilter.tsx b/viewer-frontend/components/search/PeopleFilter.tsx index b3dc7c4..2960a65 100644 --- a/viewer-frontend/components/search/PeopleFilter.tsx +++ b/viewer-frontend/components/search/PeopleFilter.tsx @@ -24,7 +24,7 @@ export function PeopleFilter({ people, selected, mode, onSelectionChange, onMode const [open, setOpen] = useState(false); const filteredPeople = people.filter((person) => { - const fullName = `${person.firstName} ${person.lastName}`.toLowerCase(); + const fullName = `${person.first_name} ${person.last_name}`.toLowerCase(); return fullName.includes(searchQuery.toLowerCase()); }); @@ -92,7 +92,7 @@ export function PeopleFilter({ people, selected, mode, onSelectionChange, onMode />

); @@ -111,7 +111,7 @@ export function PeopleFilter({ people, selected, mode, onSelectionChange, onMode variant="secondary" className="flex items-center gap-1" > - {person.firstName} {person.lastName} + {person.first_name} {person.last_name}
); @@ -110,7 +116,7 @@ export function TagFilter({ tags, selected, mode, onSelectionChange, onModeChang variant="secondary" className="flex items-center gap-1" > - {tag.tagName || tag.tag_name || 'Unnamed Tag'} + {getTagName(tag) || 'Unnamed Tag'}