chore: Update project configuration and enhance code quality
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
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
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.
This commit is contained in:
parent
36b84fc355
commit
b6a9765315
@ -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