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
6
.gitignore
vendored
6
.gitignore
vendored
@ -34,3 +34,9 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.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": "",
|
"description": "",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tabler/icons-react": "^3.34.1",
|
"@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",
|
"next": "^15.5.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^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 { NextRequest, NextResponse } from 'next/server'
|
||||||
import { existsSync, statSync } from 'fs'
|
import { existsSync, statSync } from 'fs'
|
||||||
|
import { glob } from 'glob'
|
||||||
import path from 'path'
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { directory } = await request.json()
|
const { directory } = await request.json()
|
||||||
@ -9,39 +58,31 @@ export async function POST(request: NextRequest) {
|
|||||||
if (!directory || typeof directory !== 'string') {
|
if (!directory || typeof directory !== 'string') {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
valid: false,
|
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) {
|
// Check if the input exactly matches an existing directory
|
||||||
return NextResponse.json({
|
const normalizedPath = path.resolve(trimmedInput)
|
||||||
valid: false,
|
const isValid = existsSync(normalizedPath) && statSync(normalizedPath).isDirectory()
|
||||||
error: 'Directory does not exist'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = statSync(normalizedPath)
|
|
||||||
|
|
||||||
if (!stats.isDirectory()) {
|
|
||||||
return NextResponse.json({
|
|
||||||
valid: false,
|
|
||||||
error: 'Path is not a directory'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
valid: true,
|
valid: isValid,
|
||||||
path: normalizedPath
|
path: isValid ? normalizedPath : undefined,
|
||||||
|
suggestions
|
||||||
})
|
})
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
valid: false,
|
valid: false,
|
||||||
error: 'Invalid directory path'
|
error: 'Invalid directory path',
|
||||||
|
suggestions: []
|
||||||
}, { status: 400 })
|
}, { status: 400 })
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -3,25 +3,39 @@ import { ButtonHTMLAttributes, ReactNode } from 'react'
|
|||||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
variant?: 'primary' | 'secondary'
|
variant?: 'primary' | 'secondary'
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
enabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Button({
|
export default function Button({
|
||||||
variant = 'primary',
|
variant = 'primary',
|
||||||
children,
|
children,
|
||||||
className = '',
|
className = '',
|
||||||
|
enabled = true,
|
||||||
|
disabled,
|
||||||
...props
|
...props
|
||||||
}: ButtonProps) {
|
}: 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 = {
|
const variantClasses = {
|
||||||
primary: 'bg-blue-600 text-white hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed focus:ring-blue-500',
|
primary: isDisabled
|
||||||
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'
|
? '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}`
|
const finalClasses = `${baseClasses} ${variantClasses[variant]} ${className}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button className={finalClasses} {...props}>
|
<button
|
||||||
|
className={finalClasses}
|
||||||
|
disabled={isDisabled}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</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
|
isOpen: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSave: (directory: string) => 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 [directory, setDirectory] = useState('')
|
||||||
const [isValidating, setIsValidating] = useState(false)
|
const [isValidating, setIsValidating] = useState(false)
|
||||||
const [isValid, setIsValid] = useState<boolean | null>(null)
|
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>) => {
|
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
|
if (!showSuggestions || suggestions.length === 0) return
|
||||||
|
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
@ -130,12 +138,32 @@ export default function DirectoryModal({ isOpen, onClose, onSave }: DirectoryMod
|
|||||||
|
|
||||||
console.log('Modal is rendering with isOpen:', isOpen)
|
console.log('Modal is rendering with isOpen:', isOpen)
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = async () => {
|
||||||
if (directory.trim() && isValid) {
|
if (directory.trim() && isValid) {
|
||||||
|
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())
|
onSave(directory.trim())
|
||||||
|
onDirectoryListRefresh?.() // Trigger directory list refresh
|
||||||
setDirectory('')
|
setDirectory('')
|
||||||
setIsValid(null)
|
setIsValid(null)
|
||||||
onClose()
|
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'
|
'use client'
|
||||||
|
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
|
import {useState} from "react";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
onScanDirectory: () => void
|
onSelectDirectory: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Header({ onScanDirectory }: HeaderProps) {
|
export default function Header({ onSelectDirectory }: HeaderProps) {
|
||||||
return (
|
return (
|
||||||
<header className="bg-white dark:bg-gray-900 shadow-sm border-b border-gray-200 dark:border-gray-700">
|
<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">
|
<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">
|
<nav className="md:flex space-x-8">
|
||||||
<Button
|
<Button
|
||||||
onClick={onScanDirectory}
|
onClick={onSelectDirectory}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
>
|
>
|
||||||
Scan Directory
|
Select Directory
|
||||||
</Button>
|
</Button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
@ -11,17 +11,22 @@ interface MainLayoutProps {
|
|||||||
|
|
||||||
export default function MainLayout({ children }: MainLayoutProps) {
|
export default function MainLayout({ children }: MainLayoutProps) {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
|
const [directoryListRefreshTrigger, setDirectoryListRefreshTrigger] = useState(0)
|
||||||
|
|
||||||
const handleDirectorySave = (directory: string) => {
|
const handleDirectorySave = (directory: string) => {
|
||||||
console.log('Directory to scan:', directory)
|
console.log('Directory to scan:', directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDirectoryListRefresh = () => {
|
||||||
|
setDirectoryListRefreshTrigger(prev => prev + 1)
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Modal state:', isModalOpen)
|
console.log('Modal state:', isModalOpen)
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
<Header onScanDirectory={() => setIsModalOpen(true)} />
|
<Header onSelectDirectory={() => setIsModalOpen(true)} />
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<Sidebar />
|
<Sidebar refreshTrigger={directoryListRefreshTrigger} />
|
||||||
<main className="flex-1 min-h-screen">
|
<main className="flex-1 min-h-screen">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
@ -32,6 +37,7 @@ export default function MainLayout({ children }: MainLayoutProps) {
|
|||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
onClose={() => setIsModalOpen(false)}
|
onClose={() => setIsModalOpen(false)}
|
||||||
onSave={handleDirectorySave}
|
onSave={handleDirectorySave}
|
||||||
|
onDirectoryListRefresh={handleDirectoryListRefresh}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,8 +1,29 @@
|
|||||||
'use client'
|
'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 (
|
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`}>
|
<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`}>
|
||||||
<h2>Sidebar</h2>
|
<DirectoryList
|
||||||
|
onDirectorySelect={handleDirectorySelect}
|
||||||
|
selectedDirectory={selectedDirectory}
|
||||||
|
refreshTrigger={refreshTrigger}
|
||||||
|
/>
|
||||||
</aside>
|
</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