Add SQLite database and directory management system
- Install better-sqlite3 for embedded SQLite support - Create complete database schema with photos, albums, tags, directories tables - Add PhotoService class with full CRUD operations and relationships - Create comprehensive API endpoints for photos, albums, directories, and stats - Add DirectoryList component with delete functionality and visual feedback - Implement directory saving to database when user selects path - Add automatic refresh of directory list when new directories are saved - Update Button component with enhanced enabled/disabled states and animations - Add Tab key handling to hide suggestions in directory modal - Update .gitignore to exclude SQLite database files 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
de3fa100d1
commit
31784d91b2
8
.gitignore
vendored
8
.gitignore
vendored
@ -33,4 +33,10 @@ yarn-error.log*
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
next-env.d.ts
|
||||
|
||||
# SQLite database files
|
||||
/data/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
898
package-lock.json
generated
898
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -14,6 +14,9 @@
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"@tabler/icons-react": "^3.34.1",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"glob": "^11.0.3",
|
||||
"next": "^15.5.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1"
|
||||
|
30
src/app/api/albums/route.ts
Normal file
30
src/app/api/albums/route.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { photoService } from '@/lib/photo-service'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const albums = photoService.getAlbums()
|
||||
return NextResponse.json({ albums })
|
||||
} catch (error) {
|
||||
console.error('Error fetching albums:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch albums' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const albumData = await request.json()
|
||||
const album = photoService.createAlbum(albumData)
|
||||
|
||||
return NextResponse.json(album, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error creating album:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create album' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
38
src/app/api/directories/[id]/route.ts
Normal file
38
src/app/api/directories/[id]/route.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { photoService } from '@/lib/photo-service'
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
// Get the directory first to check if it exists
|
||||
const directory = photoService.getDirectory(id)
|
||||
if (!directory) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Directory not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Delete the directory record
|
||||
const success = photoService.deleteDirectory(id)
|
||||
|
||||
if (!success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete directory' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error deleting directory:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete directory' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
47
src/app/api/directories/route.ts
Normal file
47
src/app/api/directories/route.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { photoService } from '@/lib/photo-service'
|
||||
import path from 'path'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const directories = photoService.getDirectories()
|
||||
return NextResponse.json({ directories })
|
||||
} catch (error) {
|
||||
console.error('Error fetching directories:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch directories' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { path: directoryPath } = await request.json()
|
||||
|
||||
if (!directoryPath || typeof directoryPath !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Directory path is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create directory record
|
||||
const directoryName = path.basename(directoryPath)
|
||||
const directory = photoService.createOrUpdateDirectory({
|
||||
path: directoryPath,
|
||||
name: directoryName,
|
||||
last_scanned: new Date().toISOString(),
|
||||
photo_count: 0,
|
||||
total_size: 0
|
||||
})
|
||||
|
||||
return NextResponse.json(directory, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error saving directory:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save directory' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
79
src/app/api/photos/[id]/route.ts
Normal file
79
src/app/api/photos/[id]/route.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { photoService } from '@/lib/photo-service'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const photo = photoService.getPhoto(id)
|
||||
|
||||
if (!photo) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Photo not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(photo)
|
||||
} catch (error) {
|
||||
console.error('Error fetching photo:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch photo' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const updates = await request.json()
|
||||
|
||||
const photo = photoService.updatePhoto(id, updates)
|
||||
|
||||
if (!photo) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Photo not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(photo)
|
||||
} catch (error) {
|
||||
console.error('Error updating photo:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update photo' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const success = photoService.deletePhoto(id)
|
||||
|
||||
if (!success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Photo not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error deleting photo:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete photo' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
56
src/app/api/photos/route.ts
Normal file
56
src/app/api/photos/route.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { photoService } from '@/lib/photo-service'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
||||
const options = {
|
||||
directory: searchParams.get('directory') || undefined,
|
||||
limit: searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : undefined,
|
||||
offset: searchParams.get('offset') ? parseInt(searchParams.get('offset')!) : undefined,
|
||||
sortBy: (searchParams.get('sortBy') as 'created_at' | 'modified_at' | 'filename' | 'filesize') || 'created_at',
|
||||
sortOrder: (searchParams.get('sortOrder') as 'ASC' | 'DESC') || 'DESC',
|
||||
favorite: searchParams.get('favorite') ? searchParams.get('favorite') === 'true' : undefined,
|
||||
rating: searchParams.get('rating') ? parseInt(searchParams.get('rating')!) : undefined
|
||||
}
|
||||
|
||||
const photos = photoService.getPhotos(options)
|
||||
|
||||
return NextResponse.json({
|
||||
photos,
|
||||
count: photos.length
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching photos:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch photos' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const photoData = await request.json()
|
||||
|
||||
// Check if photo already exists
|
||||
const existingPhoto = photoService.getPhotoByPath(photoData.filepath)
|
||||
if (existingPhoto) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Photo already exists', photo: existingPhoto },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const photo = photoService.createPhoto(photoData)
|
||||
|
||||
return NextResponse.json(photo, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error creating photo:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create photo' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
27
src/app/api/stats/route.ts
Normal file
27
src/app/api/stats/route.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { photoService } from '@/lib/photo-service'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const photoCount = photoService.getPhotoCount()
|
||||
const totalSize = photoService.getTotalFileSize()
|
||||
const directories = photoService.getDirectories()
|
||||
const albums = photoService.getAlbums()
|
||||
const tags = photoService.getTags()
|
||||
|
||||
return NextResponse.json({
|
||||
photoCount,
|
||||
totalSize,
|
||||
directoryCount: directories.length,
|
||||
albumCount: albums.length,
|
||||
tagCount: tags.length,
|
||||
directories: directories.slice(0, 5) // Latest 5 directories
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching stats:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch statistics' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
@ -1,7 +1,56 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { existsSync, statSync } from 'fs'
|
||||
import { glob } from 'glob'
|
||||
import path from 'path'
|
||||
|
||||
async function getSuggestions(inputPath: string): Promise<string[]> {
|
||||
try {
|
||||
const trimmedInput = inputPath.trim()
|
||||
if (!trimmedInput) return []
|
||||
|
||||
// Create glob pattern based on input
|
||||
const globPattern = `${trimmedInput}*`
|
||||
|
||||
console.log('Searching with pattern:', globPattern)
|
||||
console.log('Input path exists:', existsSync(trimmedInput))
|
||||
|
||||
// Check if the base path exists first
|
||||
if (trimmedInput.endsWith('/')) {
|
||||
const basePath = trimmedInput.slice(0, -1)
|
||||
console.log('Base path:', basePath, 'exists:', existsSync(basePath))
|
||||
}
|
||||
|
||||
// Use glob to find matching directories
|
||||
const matches = await glob(globPattern, {
|
||||
withFileTypes: false, // Return strings not Dirent objects
|
||||
absolute: true, // Return absolute paths
|
||||
dot: true, // Include hidden directories
|
||||
ignore: [], // Don't ignore any patterns
|
||||
nodir: false // Include directories
|
||||
})
|
||||
|
||||
console.log('Glob matches found:', matches.length, matches)
|
||||
|
||||
// Filter to only include directories
|
||||
const directories = matches.filter(match => {
|
||||
try {
|
||||
const isDir = statSync(match).isDirectory()
|
||||
if (isDir) console.log('Directory found:', match)
|
||||
return isDir
|
||||
} catch (error) {
|
||||
console.log('Error checking:', match, error)
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
console.log('Final directories:', directories)
|
||||
return directories.slice(0, 10) // Limit to 10 suggestions
|
||||
} catch (error) {
|
||||
console.error('getSuggestions error:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { directory } = await request.json()
|
||||
@ -9,39 +58,31 @@ export async function POST(request: NextRequest) {
|
||||
if (!directory || typeof directory !== 'string') {
|
||||
return NextResponse.json({
|
||||
valid: false,
|
||||
error: 'Directory path is required'
|
||||
error: 'Directory path is required',
|
||||
suggestions: []
|
||||
})
|
||||
}
|
||||
|
||||
const normalizedPath = path.resolve(directory.trim())
|
||||
const trimmedInput = directory.trim()
|
||||
|
||||
const exists = existsSync(normalizedPath)
|
||||
// Always get suggestions using glob search
|
||||
const suggestions = await getSuggestions(trimmedInput)
|
||||
|
||||
if (!exists) {
|
||||
return NextResponse.json({
|
||||
valid: false,
|
||||
error: 'Directory does not exist'
|
||||
})
|
||||
}
|
||||
|
||||
const stats = statSync(normalizedPath)
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
return NextResponse.json({
|
||||
valid: false,
|
||||
error: 'Path is not a directory'
|
||||
})
|
||||
}
|
||||
// Check if the input exactly matches an existing directory
|
||||
const normalizedPath = path.resolve(trimmedInput)
|
||||
const isValid = existsSync(normalizedPath) && statSync(normalizedPath).isDirectory()
|
||||
|
||||
return NextResponse.json({
|
||||
valid: true,
|
||||
path: normalizedPath
|
||||
valid: isValid,
|
||||
path: isValid ? normalizedPath : undefined,
|
||||
suggestions
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
valid: false,
|
||||
error: 'Invalid directory path'
|
||||
error: 'Invalid directory path',
|
||||
suggestions: []
|
||||
}, { status: 400 })
|
||||
}
|
||||
}
|
@ -3,25 +3,39 @@ import { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary'
|
||||
children: ReactNode
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export default function Button({
|
||||
variant = 'primary',
|
||||
children,
|
||||
className = '',
|
||||
enabled = true,
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const baseClasses = 'px-12 py-2 rounded font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2'
|
||||
// Determine if button should be disabled
|
||||
const isDisabled = disabled || !enabled
|
||||
|
||||
const baseClasses = 'px-12 py-2 rounded font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 transform'
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'bg-blue-600 text-white hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed focus:ring-blue-500',
|
||||
secondary: 'border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 focus:ring-gray-500'
|
||||
primary: isDisabled
|
||||
? 'bg-gray-400 text-gray-200 cursor-not-allowed opacity-60'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700 hover:scale-105 active:scale-95 focus:ring-blue-500 shadow-md hover:shadow-lg',
|
||||
secondary: isDisabled
|
||||
? 'border border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-600 bg-gray-100 dark:bg-gray-800 cursor-not-allowed opacity-60'
|
||||
: 'border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 hover:scale-105 active:scale-95 focus:ring-gray-500 shadow-sm hover:shadow-md'
|
||||
}
|
||||
|
||||
const finalClasses = `${baseClasses} ${variantClasses[variant]} ${className}`
|
||||
|
||||
return (
|
||||
<button className={finalClasses} {...props}>
|
||||
<button
|
||||
className={finalClasses}
|
||||
disabled={isDisabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
|
175
src/components/DirectoryList.tsx
Normal file
175
src/components/DirectoryList.tsx
Normal file
@ -0,0 +1,175 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { IconFolder, IconClock, IconTrash } from '@tabler/icons-react'
|
||||
import { Directory } from '@/types/photo'
|
||||
import Button from "@/components/Button";
|
||||
|
||||
interface DirectoryListProps {
|
||||
onDirectorySelect?: (directory: Directory) => void
|
||||
selectedDirectory?: string
|
||||
refreshTrigger?: number // Add a trigger to force refresh
|
||||
}
|
||||
|
||||
export default function DirectoryList({ onDirectorySelect, selectedDirectory, refreshTrigger }: DirectoryListProps) {
|
||||
const [directories, setDirectories] = useState<Directory[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchDirectories = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await fetch('/api/directories')
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch directories')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setDirectories(data.directories || [])
|
||||
setError(null)
|
||||
} catch (error) {
|
||||
console.error('Error fetching directories:', error)
|
||||
setError('Failed to load directories')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteDirectory = async (directoryId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/directories/${directoryId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete directory')
|
||||
}
|
||||
|
||||
// Refresh the list
|
||||
fetchDirectories()
|
||||
} catch (error) {
|
||||
console.error('Error deleting directory:', error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchDirectories()
|
||||
}, [refreshTrigger])
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
|
||||
Saved Directories
|
||||
</h3>
|
||||
<div className="animate-pulse space-y-2">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-12 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
|
||||
Saved Directories
|
||||
</h3>
|
||||
<div className="text-sm text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
|
||||
Saved Directories
|
||||
</h3>
|
||||
|
||||
{directories.length === 0 ? (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
No directories saved yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{directories.map((directory) => (
|
||||
<div
|
||||
key={directory.id}
|
||||
className={`group relative rounded-lg p-3 cursor-pointer transition-colors ${
|
||||
selectedDirectory === directory.path
|
||||
? 'bg-blue-100 dark:bg-blue-900/30'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
onClick={() => onDirectorySelect?.(directory)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-2 min-w-0 flex-1">
|
||||
<IconFolder className="h-4 w-4 text-gray-400 dark:text-gray-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{directory.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{directory.path}
|
||||
</div>
|
||||
<Button enabled={false}>Scan</Button>
|
||||
<Button enabled={false}>Remove</Button>
|
||||
<div className="flex items-center space-x-3 mt-1">
|
||||
<div className="flex items-center space-x-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<IconClock className="h-3 w-3" />
|
||||
<span>{formatDate(directory.last_scanned)}</span>
|
||||
</div>
|
||||
{directory.photo_count > 0 && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{directory.photo_count} photos
|
||||
</div>
|
||||
)}
|
||||
{directory.total_size > 0 && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatFileSize(directory.total_size)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
deleteDirectory(directory.id)
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-all"
|
||||
title="Delete directory"
|
||||
>
|
||||
<IconTrash className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -9,9 +9,10 @@ interface DirectoryModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSave: (directory: string) => void
|
||||
onDirectoryListRefresh?: () => void
|
||||
}
|
||||
|
||||
export default function DirectoryModal({ isOpen, onClose, onSave }: DirectoryModalProps) {
|
||||
export default function DirectoryModal({ isOpen, onClose, onSave, onDirectoryListRefresh }: DirectoryModalProps) {
|
||||
const [directory, setDirectory] = useState('')
|
||||
const [isValidating, setIsValidating] = useState(false)
|
||||
const [isValid, setIsValid] = useState<boolean | null>(null)
|
||||
@ -82,6 +83,13 @@ export default function DirectoryModal({ isOpen, onClose, onSave }: DirectoryMod
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// Handle Tab key to hide suggestions if directory is valid
|
||||
if (e.key === 'Tab' && directory.trim() && isValid) {
|
||||
setShowSuggestions(false)
|
||||
setSelectedSuggestionIndex(-1)
|
||||
return // Allow default Tab behavior
|
||||
}
|
||||
|
||||
if (!showSuggestions || suggestions.length === 0) return
|
||||
|
||||
switch (e.key) {
|
||||
@ -130,12 +138,32 @@ export default function DirectoryModal({ isOpen, onClose, onSave }: DirectoryMod
|
||||
|
||||
console.log('Modal is rendering with isOpen:', isOpen)
|
||||
|
||||
const handleSave = () => {
|
||||
const handleSave = async () => {
|
||||
if (directory.trim() && isValid) {
|
||||
onSave(directory.trim())
|
||||
setDirectory('')
|
||||
setIsValid(null)
|
||||
onClose()
|
||||
try {
|
||||
// Save directory to database
|
||||
await fetch('/api/directories', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ path: directory.trim() }),
|
||||
})
|
||||
|
||||
onSave(directory.trim())
|
||||
onDirectoryListRefresh?.() // Trigger directory list refresh
|
||||
setDirectory('')
|
||||
setIsValid(null)
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('Failed to save directory:', error)
|
||||
// Still proceed with the save even if database save fails
|
||||
onSave(directory.trim())
|
||||
onDirectoryListRefresh?.() // Trigger directory list refresh
|
||||
setDirectory('')
|
||||
setIsValid(null)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,12 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import Button from './Button'
|
||||
import {useState} from "react";
|
||||
|
||||
interface HeaderProps {
|
||||
onScanDirectory: () => void
|
||||
onSelectDirectory: () => void
|
||||
}
|
||||
|
||||
export default function Header({ onScanDirectory }: HeaderProps) {
|
||||
export default function Header({ onSelectDirectory }: HeaderProps) {
|
||||
return (
|
||||
<header className="bg-white dark:bg-gray-900 shadow-sm border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
@ -19,11 +20,11 @@ export default function Header({ onScanDirectory }: HeaderProps) {
|
||||
|
||||
<nav className="md:flex space-x-8">
|
||||
<Button
|
||||
onClick={onScanDirectory}
|
||||
variant="secondary"
|
||||
className="text-sm"
|
||||
onClick={onSelectDirectory}
|
||||
variant="secondary"
|
||||
className="text-sm"
|
||||
>
|
||||
Scan Directory
|
||||
Select Directory
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
|
@ -11,17 +11,22 @@ interface MainLayoutProps {
|
||||
|
||||
export default function MainLayout({ children }: MainLayoutProps) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [directoryListRefreshTrigger, setDirectoryListRefreshTrigger] = useState(0)
|
||||
|
||||
const handleDirectorySave = (directory: string) => {
|
||||
console.log('Directory to scan:', directory)
|
||||
}
|
||||
|
||||
const handleDirectoryListRefresh = () => {
|
||||
setDirectoryListRefreshTrigger(prev => prev + 1)
|
||||
}
|
||||
|
||||
console.log('Modal state:', isModalOpen)
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header onScanDirectory={() => setIsModalOpen(true)} />
|
||||
<Header onSelectDirectory={() => setIsModalOpen(true)} />
|
||||
<div className="flex">
|
||||
<Sidebar />
|
||||
<Sidebar refreshTrigger={directoryListRefreshTrigger} />
|
||||
<main className="flex-1 min-h-screen">
|
||||
{children}
|
||||
</main>
|
||||
@ -32,6 +37,7 @@ export default function MainLayout({ children }: MainLayoutProps) {
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSave={handleDirectorySave}
|
||||
onDirectoryListRefresh={handleDirectoryListRefresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
@ -1,8 +1,29 @@
|
||||
'use client'
|
||||
export default function Sidebar() {
|
||||
|
||||
import DirectoryList from './DirectoryList'
|
||||
import { Directory } from '@/types/photo'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface SidebarProps {
|
||||
refreshTrigger?: number
|
||||
}
|
||||
|
||||
export default function Sidebar({ refreshTrigger }: SidebarProps) {
|
||||
const [selectedDirectory, setSelectedDirectory] = useState<string>()
|
||||
|
||||
const handleDirectorySelect = (directory: Directory) => {
|
||||
setSelectedDirectory(directory.path)
|
||||
// TODO: Implement photo loading for selected directory
|
||||
console.log('Selected directory:', directory)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={`w-1/4 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 transition-all duration-300 lg:block`}>
|
||||
<h2>Sidebar</h2>
|
||||
<aside className={`w-1/4 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 transition-all duration-300 lg:block overflow-y-auto`}>
|
||||
<DirectoryList
|
||||
onDirectorySelect={handleDirectorySelect}
|
||||
selectedDirectory={selectedDirectory}
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
138
src/lib/database.ts
Normal file
138
src/lib/database.ts
Normal file
@ -0,0 +1,138 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
let db: Database.Database | null = null
|
||||
|
||||
export function getDatabase(): Database.Database {
|
||||
if (db) {
|
||||
return db
|
||||
}
|
||||
|
||||
// Create data directory if it doesn't exist
|
||||
const dataDir = path.join(process.cwd(), 'data')
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Initialize database
|
||||
const dbPath = path.join(dataDir, 'photos.db')
|
||||
db = new Database(dbPath)
|
||||
|
||||
// Enable WAL mode for better performance
|
||||
db.pragma('journal_mode = WAL')
|
||||
|
||||
// Initialize tables
|
||||
initializeTables(db)
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
function initializeTables(database: Database.Database) {
|
||||
// Create photos table
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS photos (
|
||||
id TEXT PRIMARY KEY,
|
||||
filename TEXT NOT NULL,
|
||||
filepath TEXT NOT NULL UNIQUE,
|
||||
directory TEXT NOT NULL,
|
||||
filesize INTEGER NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
modified_at DATETIME NOT NULL,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
format TEXT,
|
||||
favorite BOOLEAN DEFAULT FALSE,
|
||||
rating INTEGER CHECK (rating >= 0 AND rating <= 5),
|
||||
description TEXT,
|
||||
metadata TEXT, -- JSON string
|
||||
indexed_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
|
||||
// Create albums table
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS albums (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
modified_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
cover_photo_id TEXT,
|
||||
FOREIGN KEY (cover_photo_id) REFERENCES photos (id)
|
||||
)
|
||||
`)
|
||||
|
||||
// Create photo_albums junction table (many-to-many)
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS photo_albums (
|
||||
photo_id TEXT NOT NULL,
|
||||
album_id TEXT NOT NULL,
|
||||
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (photo_id, album_id),
|
||||
FOREIGN KEY (photo_id) REFERENCES photos (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (album_id) REFERENCES albums (id) ON DELETE CASCADE
|
||||
)
|
||||
`)
|
||||
|
||||
// Create tags table
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
color TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
|
||||
// Create photo_tags junction table (many-to-many)
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS photo_tags (
|
||||
photo_id TEXT NOT NULL,
|
||||
tag_id TEXT NOT NULL,
|
||||
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (photo_id, tag_id),
|
||||
FOREIGN KEY (photo_id) REFERENCES photos (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (tag_id) REFERENCES tags (id) ON DELETE CASCADE
|
||||
)
|
||||
`)
|
||||
|
||||
// Create directories table for scan history
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS directories (
|
||||
id TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
last_scanned DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
photo_count INTEGER DEFAULT 0,
|
||||
total_size INTEGER DEFAULT 0
|
||||
)
|
||||
`)
|
||||
|
||||
// Create indexes for better performance
|
||||
database.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_directory ON photos (directory);
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_filename ON photos (filename);
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_created_at ON photos (created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_modified_at ON photos (modified_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_favorite ON photos (favorite);
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_rating ON photos (rating);
|
||||
CREATE INDEX IF NOT EXISTS idx_photo_albums_photo_id ON photo_albums (photo_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_photo_albums_album_id ON photo_albums (album_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_photo_tags_photo_id ON photo_tags (photo_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_photo_tags_tag_id ON photo_tags (tag_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_directories_path ON directories (path);
|
||||
`)
|
||||
}
|
||||
|
||||
export function closeDatabase() {
|
||||
if (db) {
|
||||
db.close()
|
||||
db = null
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('exit', closeDatabase)
|
||||
process.on('SIGINT', closeDatabase)
|
||||
process.on('SIGTERM', closeDatabase)
|
277
src/lib/photo-service.ts
Normal file
277
src/lib/photo-service.ts
Normal file
@ -0,0 +1,277 @@
|
||||
import { getDatabase } from './database'
|
||||
import { Photo, Album, Tag, Directory } from '@/types/photo'
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
export class PhotoService {
|
||||
private db = getDatabase()
|
||||
|
||||
// Photo operations
|
||||
createPhoto(photoData: Omit<Photo, 'id' | 'indexed_at'>): Photo {
|
||||
const id = randomUUID()
|
||||
const insertPhoto = this.db.prepare(`
|
||||
INSERT INTO photos (
|
||||
id, filename, filepath, directory, filesize, created_at, modified_at,
|
||||
width, height, format, favorite, rating, description, metadata
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
insertPhoto.run(
|
||||
id,
|
||||
photoData.filename,
|
||||
photoData.filepath,
|
||||
photoData.directory,
|
||||
photoData.filesize,
|
||||
photoData.created_at,
|
||||
photoData.modified_at,
|
||||
photoData.width,
|
||||
photoData.height,
|
||||
photoData.format,
|
||||
photoData.favorite,
|
||||
photoData.rating,
|
||||
photoData.description,
|
||||
photoData.metadata
|
||||
)
|
||||
|
||||
return this.getPhoto(id)!
|
||||
}
|
||||
|
||||
getPhoto(id: string): Photo | null {
|
||||
const selectPhoto = this.db.prepare('SELECT * FROM photos WHERE id = ?')
|
||||
return selectPhoto.get(id) as Photo | null
|
||||
}
|
||||
|
||||
getPhotoByPath(filepath: string): Photo | null {
|
||||
const selectPhoto = this.db.prepare('SELECT * FROM photos WHERE filepath = ?')
|
||||
return selectPhoto.get(filepath) as Photo | null
|
||||
}
|
||||
|
||||
getPhotos(options: {
|
||||
directory?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
sortBy?: 'created_at' | 'modified_at' | 'filename' | 'filesize'
|
||||
sortOrder?: 'ASC' | 'DESC'
|
||||
favorite?: boolean
|
||||
rating?: number
|
||||
} = {}): Photo[] {
|
||||
let query = 'SELECT * FROM photos WHERE 1=1'
|
||||
const params: any[] = []
|
||||
|
||||
if (options.directory) {
|
||||
query += ' AND directory = ?'
|
||||
params.push(options.directory)
|
||||
}
|
||||
|
||||
if (options.favorite !== undefined) {
|
||||
query += ' AND favorite = ?'
|
||||
params.push(options.favorite)
|
||||
}
|
||||
|
||||
if (options.rating !== undefined) {
|
||||
query += ' AND rating = ?'
|
||||
params.push(options.rating)
|
||||
}
|
||||
|
||||
const sortBy = options.sortBy || 'created_at'
|
||||
const sortOrder = options.sortOrder || 'DESC'
|
||||
query += ` ORDER BY ${sortBy} ${sortOrder}`
|
||||
|
||||
if (options.limit) {
|
||||
query += ' LIMIT ?'
|
||||
params.push(options.limit)
|
||||
}
|
||||
|
||||
if (options.offset) {
|
||||
query += ' OFFSET ?'
|
||||
params.push(options.offset)
|
||||
}
|
||||
|
||||
const selectPhotos = this.db.prepare(query)
|
||||
return selectPhotos.all(...params) as Photo[]
|
||||
}
|
||||
|
||||
updatePhoto(id: string, updates: Partial<Omit<Photo, 'id' | 'filepath' | 'indexed_at'>>): Photo | null {
|
||||
const fields = Object.keys(updates).filter(key => updates[key as keyof typeof updates] !== undefined)
|
||||
if (fields.length === 0) return this.getPhoto(id)
|
||||
|
||||
const setClause = fields.map(field => `${field} = ?`).join(', ')
|
||||
const values = fields.map(field => updates[field as keyof typeof updates])
|
||||
|
||||
const updatePhoto = this.db.prepare(`UPDATE photos SET ${setClause} WHERE id = ?`)
|
||||
updatePhoto.run(...values, id)
|
||||
|
||||
return this.getPhoto(id)
|
||||
}
|
||||
|
||||
deletePhoto(id: string): boolean {
|
||||
const deletePhoto = this.db.prepare('DELETE FROM photos WHERE id = ?')
|
||||
const result = deletePhoto.run(id)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
// Album operations
|
||||
createAlbum(albumData: Omit<Album, 'id' | 'created_at' | 'modified_at'>): Album {
|
||||
const id = randomUUID()
|
||||
const insertAlbum = this.db.prepare(`
|
||||
INSERT INTO albums (id, name, description, cover_photo_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
insertAlbum.run(id, albumData.name, albumData.description, albumData.cover_photo_id)
|
||||
return this.getAlbum(id)!
|
||||
}
|
||||
|
||||
getAlbum(id: string): Album | null {
|
||||
const selectAlbum = this.db.prepare('SELECT * FROM albums WHERE id = ?')
|
||||
return selectAlbum.get(id) as Album | null
|
||||
}
|
||||
|
||||
getAlbums(): Album[] {
|
||||
const selectAlbums = this.db.prepare('SELECT * FROM albums ORDER BY name')
|
||||
return selectAlbums.all() as Album[]
|
||||
}
|
||||
|
||||
addPhotoToAlbum(photoId: string, albumId: string): boolean {
|
||||
const insertPhotoAlbum = this.db.prepare(`
|
||||
INSERT OR IGNORE INTO photo_albums (photo_id, album_id)
|
||||
VALUES (?, ?)
|
||||
`)
|
||||
const result = insertPhotoAlbum.run(photoId, albumId)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
removePhotoFromAlbum(photoId: string, albumId: string): boolean {
|
||||
const deletePhotoAlbum = this.db.prepare(`
|
||||
DELETE FROM photo_albums WHERE photo_id = ? AND album_id = ?
|
||||
`)
|
||||
const result = deletePhotoAlbum.run(photoId, albumId)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
getAlbumPhotos(albumId: string): Photo[] {
|
||||
const selectAlbumPhotos = this.db.prepare(`
|
||||
SELECT p.* FROM photos p
|
||||
JOIN photo_albums pa ON p.id = pa.photo_id
|
||||
WHERE pa.album_id = ?
|
||||
ORDER BY pa.added_at DESC
|
||||
`)
|
||||
return selectAlbumPhotos.all(albumId) as Photo[]
|
||||
}
|
||||
|
||||
// Tag operations
|
||||
createTag(tagData: Omit<Tag, 'id' | 'created_at'>): Tag {
|
||||
const id = randomUUID()
|
||||
const insertTag = this.db.prepare(`
|
||||
INSERT INTO tags (id, name, color) VALUES (?, ?, ?)
|
||||
`)
|
||||
|
||||
insertTag.run(id, tagData.name, tagData.color)
|
||||
return this.getTag(id)!
|
||||
}
|
||||
|
||||
getTag(id: string): Tag | null {
|
||||
const selectTag = this.db.prepare('SELECT * FROM tags WHERE id = ?')
|
||||
return selectTag.get(id) as Tag | null
|
||||
}
|
||||
|
||||
getTags(): Tag[] {
|
||||
const selectTags = this.db.prepare('SELECT * FROM tags ORDER BY name')
|
||||
return selectTags.all() as Tag[]
|
||||
}
|
||||
|
||||
addPhotoTag(photoId: string, tagId: string): boolean {
|
||||
const insertPhotoTag = this.db.prepare(`
|
||||
INSERT OR IGNORE INTO photo_tags (photo_id, tag_id)
|
||||
VALUES (?, ?)
|
||||
`)
|
||||
const result = insertPhotoTag.run(photoId, tagId)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
removePhotoTag(photoId: string, tagId: string): boolean {
|
||||
const deletePhotoTag = this.db.prepare(`
|
||||
DELETE FROM photo_tags WHERE photo_id = ? AND tag_id = ?
|
||||
`)
|
||||
const result = deletePhotoTag.run(photoId, tagId)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
getPhotoTags(photoId: string): Tag[] {
|
||||
const selectPhotoTags = this.db.prepare(`
|
||||
SELECT t.* FROM tags t
|
||||
JOIN photo_tags pt ON t.id = pt.tag_id
|
||||
WHERE pt.photo_id = ?
|
||||
ORDER BY t.name
|
||||
`)
|
||||
return selectPhotoTags.all(photoId) as Tag[]
|
||||
}
|
||||
|
||||
// Directory operations
|
||||
createOrUpdateDirectory(directoryData: Omit<Directory, 'id'>): Directory {
|
||||
const existingDir = this.getDirectoryByPath(directoryData.path)
|
||||
|
||||
if (existingDir) {
|
||||
const updateDir = this.db.prepare(`
|
||||
UPDATE directories SET name = ?, last_scanned = ?, photo_count = ?, total_size = ?
|
||||
WHERE path = ?
|
||||
`)
|
||||
updateDir.run(
|
||||
directoryData.name,
|
||||
directoryData.last_scanned,
|
||||
directoryData.photo_count,
|
||||
directoryData.total_size,
|
||||
directoryData.path
|
||||
)
|
||||
return this.getDirectoryByPath(directoryData.path)!
|
||||
} else {
|
||||
const id = randomUUID()
|
||||
const insertDir = this.db.prepare(`
|
||||
INSERT INTO directories (id, path, name, last_scanned, photo_count, total_size)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
insertDir.run(
|
||||
id,
|
||||
directoryData.path,
|
||||
directoryData.name,
|
||||
directoryData.last_scanned,
|
||||
directoryData.photo_count,
|
||||
directoryData.total_size
|
||||
)
|
||||
return this.getDirectory(id)!
|
||||
}
|
||||
}
|
||||
|
||||
getDirectory(id: string): Directory | null {
|
||||
const selectDir = this.db.prepare('SELECT * FROM directories WHERE id = ?')
|
||||
return selectDir.get(id) as Directory | null
|
||||
}
|
||||
|
||||
getDirectoryByPath(path: string): Directory | null {
|
||||
const selectDir = this.db.prepare('SELECT * FROM directories WHERE path = ?')
|
||||
return selectDir.get(path) as Directory | null
|
||||
}
|
||||
|
||||
getDirectories(): Directory[] {
|
||||
const selectDirs = this.db.prepare('SELECT * FROM directories ORDER BY last_scanned DESC')
|
||||
return selectDirs.all() as Directory[]
|
||||
}
|
||||
|
||||
deleteDirectory(id: string): boolean {
|
||||
const deleteDir = this.db.prepare('DELETE FROM directories WHERE id = ?')
|
||||
const result = deleteDir.run(id)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
// Statistics
|
||||
getPhotoCount(): number {
|
||||
const countPhotos = this.db.prepare('SELECT COUNT(*) as count FROM photos')
|
||||
return (countPhotos.get() as { count: number }).count
|
||||
}
|
||||
|
||||
getTotalFileSize(): number {
|
||||
const totalSize = this.db.prepare('SELECT SUM(filesize) as total FROM photos')
|
||||
return (totalSize.get() as { total: number }).total || 0
|
||||
}
|
||||
}
|
||||
|
||||
export const photoService = new PhotoService()
|
54
src/types/photo.ts
Normal file
54
src/types/photo.ts
Normal file
@ -0,0 +1,54 @@
|
||||
export interface Photo {
|
||||
id: string
|
||||
filename: string
|
||||
filepath: string
|
||||
directory: string
|
||||
filesize: number
|
||||
created_at: string
|
||||
modified_at: string
|
||||
width?: number
|
||||
height?: number
|
||||
format?: string
|
||||
favorite: boolean
|
||||
rating?: number
|
||||
description?: string
|
||||
metadata?: string // JSON string
|
||||
indexed_at: string
|
||||
}
|
||||
|
||||
export interface Album {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
created_at: string
|
||||
modified_at: string
|
||||
cover_photo_id?: string
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: string
|
||||
name: string
|
||||
color?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Directory {
|
||||
id: string
|
||||
path: string
|
||||
name: string
|
||||
last_scanned: string
|
||||
photo_count: number
|
||||
total_size: number
|
||||
}
|
||||
|
||||
export interface PhotoAlbum {
|
||||
photo_id: string
|
||||
album_id: string
|
||||
added_at: string
|
||||
}
|
||||
|
||||
export interface PhotoTag {
|
||||
photo_id: string
|
||||
tag_id: string
|
||||
added_at: string
|
||||
}
|
Loading…
Reference in New Issue
Block a user