PunimTag Web Application - Major Feature Release #1
@ -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:
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -10,7 +10,9 @@ dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
# Python lib directories (but not viewer-frontend/lib/)
|
||||
lib/
|
||||
!viewer-frontend/lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
|
||||
@ -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 │
|
||||
│ ┌──────────────────────────────┬──────────────────────────────────┐ │
|
||||
|
||||
@ -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)'",
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 (
|
||||
<Suspense fallback={
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
|
||||
Sign in to your account
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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() {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<HomePageContent initialPhotos={serializePhotos(photos)} people={serializePeople(people)} tags={serializeTags(tags)} />
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<HomePageContent initialPhotos={serializePhotos(photos)} people={serializePeople(people)} tags={serializeTags(tags)} />
|
||||
</Suspense>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 (
|
||||
<Suspense fallback={
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
|
||||
Reset your password
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<ResetPasswordForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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' },
|
||||
});
|
||||
|
||||
@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
@ -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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black">
|
||||
@ -143,9 +143,9 @@ export function PhotoViewer({ photo, previousId, nextId }: PhotoViewerProps) {
|
||||
<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.dateTaken && (
|
||||
{photo.date_taken && (
|
||||
<p className="text-sm text-gray-300 mb-2">
|
||||
{new Date(photo.dateTaken).toLocaleDateString('en-US', {
|
||||
{new Date(photo.date_taken).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
|
||||
@ -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({
|
||||
>
|
||||
<div className="container mx-auto p-6 pointer-events-auto">
|
||||
<h2 className="text-xl font-semibold mb-2">{currentPhoto.filename}</h2>
|
||||
{currentPhoto.dateTaken && (
|
||||
{currentPhoto.date_taken && (
|
||||
<p className="text-sm text-gray-300 mb-2">
|
||||
{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}
|
||||
/>
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
<span className="text-sm">{tag.tagName}</span>
|
||||
<span className="text-sm">{tag.tag_name}</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
@ -214,7 +214,7 @@ export function TagSelectionDialog({
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<TagIcon className="h-3 w-3" />
|
||||
{tag.tagName}
|
||||
{tag.tag_name}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -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
|
||||
/>
|
||||
</span>
|
||||
<label className="flex-1 cursor-pointer text-sm">
|
||||
{person.firstName} {person.lastName}
|
||||
{person.first_name} {person.last_name}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
@ -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}
|
||||
<button
|
||||
onClick={() => togglePerson(person.id)}
|
||||
className="ml-1 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||
|
||||
@ -22,8 +22,14 @@ export function TagFilter({ tags, selected, mode, onSelectionChange, onModeChang
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Helper to safely get tag name, handling potential type mismatches
|
||||
const getTagName = (tag: Tag | any): string => {
|
||||
// Try multiple possible field names
|
||||
return tag.tag_name || tag.tagName || tag.name || '';
|
||||
};
|
||||
|
||||
const filteredTags = tags.filter((tag) => {
|
||||
const tagName = tag.tagName || tag.tag_name || '';
|
||||
const tagName = getTagName(tag);
|
||||
return tagName.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
});
|
||||
|
||||
@ -91,7 +97,7 @@ export function TagFilter({ tags, selected, mode, onSelectionChange, onModeChang
|
||||
/>
|
||||
</span>
|
||||
<label className="flex-1 cursor-pointer text-sm">
|
||||
{tag.tagName || tag.tag_name || 'Unnamed Tag'}
|
||||
{getTagName(tag) || 'Unnamed Tag'}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
@ -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'}
|
||||
<button
|
||||
onClick={() => toggleTag(tag.id)}
|
||||
className="ml-1 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||
|
||||
44
viewer-frontend/lib/db.ts
Normal file
44
viewer-frontend/lib/db.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaClient as PrismaClientAuth } from '../node_modules/.prisma/client-auth';
|
||||
|
||||
const globalForPrisma = global as unknown as {
|
||||
prisma: PrismaClient;
|
||||
prismaWrite: PrismaClient;
|
||||
prismaAuth: PrismaClientAuth;
|
||||
};
|
||||
|
||||
// Read-only client (uses DATABASE_URL) - connects to punimtag database
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ||
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
|
||||
});
|
||||
|
||||
// Write-capable client (uses DATABASE_URL_WRITE if available, otherwise DATABASE_URL)
|
||||
// This is kept for backward compatibility but should not be used for auth operations
|
||||
export const prismaWrite =
|
||||
globalForPrisma.prismaWrite ||
|
||||
new PrismaClient({
|
||||
datasourceUrl: process.env.DATABASE_URL_WRITE || process.env.DATABASE_URL,
|
||||
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
|
||||
});
|
||||
|
||||
// Auth client - connects to separate punimtag_auth database (uses DATABASE_URL_AUTH)
|
||||
// This database contains users and pending_identifications tables
|
||||
export const prismaAuth =
|
||||
globalForPrisma.prismaAuth ||
|
||||
new PrismaClientAuth({
|
||||
datasourceUrl: process.env.DATABASE_URL_AUTH,
|
||||
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.prisma = prisma;
|
||||
globalForPrisma.prismaWrite = prismaWrite;
|
||||
globalForPrisma.prismaAuth = prismaAuth;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
196
viewer-frontend/lib/email.ts
Normal file
196
viewer-frontend/lib/email.ts
Normal file
@ -0,0 +1,196 @@
|
||||
import { Resend } from 'resend';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
export function generateEmailConfirmationToken(): string {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
export async function sendEmailConfirmation(
|
||||
email: string,
|
||||
name: string,
|
||||
token: string
|
||||
): Promise<void> {
|
||||
const baseUrl = process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3001';
|
||||
const confirmationUrl = `${baseUrl}/api/auth/verify-email?token=${token}`;
|
||||
|
||||
try {
|
||||
await resend.emails.send({
|
||||
from: process.env.RESEND_FROM_EMAIL || 'onboarding@resend.dev',
|
||||
to: email,
|
||||
subject: 'Confirm your email address',
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Confirm your email</title>
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<div style="background-color: #f8f9fa; padding: 30px; border-radius: 8px;">
|
||||
<h1 style="color: #2563eb; margin-top: 0;">Confirm your email address</h1>
|
||||
<p>Hi ${name},</p>
|
||||
<p>Thank you for signing up! Please confirm your email address by clicking the button below:</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${confirmationUrl}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; font-weight: bold;">Confirm Email Address</a>
|
||||
</div>
|
||||
<p>Or copy and paste this link into your browser:</p>
|
||||
<p style="word-break: break-all; color: #666; font-size: 14px;">${confirmationUrl}</p>
|
||||
<p style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb; color: #6b7280; font-size: 14px;">
|
||||
If you didn't create an account, you can safely ignore this email.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error sending confirmation email:', error);
|
||||
throw new Error('Failed to send confirmation email');
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendEmailConfirmationResend(
|
||||
email: string,
|
||||
name: string,
|
||||
token: string
|
||||
): Promise<void> {
|
||||
const baseUrl = process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3001';
|
||||
const confirmationUrl = `${baseUrl}/api/auth/verify-email?token=${token}`;
|
||||
|
||||
try {
|
||||
await resend.emails.send({
|
||||
from: process.env.RESEND_FROM_EMAIL || 'onboarding@resend.dev',
|
||||
to: email,
|
||||
subject: 'Confirm your email address',
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Confirm your email</title>
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<div style="background-color: #f8f9fa; padding: 30px; border-radius: 8px;">
|
||||
<h1 style="color: #2563eb; margin-top: 0;">Confirm your email address</h1>
|
||||
<p>Hi ${name},</p>
|
||||
<p>You requested a new confirmation email. Please confirm your email address by clicking the button below:</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${confirmationUrl}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; font-weight: bold;">Confirm Email Address</a>
|
||||
</div>
|
||||
<p>Or copy and paste this link into your browser:</p>
|
||||
<p style="word-break: break-all; color: #666; font-size: 14px;">${confirmationUrl}</p>
|
||||
<p style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb; color: #6b7280; font-size: 14px;">
|
||||
This link will expire in 24 hours. If you didn't request this email, you can safely ignore it.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error sending confirmation email:', error);
|
||||
throw new Error('Failed to send confirmation email');
|
||||
}
|
||||
}
|
||||
|
||||
export function generatePasswordResetToken(): string {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
export async function sendPasswordResetEmail(
|
||||
email: string,
|
||||
name: string,
|
||||
token: string
|
||||
): Promise<void> {
|
||||
const baseUrl = process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3001';
|
||||
const resetUrl = `${baseUrl}/reset-password?token=${token}`;
|
||||
const fromEmail = process.env.RESEND_FROM_EMAIL || 'onboarding@resend.dev';
|
||||
const replyTo = process.env.RESEND_REPLY_TO || fromEmail;
|
||||
|
||||
// Plain text version for better deliverability
|
||||
const text = `Hi ${name},
|
||||
|
||||
You requested to reset your password. Click the link below to create a new password:
|
||||
|
||||
${resetUrl}
|
||||
|
||||
This link will expire in 1 hour.
|
||||
|
||||
If you didn't request a password reset, you can safely ignore this email.
|
||||
|
||||
Best regards,
|
||||
PunimTag Viewer Team`;
|
||||
|
||||
try {
|
||||
console.log('[EMAIL] Sending password reset email:', {
|
||||
from: fromEmail,
|
||||
to: email,
|
||||
replyTo: replyTo,
|
||||
});
|
||||
|
||||
const result = await resend.emails.send({
|
||||
from: fromEmail,
|
||||
to: email,
|
||||
replyTo: replyTo,
|
||||
subject: 'Reset your password - PunimTag Viewer',
|
||||
text: text,
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<title>Reset your password</title>
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; background-color: #ffffff;">
|
||||
<div style="background-color: #f8f9fa; padding: 30px; border-radius: 8px; border: 1px solid #e5e7eb;">
|
||||
<h1 style="color: #2563eb; margin-top: 0; font-size: 24px;">Reset your password</h1>
|
||||
<p style="font-size: 16px;">Hi ${name},</p>
|
||||
<p style="font-size: 16px;">You requested to reset your password for your PunimTag Viewer account. Click the button below to create a new password:</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${resetUrl}" style="background-color: #2563eb; color: white; padding: 14px 28px; text-decoration: none; border-radius: 6px; display: inline-block; font-weight: bold; font-size: 16px;">Reset Password</a>
|
||||
</div>
|
||||
<p style="font-size: 14px; color: #666;">Or copy and paste this link into your browser:</p>
|
||||
<p style="word-break: break-all; color: #666; font-size: 14px; background-color: #ffffff; padding: 10px; border-radius: 4px; border: 1px solid #e5e7eb;">${resetUrl}</p>
|
||||
<p style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb; color: #6b7280; font-size: 14px;">
|
||||
<strong>Important:</strong> This link will expire in 1 hour for security reasons.
|
||||
</p>
|
||||
<p style="color: #6b7280; font-size: 14px;">
|
||||
If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.
|
||||
</p>
|
||||
<p style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb; color: #9ca3af; font-size: 12px;">
|
||||
This is an automated message from PunimTag Viewer. Please do not reply to this email.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error('[EMAIL] Resend API error:', result.error);
|
||||
throw new Error(`Resend API error: ${result.error.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
console.log('[EMAIL] Password reset email sent successfully:', {
|
||||
emailId: result.data?.id,
|
||||
to: email,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[EMAIL] Error sending password reset email:', error);
|
||||
console.error('[EMAIL] Error details:', {
|
||||
message: error?.message,
|
||||
name: error?.name,
|
||||
response: error?.response,
|
||||
statusCode: error?.statusCode,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
234
viewer-frontend/lib/face-utils.ts
Normal file
234
viewer-frontend/lib/face-utils.ts
Normal file
@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Utilities for face detection and location parsing
|
||||
*/
|
||||
|
||||
export interface FaceLocation {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses face location string from database
|
||||
* Supports multiple formats:
|
||||
* - JSON object: {"x": 100, "y": 200, "width": 150, "height": 150}
|
||||
* - JSON array: [100, 200, 150, 150] or [x1, y1, x2, y2]
|
||||
* - Comma-separated: "100,200,150,150" (x,y,width,height or x1,y1,x2,y2)
|
||||
*/
|
||||
export function parseFaceLocation(location: string): FaceLocation | null {
|
||||
if (!location) return null;
|
||||
|
||||
// If location is already an object (shouldn't happen but handle it)
|
||||
if (typeof location === 'object' && location !== null) {
|
||||
const loc = location as any;
|
||||
if (typeof loc.x === 'number' && typeof loc.y === 'number' &&
|
||||
typeof loc.width === 'number' && typeof loc.height === 'number') {
|
||||
return { x: loc.x, y: loc.y, width: loc.width, height: loc.height };
|
||||
}
|
||||
}
|
||||
|
||||
// If it's not a string, try to convert
|
||||
const locationStr = String(location).trim();
|
||||
if (!locationStr) return null;
|
||||
|
||||
try {
|
||||
// Try JSON format first (object or array)
|
||||
const parsed = JSON.parse(locationStr);
|
||||
|
||||
// Handle JSON object: {"x": 100, "y": 200, "width": 150, "height": 150}
|
||||
// or {"x": 100, "y": 200, "w": 150, "h": 150}
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
// Handle width/height format
|
||||
if (
|
||||
typeof parsed.x === 'number' &&
|
||||
typeof parsed.y === 'number' &&
|
||||
typeof parsed.width === 'number' &&
|
||||
typeof parsed.height === 'number'
|
||||
) {
|
||||
return {
|
||||
x: parsed.x,
|
||||
y: parsed.y,
|
||||
width: parsed.width,
|
||||
height: parsed.height,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle w/h format (shorthand)
|
||||
if (
|
||||
typeof parsed.x === 'number' &&
|
||||
typeof parsed.y === 'number' &&
|
||||
typeof parsed.w === 'number' &&
|
||||
typeof parsed.h === 'number'
|
||||
) {
|
||||
return {
|
||||
x: parsed.x,
|
||||
y: parsed.y,
|
||||
width: parsed.w,
|
||||
height: parsed.h,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle object with x1, y1, x2, y2 format
|
||||
if (
|
||||
typeof parsed.x1 === 'number' &&
|
||||
typeof parsed.y1 === 'number' &&
|
||||
typeof parsed.x2 === 'number' &&
|
||||
typeof parsed.y2 === 'number'
|
||||
) {
|
||||
return {
|
||||
x: parsed.x1,
|
||||
y: parsed.y1,
|
||||
width: parsed.x2 - parsed.x1,
|
||||
height: parsed.y2 - parsed.y1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Handle JSON array: [x, y, width, height] or [x1, y1, x2, y2]
|
||||
if (Array.isArray(parsed) && parsed.length === 4) {
|
||||
const [a, b, c, d] = parsed.map(Number);
|
||||
if (parsed.every((n: any) => typeof n === 'number' && !isNaN(n))) {
|
||||
// Check if it's x1,y1,x2,y2 format (width/height would be negative if x2<x1 or y2<y1)
|
||||
// Or if values suggest it's coordinates rather than size
|
||||
if (c > a && d > b) {
|
||||
// Likely x1, y1, x2, y2 format
|
||||
return {
|
||||
x: a,
|
||||
y: b,
|
||||
width: c - a,
|
||||
height: d - b,
|
||||
};
|
||||
} else {
|
||||
// Likely x, y, width, height format
|
||||
return {
|
||||
x: a,
|
||||
y: b,
|
||||
width: c,
|
||||
height: d,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, try comma-separated format
|
||||
}
|
||||
|
||||
// Try comma-separated format: "x,y,width,height" or "x1,y1,x2,y2"
|
||||
const parts = locationStr.split(',').map((s) => s.trim()).map(Number);
|
||||
if (parts.length === 4 && parts.every((n) => !isNaN(n))) {
|
||||
const [a, b, c, d] = parts;
|
||||
// Check if it's x1,y1,x2,y2 format (width/height would be negative if x2<x1 or y2<y1)
|
||||
if (c > a && d > b) {
|
||||
// Likely x1, y1, x2, y2 format
|
||||
return {
|
||||
x: a,
|
||||
y: b,
|
||||
width: c - a,
|
||||
height: d - b,
|
||||
};
|
||||
} else {
|
||||
// Likely x, y, width, height format
|
||||
return {
|
||||
x: a,
|
||||
y: b,
|
||||
width: c,
|
||||
height: d,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a mouse point is within a face bounding box
|
||||
* Handles image scaling (object-fit: cover) correctly
|
||||
*/
|
||||
export function isPointInFace(
|
||||
mouseX: number,
|
||||
mouseY: number,
|
||||
faceLocation: FaceLocation,
|
||||
imageNaturalWidth: number,
|
||||
imageNaturalHeight: number,
|
||||
containerWidth: number,
|
||||
containerHeight: number
|
||||
): boolean {
|
||||
return isPointInFaceWithFit(
|
||||
mouseX,
|
||||
mouseY,
|
||||
faceLocation,
|
||||
imageNaturalWidth,
|
||||
imageNaturalHeight,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
'cover'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a mouse point is within a face bounding box
|
||||
* Handles both object-fit: cover and object-fit: contain
|
||||
*/
|
||||
export function isPointInFaceWithFit(
|
||||
mouseX: number,
|
||||
mouseY: number,
|
||||
faceLocation: FaceLocation,
|
||||
imageNaturalWidth: number,
|
||||
imageNaturalHeight: number,
|
||||
containerWidth: number,
|
||||
containerHeight: number,
|
||||
objectFit: 'cover' | 'contain' = 'cover'
|
||||
): boolean {
|
||||
if (!imageNaturalWidth || !imageNaturalHeight) return false;
|
||||
|
||||
const imageAspect = imageNaturalWidth / imageNaturalHeight;
|
||||
const containerAspect = containerWidth / containerHeight;
|
||||
|
||||
let scale: number;
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
||||
if (objectFit === 'cover') {
|
||||
// object-fit: cover - image covers entire container, may be cropped
|
||||
if (imageAspect > containerAspect) {
|
||||
// Image is wider - scale based on height
|
||||
scale = containerHeight / imageNaturalHeight;
|
||||
const scaledWidth = imageNaturalWidth * scale;
|
||||
offsetX = (containerWidth - scaledWidth) / 2;
|
||||
} else {
|
||||
// Image is taller - scale based on width
|
||||
scale = containerWidth / imageNaturalWidth;
|
||||
const scaledHeight = imageNaturalHeight * scale;
|
||||
offsetY = (containerHeight - scaledHeight) / 2;
|
||||
}
|
||||
} else {
|
||||
// object-fit: contain - image fits entirely within container, may have empty space
|
||||
if (imageAspect > containerAspect) {
|
||||
// Image is wider - scale based on width
|
||||
scale = containerWidth / imageNaturalWidth;
|
||||
const scaledHeight = imageNaturalHeight * scale;
|
||||
offsetY = (containerHeight - scaledHeight) / 2;
|
||||
} else {
|
||||
// Image is taller - scale based on height
|
||||
scale = containerHeight / imageNaturalHeight;
|
||||
const scaledWidth = imageNaturalWidth * scale;
|
||||
offsetX = (containerWidth - scaledWidth) / 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Scale face location to container coordinates
|
||||
const scaledX = faceLocation.x * scale + offsetX;
|
||||
const scaledY = faceLocation.y * scale + offsetY;
|
||||
const scaledWidth = faceLocation.width * scale;
|
||||
const scaledHeight = faceLocation.height * scale;
|
||||
|
||||
// Check if mouse is within face bounds
|
||||
return (
|
||||
mouseX >= scaledX &&
|
||||
mouseX <= scaledX + scaledWidth &&
|
||||
mouseY >= scaledY &&
|
||||
mouseY <= scaledY + scaledHeight
|
||||
);
|
||||
}
|
||||
|
||||
49
viewer-frontend/lib/permissions.ts
Normal file
49
viewer-frontend/lib/permissions.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { auth } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { prismaAuth } from './db';
|
||||
|
||||
/**
|
||||
* Check if the current user is an admin
|
||||
*/
|
||||
export async function isAdmin(): Promise<boolean> {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// First check if isAdmin is already in the session (faster, no DB query needed)
|
||||
if (session.user.isAdmin !== undefined) {
|
||||
return session.user.isAdmin === true;
|
||||
}
|
||||
|
||||
// Fallback to database query if session doesn't have isAdmin
|
||||
const userId = parseInt(session.user.id, 10);
|
||||
if (isNaN(userId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const user = await prismaAuth.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { isAdmin: true, isActive: true },
|
||||
});
|
||||
|
||||
// User must be active to have admin permissions (treat null/undefined as true)
|
||||
if (user?.isActive === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return user?.isAdmin ?? false;
|
||||
} catch (error: any) {
|
||||
console.error('[isAdmin] Error checking admin status:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user can approve identifications (admin only)
|
||||
*/
|
||||
export async function canApproveIdentifications(): Promise<boolean> {
|
||||
return isAdmin();
|
||||
}
|
||||
|
||||
59
viewer-frontend/lib/photo-utils.ts
Normal file
59
viewer-frontend/lib/photo-utils.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { Photo } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Determines if a path is a URL (http/https) or a file system path
|
||||
*/
|
||||
export function isUrl(path: string): boolean {
|
||||
return path.startsWith('http://') || path.startsWith('https://');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if photo is a video
|
||||
*/
|
||||
export function isVideo(photo: Photo): boolean {
|
||||
// Handle both camelCase (Prisma client) and snake_case (direct DB access)
|
||||
return (photo as any).mediaType === 'video' || (photo as any).media_type === 'video';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the appropriate image source URL
|
||||
* - URLs (SharePoint, CDN, etc.) → use directly
|
||||
* - File system paths → use API proxy
|
||||
* - Videos → use thumbnail endpoint (for grid display)
|
||||
*/
|
||||
export function getImageSrc(photo: Photo, options?: { watermark?: boolean; thumbnail?: boolean }): string {
|
||||
// For videos, use thumbnail endpoint if requested (for grid display)
|
||||
if (options?.thumbnail && isVideo(photo)) {
|
||||
return `/api/photos/${photo.id}/image?thumbnail=true`;
|
||||
}
|
||||
|
||||
if (isUrl(photo.path)) {
|
||||
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
|
||||
console.log(`✅ Photo ${photo.id}: Using DIRECT access for URL:`, photo.path);
|
||||
}
|
||||
return photo.path;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (options?.watermark) {
|
||||
params.set('watermark', 'true');
|
||||
}
|
||||
const query = params.toString();
|
||||
|
||||
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
|
||||
console.log(`📁 Photo ${photo.id}: Using API PROXY for file path:`, photo.path);
|
||||
}
|
||||
|
||||
return `/api/photos/${photo.id}/image${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the appropriate video source URL
|
||||
*/
|
||||
export function getVideoSrc(photo: Photo): string {
|
||||
if (isUrl(photo.path)) {
|
||||
return photo.path;
|
||||
}
|
||||
return `/api/photos/${photo.id}/image`;
|
||||
}
|
||||
|
||||
115
viewer-frontend/lib/queries.ts
Normal file
115
viewer-frontend/lib/queries.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { prisma } from './db';
|
||||
|
||||
export interface SearchFilters {
|
||||
people?: number[];
|
||||
tags?: number[];
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
mediaType?: 'all' | 'photos' | 'videos';
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export async function searchPhotos(filters: SearchFilters) {
|
||||
const {
|
||||
people = [],
|
||||
tags = [],
|
||||
dateFrom,
|
||||
dateTo,
|
||||
mediaType = 'all',
|
||||
page = 1,
|
||||
pageSize = 30,
|
||||
} = filters;
|
||||
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
// Build where clause
|
||||
const where: any = {
|
||||
processed: true,
|
||||
};
|
||||
|
||||
// Media type filter
|
||||
if (mediaType !== 'all') {
|
||||
if (mediaType === 'photos') {
|
||||
where.media_type = 'image';
|
||||
} else if (mediaType === 'videos') {
|
||||
where.media_type = 'video';
|
||||
}
|
||||
}
|
||||
|
||||
// Date filter
|
||||
if (dateFrom || dateTo) {
|
||||
where.date_taken = {};
|
||||
if (dateFrom) {
|
||||
where.date_taken.gte = dateFrom;
|
||||
}
|
||||
if (dateTo) {
|
||||
where.date_taken.lte = dateTo;
|
||||
}
|
||||
}
|
||||
|
||||
// People filter (photo has face with person_id in list)
|
||||
if (people.length > 0) {
|
||||
where.faces = {
|
||||
some: {
|
||||
personId: { in: people },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Tags filter
|
||||
if (tags.length > 0) {
|
||||
where.PhotoTagLinkage = {
|
||||
some: {
|
||||
tagId: { in: tags },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Execute query
|
||||
const [photos, total] = await Promise.all([
|
||||
prisma.photo.findMany({
|
||||
where,
|
||||
include: {
|
||||
Face: {
|
||||
include: {
|
||||
Person: true,
|
||||
},
|
||||
},
|
||||
PhotoTagLinkage: {
|
||||
include: {
|
||||
Tag: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { date_taken: 'desc' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.photo.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
photos,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAllPeople() {
|
||||
return prisma.person.findMany({
|
||||
orderBy: [
|
||||
{ first_name: 'asc' },
|
||||
{ last_name: 'asc' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAllTags() {
|
||||
return prisma.tag.findMany({
|
||||
orderBy: { tag_name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
233
viewer-frontend/lib/serialize.ts
Normal file
233
viewer-frontend/lib/serialize.ts
Normal file
@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Serialization utilities for converting Prisma objects to plain JavaScript objects
|
||||
* that can be safely passed from Server Components to Client Components in Next.js.
|
||||
*
|
||||
* Handles:
|
||||
* - Decimal objects -> numbers
|
||||
* - Date objects -> ISO strings
|
||||
* - Nested structures (photos, faces, people, tags)
|
||||
*/
|
||||
|
||||
type Decimal = {
|
||||
toNumber(): number;
|
||||
toString(): string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a value is a Prisma Decimal object
|
||||
*/
|
||||
function isDecimal(value: any): value is Decimal {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
typeof value.toNumber === 'function' &&
|
||||
typeof value.toString === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Decimal to a number, handling null/undefined
|
||||
*/
|
||||
function decimalToNumber(value: any): number | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (isDecimal(value)) {
|
||||
return value.toNumber();
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
// Fallback: try to parse as number
|
||||
const parsed = Number(value);
|
||||
return isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a Date object to an ISO string
|
||||
*/
|
||||
function serializeDate(value: any): string | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a Person object
|
||||
*/
|
||||
function serializePerson(person: any): any {
|
||||
if (!person) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: person.id,
|
||||
first_name: person.first_name,
|
||||
last_name: person.last_name,
|
||||
middle_name: person.middle_name ?? null,
|
||||
maiden_name: person.maiden_name ?? null,
|
||||
date_of_birth: serializeDate(person.date_of_birth),
|
||||
created_date: serializeDate(person.created_date),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a Face object, converting Decimal fields to numbers
|
||||
*/
|
||||
function serializeFace(face: any): any {
|
||||
if (!face) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const serialized: any = {
|
||||
id: face.id,
|
||||
photo_id: face.photo_id,
|
||||
person_id: face.person_id ?? null,
|
||||
location: face.location,
|
||||
confidence: decimalToNumber(face.confidence) ?? 0,
|
||||
quality_score: decimalToNumber(face.quality_score) ?? 0,
|
||||
is_primary_encoding: face.is_primary_encoding ?? false,
|
||||
detector_backend: face.detector_backend,
|
||||
model_name: face.model_name,
|
||||
face_confidence: decimalToNumber(face.face_confidence) ?? 0,
|
||||
exif_orientation: face.exif_orientation ?? null,
|
||||
pose_mode: face.pose_mode,
|
||||
yaw_angle: decimalToNumber(face.yaw_angle),
|
||||
pitch_angle: decimalToNumber(face.pitch_angle),
|
||||
roll_angle: decimalToNumber(face.roll_angle),
|
||||
landmarks: face.landmarks ?? null,
|
||||
identified_by_user_id: face.identified_by_user_id ?? null,
|
||||
excluded: face.excluded ?? false,
|
||||
};
|
||||
|
||||
// Handle nested Person object (if present)
|
||||
if (face.Person) {
|
||||
serialized.Person = serializePerson(face.Person);
|
||||
} else if (face.person) {
|
||||
serialized.person = serializePerson(face.person);
|
||||
}
|
||||
|
||||
return serialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a Tag object
|
||||
*/
|
||||
function serializeTag(tag: any): any {
|
||||
if (!tag) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: tag.id,
|
||||
tagName: tag.tag_name || tag.tagName,
|
||||
created_date: serializeDate(tag.created_date),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a PhotoTagLinkage object
|
||||
*/
|
||||
function serializePhotoTagLinkage(linkage: any): any {
|
||||
if (!linkage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const serialized: any = {
|
||||
linkage_id: linkage.linkage_id ?? linkage.id,
|
||||
photo_id: linkage.photo_id,
|
||||
tag_id: linkage.tag_id,
|
||||
linkage_type: linkage.linkage_type ?? 0,
|
||||
created_date: serializeDate(linkage.created_date),
|
||||
};
|
||||
|
||||
// Handle nested Tag object (if present)
|
||||
if (linkage.Tag || linkage.tag) {
|
||||
serialized.tag = serializeTag(linkage.Tag || linkage.tag);
|
||||
// Also keep Tag for backward compatibility
|
||||
serialized.Tag = serialized.tag;
|
||||
}
|
||||
if (linkage.Tag || linkage.tag) {
|
||||
// Also keep Tag for backward compatibility
|
||||
serialized.Tag = serialized.tag;
|
||||
}
|
||||
|
||||
return serialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a single Photo object with all nested structures
|
||||
*/
|
||||
export function serializePhoto(photo: any): any {
|
||||
if (!photo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const serialized: any = {
|
||||
id: photo.id,
|
||||
path: photo.path,
|
||||
filename: photo.filename,
|
||||
date_added: serializeDate(photo.date_added),
|
||||
date_taken: serializeDate(photo.date_taken),
|
||||
processed: photo.processed ?? false,
|
||||
media_type: photo.media_type ?? null,
|
||||
};
|
||||
|
||||
// Handle Face array (can be named Face or faces)
|
||||
if (photo.Face && Array.isArray(photo.Face)) {
|
||||
serialized.Face = photo.Face.map((face: any) => serializeFace(face));
|
||||
} else if (photo.faces && Array.isArray(photo.faces)) {
|
||||
serialized.faces = photo.faces.map((face: any) => serializeFace(face));
|
||||
}
|
||||
|
||||
// Handle PhotoTagLinkage array (can be named PhotoTagLinkage or photoTags)
|
||||
if (photo.PhotoTagLinkage && Array.isArray(photo.PhotoTagLinkage)) {
|
||||
serialized.PhotoTagLinkage = photo.PhotoTagLinkage.map((linkage: any) =>
|
||||
serializePhotoTagLinkage(linkage)
|
||||
);
|
||||
} else if (photo.photoTags && Array.isArray(photo.photoTags)) {
|
||||
serialized.photoTags = photo.photoTags.map((linkage: any) =>
|
||||
serializePhotoTagLinkage(linkage)
|
||||
);
|
||||
}
|
||||
|
||||
return serialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes an array of Photo objects
|
||||
*/
|
||||
export function serializePhotos(photos: any[]): any[] {
|
||||
if (!Array.isArray(photos)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return photos.map((photo) => serializePhoto(photo));
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes an array of Person objects
|
||||
*/
|
||||
export function serializePeople(people: any[]): any[] {
|
||||
if (!Array.isArray(people)) {
|
||||
return [];
|
||||
}
|
||||
return people.map((person) => serializePerson(person));
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes an array of Tag objects
|
||||
*/
|
||||
export function serializeTags(tags: any[]): any[] {
|
||||
if (!Array.isArray(tags)) {
|
||||
return [];
|
||||
}
|
||||
return tags.map((tag) => serializeTag(tag));
|
||||
}
|
||||
86
viewer-frontend/lib/utils.ts
Normal file
86
viewer-frontend/lib/utils.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an email address format
|
||||
* @param email - The email address to validate
|
||||
* @returns true if the email is valid, false otherwise
|
||||
*/
|
||||
export function isValidEmail(email: string): boolean {
|
||||
if (!email || typeof email !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Trim whitespace
|
||||
const trimmedEmail = email.trim();
|
||||
|
||||
// Basic email regex pattern
|
||||
// Matches: local-part@domain
|
||||
// - Local part: alphanumeric, dots, hyphens, underscores, plus signs
|
||||
// - Domain: alphanumeric, dots, hyphens
|
||||
// - Must have @ symbol
|
||||
// - Domain must have at least one dot
|
||||
const emailRegex = /^[a-zA-Z0-9._+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||
|
||||
// Check length constraints (RFC 5321)
|
||||
if (trimmedEmail.length > 254) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for valid format
|
||||
if (!emailRegex.test(trimmedEmail)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Additional checks
|
||||
// - Cannot start or end with dot
|
||||
// - Cannot have consecutive dots
|
||||
// - Must have valid domain part
|
||||
const parts = trimmedEmail.split('@');
|
||||
if (parts.length !== 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [localPart, domain] = parts;
|
||||
|
||||
// Validate local part
|
||||
if (localPart.length === 0 || localPart.length > 64) {
|
||||
return false;
|
||||
}
|
||||
if (localPart.startsWith('.') || localPart.endsWith('.')) {
|
||||
return false;
|
||||
}
|
||||
if (localPart.includes('..')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate domain part
|
||||
if (domain.length === 0 || domain.length > 253) {
|
||||
return false;
|
||||
}
|
||||
if (domain.startsWith('.') || domain.endsWith('.')) {
|
||||
return false;
|
||||
}
|
||||
if (domain.includes('..')) {
|
||||
return false;
|
||||
}
|
||||
if (!domain.includes('.')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Domain must have at least one TLD (top-level domain)
|
||||
const domainParts = domain.split('.');
|
||||
if (domainParts.length < 2) {
|
||||
return false;
|
||||
}
|
||||
const tld = domainParts[domainParts.length - 1];
|
||||
if (tld.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
166
viewer-frontend/lib/video-thumbnail.ts
Normal file
166
viewer-frontend/lib/video-thumbnail.ts
Normal file
@ -0,0 +1,166 @@
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { readFile, writeFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Thumbnail cache directory (relative to project root)
|
||||
const THUMBNAIL_CACHE_DIR = path.join(process.cwd(), '.cache', 'video-thumbnails');
|
||||
const THUMBNAIL_QUALITY = 85; // JPEG quality
|
||||
const THUMBNAIL_MAX_WIDTH = 800; // Max width for thumbnails
|
||||
|
||||
// Ensure cache directory exists
|
||||
function ensureCacheDir() {
|
||||
if (!existsSync(THUMBNAIL_CACHE_DIR)) {
|
||||
mkdirSync(THUMBNAIL_CACHE_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get thumbnail path for a video file
|
||||
*/
|
||||
function getThumbnailPath(videoPath: string): string {
|
||||
ensureCacheDir();
|
||||
// Create a hash-like filename from the video path
|
||||
// Use a simple approach: replace path separators and special chars
|
||||
const safePath = videoPath.replace(/[^a-zA-Z0-9]/g, '_');
|
||||
const hash = Buffer.from(safePath).toString('base64').slice(0, 32);
|
||||
return path.join(THUMBNAIL_CACHE_DIR, `${hash}.jpg`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ffmpeg is available
|
||||
*/
|
||||
async function isFfmpegAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await execAsync('ffmpeg -version');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate video thumbnail using ffmpeg
|
||||
* Extracts frame at 1 second (or first frame if video is shorter)
|
||||
*/
|
||||
async function generateThumbnailWithFfmpeg(
|
||||
videoPath: string,
|
||||
thumbnailPath: string
|
||||
): Promise<Buffer> {
|
||||
// Escape paths properly for shell commands
|
||||
const escapedVideoPath = videoPath.replace(/'/g, "'\"'\"'");
|
||||
const escapedThumbnailPath = thumbnailPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
// Extract frame at 1 second, or first frame if video is shorter
|
||||
// Scale to max width while maintaining aspect ratio
|
||||
// Use -loglevel error to suppress ffmpeg output unless there's an error
|
||||
const command = `ffmpeg -i '${escapedVideoPath}' -ss 00:00:01 -vframes 1 -vf "scale=${THUMBNAIL_MAX_WIDTH}:-1" -q:v ${THUMBNAIL_QUALITY} -y '${escapedThumbnailPath}' -loglevel error`;
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(command, { maxBuffer: 10 * 1024 * 1024 });
|
||||
|
||||
if (stderr && !stderr.includes('frame=')) {
|
||||
console.warn('ffmpeg stderr:', stderr);
|
||||
}
|
||||
|
||||
// Wait a bit for file to be written
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Read the generated thumbnail
|
||||
if (existsSync(thumbnailPath)) {
|
||||
const buffer = await readFile(thumbnailPath);
|
||||
if (buffer.length > 0) {
|
||||
return buffer;
|
||||
}
|
||||
throw new Error('Thumbnail file is empty');
|
||||
}
|
||||
throw new Error('Thumbnail file was not created');
|
||||
} catch (error) {
|
||||
console.error(`Error extracting frame at 1s for ${videoPath}:`, error);
|
||||
|
||||
// If extraction at 1s fails, try first frame
|
||||
try {
|
||||
const fallbackCommand = `ffmpeg -i '${escapedVideoPath}' -vframes 1 -vf "scale=${THUMBNAIL_MAX_WIDTH}:-1" -q:v ${THUMBNAIL_QUALITY} -y '${escapedThumbnailPath}' -loglevel error`;
|
||||
const { stdout, stderr } = await execAsync(fallbackCommand, { maxBuffer: 10 * 1024 * 1024 });
|
||||
|
||||
if (stderr && !stderr.includes('frame=')) {
|
||||
console.warn('ffmpeg fallback stderr:', stderr);
|
||||
}
|
||||
|
||||
// Wait a bit for file to be written
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
if (existsSync(thumbnailPath)) {
|
||||
const buffer = await readFile(thumbnailPath);
|
||||
if (buffer.length > 0) {
|
||||
return buffer;
|
||||
}
|
||||
throw new Error('Fallback thumbnail file is empty');
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
console.error(`Error generating video thumbnail (fallback failed) for ${videoPath}:`, fallbackError);
|
||||
throw new Error(`Failed to generate video thumbnail: ${fallbackError instanceof Error ? fallbackError.message : 'Unknown error'}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate or retrieve cached video thumbnail
|
||||
* Returns thumbnail buffer if successful, null if ffmpeg is not available
|
||||
*/
|
||||
export async function getVideoThumbnail(videoPath: string): Promise<Buffer | null> {
|
||||
// Check if ffmpeg is available
|
||||
const ffmpegAvailable = await isFfmpegAvailable();
|
||||
if (!ffmpegAvailable) {
|
||||
console.warn('ffmpeg is not available. Video thumbnails will not be generated.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const thumbnailPath = getThumbnailPath(videoPath);
|
||||
|
||||
// Check if thumbnail already exists in cache
|
||||
if (existsSync(thumbnailPath)) {
|
||||
try {
|
||||
return await readFile(thumbnailPath);
|
||||
} catch (error) {
|
||||
console.error('Error reading cached thumbnail:', error);
|
||||
// Continue to regenerate
|
||||
}
|
||||
}
|
||||
|
||||
// Check if video file exists
|
||||
if (!existsSync(videoPath)) {
|
||||
console.error(`Video file not found: ${videoPath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate new thumbnail
|
||||
try {
|
||||
const thumbnailBuffer = await generateThumbnailWithFfmpeg(videoPath, thumbnailPath);
|
||||
return thumbnailBuffer;
|
||||
} catch (error) {
|
||||
console.error(`Error generating thumbnail for ${videoPath}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a thumbnail exists in cache
|
||||
*/
|
||||
export function hasCachedThumbnail(videoPath: string): boolean {
|
||||
const thumbnailPath = getThumbnailPath(videoPath);
|
||||
return existsSync(thumbnailPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached thumbnail path (for direct file serving)
|
||||
*/
|
||||
export function getCachedThumbnailPath(videoPath: string): string | null {
|
||||
const thumbnailPath = getThumbnailPath(videoPath);
|
||||
return existsSync(thumbnailPath) ? thumbnailPath : null;
|
||||
}
|
||||
|
||||
@ -30,5 +30,5 @@
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "scripts"]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user