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:
Michael Mainguy 2025-08-26 14:26:55 -05:00
parent de3fa100d1
commit 31784d91b2
19 changed files with 1974 additions and 51 deletions

8
.gitignore vendored
View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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"

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View File

@ -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 })
}
}

View File

@ -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>
)

View 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>
)
}

View File

@ -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()
}
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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
View 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
View 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
View 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
}