Compare commits

...

13 Commits

Author SHA1 Message Date
3427c1d17b Merge remote repository and resolve README conflict 2025-08-27 19:56:44 -05:00
b4b248925f Fix docs page layout and suppress Swagger UI React warnings
- Create ConditionalLayout component to exclude sidebar from /docs routes
- Replace direct MainLayout usage in root layout with route-based conditional rendering
- Implement SwaggerUIWrapper component to suppress React strict mode warnings
- Add comprehensive console warning filtering for UNSAFE lifecycle methods
- Update webpack configuration to ignore third-party component warnings
- Install string-replace-loader for enhanced warning suppression
- Maintain full API documentation functionality while providing clean console output

Fixes issue where docs page showed unwanted sidebar and resolves console warnings from swagger-ui-react components using deprecated React lifecycle methods.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-27 19:54:48 -05:00
4aacfb8ff1 Redesign API documentation page with custom layout template
- Replace sidebar-based layout with full-width documentation website design
- Add custom layout.tsx for /docs route without sidebar
- Implement gradient header with API branding and feature highlights
- Add statistics cards showcasing API capabilities
- Create feature highlight sections for AI Classification, Image Captioning, and Batch Processing
- Integrate Swagger UI within professionally styled container
- Add comprehensive Tailwind CSS styling with dark mode support
- Include interactive documentation footer with status indicators
- Maintain all existing functionality while improving visual presentation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-27 17:28:57 -05:00
a204168c00 Add automatic API documentation system with OpenAPI 3.0 spec
## Features Added:
- **Automatic Documentation Generation**: Uses next-swagger-doc to scan API routes
- **Interactive Swagger UI**: Try-it-out functionality for testing endpoints
- **OpenAPI 3.0 Specification**: Industry-standard API documentation format
- **Comprehensive Schemas**: Type definitions for all request/response objects

## New Documentation System:
- `/docs` - Interactive Swagger UI documentation page
- `/api/docs` - OpenAPI specification JSON endpoint
- `src/lib/swagger.ts` - Documentation configuration and schemas
- Complete JSDoc examples for batch classification endpoint

## Documentation Features:
- Real-time API testing from documentation interface
- Detailed request/response examples and schemas
- Parameter validation and error response documentation
- Organized by tags (Classification, Captioning, Tags, etc.)
- Dark/light mode support with loading states

## AI Roadmap & Guides:
- `AIROADMAP.md` - Comprehensive roadmap for future AI enhancements
- `API_DOCUMENTATION.md` - Complete guide for maintaining documentation

## Benefits:
- Documentation stays automatically synchronized with code changes
- No separate docs to maintain - generated from JSDoc comments
- Professional API documentation for integration and development
- Export capabilities for Postman, Insomnia, and other tools

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-27 17:21:53 -05:00
85c1479d94 Add comprehensive AI-powered photo analysis with dual-model classification
## Features Added:
- **Dual Model Classification**: ViT (objects) + CLIP (style/artistic concepts)
- **Image Captioning**: BLIP model for detailed photo descriptions
- **Auto-tagging**: Process all photos with configurable confidence thresholds
- **Tag Management**: Clear all tags functionality with safety confirmations
- **Comprehensive Analysis**: 15-25+ tags per image covering objects, style, mood, lighting

## New API Endpoints:
- `/api/classify/batch` - Batch classification with comprehensive mode
- `/api/classify/comprehensive` - Dual-model analysis for maximum tags
- `/api/classify/config` - Tunable classifier parameters
- `/api/caption/batch` - Batch image captioning
- `/api/tags/clear` - Clear all tags with safety checks

## UI Enhancements:
- Auto-tag All button (processes 5 photos at a time)
- Caption All button (processes 3 photos at a time)
- Clear All Tags button with confirmation dialogs
- Real-time progress bars for batch operations
- Tag pills displayed on thumbnails and image modal
- AI-generated captions shown in image modal

## Performance Optimizations:
- Uses cached thumbnails for 10-100x faster processing
- Parallel model initialization and processing
- Graceful fallback to original files when thumbnails fail
- Configurable batch sizes to prevent memory issues

## Technical Implementation:
- Vision Transformer (ViT) for ImageNet object classification (1000+ classes)
- CLIP for zero-shot artistic/style classification (photography, lighting, mood)
- BLIP for natural language image descriptions
- Comprehensive safety checks and error handling
- Database integration for persistent tag and caption storage

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-27 17:05:54 -05:00
96e6f4676a Add native Web Share API for photo sharing
- Install react-share library and add native sharing to ImageModal
- Replace Facebook-specific sharing with Web Share API for broader compatibility
- Extract GPS location from EXIF data for enhanced share text
- Support file sharing with fallbacks to URL/clipboard for unsupported devices
- Add smart share text generation with location coordinates and camera info
- Perfect for private hosts - shares actual files without external URLs
- Update CLAUDE.md with completed roadmap items and coding standards

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-27 13:04:31 -05:00
5c3ad988f5 Add duplicate detection, conflict handling, and fix pagination issues
- Add photo_conflicts table for files with same path but different content
- Implement SHA256-based duplicate detection in file scanner
- Add conflict detection methods to PhotoService
- Skip identical files with info logging, store conflicts with warnings
- Fix infinite scroll pagination race conditions with functional state updates
- Add scroll throttling to prevent rapid API calls
- Enhance PhotoThumbnail with comprehensive EXIF date/time display
- Add composite React keys to prevent duplicate rendering issues

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-27 10:55:28 -05:00
f6b651eeda Add full-resolution image modal with zoom and navigation capabilities
- Create ImageModal component with professional photo viewing experience
- Support zoom in/out, pan, rotate, and full-screen display
- Add photo navigation with arrow keys and previous/next buttons
- Include comprehensive keyboard shortcuts and visual controls
- Implement proper image centering and aspect ratio preservation
- Create full-resolution image API endpoint with range request support
- Display rich EXIF metadata in modal header with camera settings

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-27 08:58:33 -05:00
868ef2eeaa Add photo scanning with EXIF metadata extraction and thumbnail caching
- Implement file scanner with SHA256 hash-based duplicate detection
- Add Sharp-based thumbnail generation with object-contain display
- Create comprehensive photo grid with EXIF metadata overlay
- Add SQLite thumbnail blob caching for improved performance
- Support full image preview with proper aspect ratio preservation
- Include background directory scanning with progress tracking

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-27 08:35:07 -05:00
2d81844c05 Add icon support to Button component and improve DirectoryList UI
- Add leftIcon prop to Button component for Tabler icons
- Support custom icon sizes with iconSize prop (defaults to 16px)
- Update button layout with flex, gap, and responsive padding
- Add IconScan to Scan button in DirectoryList for better UX
- Improve button spacing in DirectoryList with gap-2 and mt-2
- Maintain backward compatibility for existing buttons

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-26 14:53:46 -05:00
31784d91b2 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>
2025-08-26 14:26:55 -05:00
de3fa100d1 Improve directory modal with keyboard navigation and README
- Add arrow key navigation for autosuggestion dropdown
- Add scroll-into-view for long suggestion lists
- Add enter key selection for highlighted suggestions
- Add README with macOS CIFS share access instructions

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-26 14:03:36 -05:00
c44c820239 Initial Next.js photo gallery application
- Set up Next.js 15 with TypeScript and Tailwind CSS v4
- Configured responsive layout with header, sidebar, and main content area
- Implemented directory scan modal with real-time validation
- Added reusable Button component with primary/secondary variants
- Created API endpoint for server-side directory validation
- Integrated Tabler icons for UI feedback
- Configured PostCSS with @tailwindcss/postcss for proper styling

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-26 13:24:38 -05:00
59 changed files with 14670 additions and 1 deletions

42
.gitignore vendored Normal file
View File

@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# SQLite database files
/data/
*.db
*.db-shm
*.db-wal

8
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/photos.iml" filepath="$PROJECT_DIR$/.idea/photos.iml" />
</modules>
</component>
</project>

12
.idea/photos.iml generated Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

197
AIROADMAP.md Normal file
View File

@ -0,0 +1,197 @@
# AI Roadmap for Photo Tagging, Classification, and Search
## Current State
- ✅ **Dual-Model Classification**: ViT (objects) + CLIP (style/artistic concepts)
- ✅ **Image Captioning**: BLIP for natural language descriptions
- ✅ **Batch Processing**: Auto-tag and caption entire photo libraries
- ✅ **Tag Management**: Create, clear, and organize tags with UI
- ✅ **Performance Optimized**: Thumbnail-first processing with fallbacks
## Phase 1: Enhanced Classification Models (Q1 2024)
### 1.1 Specialized Domain Models
- **Face Recognition**: Add `Xenova/face-detection` for person identification
- Detect and count faces in photos
- Age/gender estimation capabilities
- Group photos by detected people
- **Scene Classification**: `Xenova/vit-base-patch16-224-scene`
- Indoor vs outdoor scene detection
- Specific location types (kitchen, bedroom, park, etc.)
- **Emotion Detection**: Face-based emotion classification
- Happy, sad, surprised, etc. from facial expressions
### 1.2 Multi-Modal Understanding
- **OCR Integration**: `Xenova/trocr-base-printed` for text in images
- Extract text from signs, documents, screenshots
- Automatic tagging based on detected text content
- **Color Analysis**: Implement dominant color extraction
- Tag photos by color palette (warm, cool, monochrome)
- Season detection based on color analysis
- **Quality Assessment**: Technical photo quality scoring
- Blur detection, exposure analysis, composition scoring
### 1.3 Fine-tuned Photography Models
- **Photography-Specific CLIP**: Train on photography datasets
- Better understanding of camera techniques
- Lens types, shooting modes, creative effects
- **Art Style Classification**: Historical and contemporary art styles
- Renaissance, Impressionist, Modern, Street Art, etc.
## Phase 2: Advanced Search and Discovery (Q2 2024)
### 2.1 Semantic Search
- **Vector Embeddings**: Store CLIP embeddings for each photo
- Enable "find similar photos" functionality
- Search by natural language descriptions
- **Hybrid Search**: Combine text search with visual similarity
- "Find beach photos that look like this sunset"
- Cross-modal search capabilities
### 2.2 Intelligent Grouping
- **Event Detection**: Group photos by time/location/people
- Automatic album creation for trips, parties, holidays
- **Duplicate Detection**: Advanced perceptual hashing
- Find near-duplicates and variations
- Suggest best photo from similar shots
- **Series Recognition**: Detect photo sequences/bursts
- Panorama detection, HDR sequences, time-lapses
### 2.3 Content-Aware Filtering
- **Smart Collections**: AI-generated photo collections
- "Best portraits", "Golden hour photos", "Action shots"
- **Contextual Recommendations**: Suggest photos based on current view
- "More photos like this", "From the same event"
- **Quality Filtering**: Automatically hide blurry/poor quality photos
## Phase 3: Personalized AI Assistant (Q3 2024)
### 3.1 Learning User Preferences
- **Favorite Detection**: Learn what makes users favorite photos
- Personalized quality scoring
- Suggest photos to review/favorite
- **Custom Label Training**: User-specific classification
- Train on user's existing tags
- Recognize personal objects, places, people
### 3.2 Interactive Tagging
- **Tag Suggestions**: AI-powered tag recommendations during manual tagging
- **Batch Validation**: Review and approve AI-generated tags
- Confidence scoring with user feedback loop
- **Active Learning**: Improve models based on user corrections
### 3.3 Natural Language Interface
- **Query Understanding**: Parse complex natural language searches
- "Show me outdoor photos from last summer with more than 3 people"
- **Photo Descriptions**: Generate detailed alt-text for accessibility
- **Story Generation**: Create narratives from photo sequences
## Phase 4: Advanced Computer Vision (Q4 2024)
### 4.1 Object Detection and Segmentation
- **YOLO Integration**: `Xenova/yolov8n` for precise object detection
- Bounding boxes around detected objects
- Count objects in photos (5 people, 3 cars, etc.)
- **Segmentation Models**: `Xenova/sam-vit-base` for object segmentation
- Extract individual objects from photos
- Background removal capabilities
### 4.2 Spatial Understanding
- **Depth Estimation**: `Xenova/dpt-large` for depth perception
- Understand 3D structure of photos
- Foreground/background classification
- **Pose Estimation**: Human pose detection in photos
- Activity recognition (running, sitting, dancing)
- Sports/exercise classification
### 4.3 Temporal Analysis
- **Video Frame Analysis**: Extract keyframes from videos
- Apply photo AI models to video content
- **Motion Detection**: Analyze camera movement and subject motion
- **Sequence Understanding**: Understand photo relationships over time
## Phase 5: Multimodal AI Integration (2025)
### 5.1 Audio-Visual Analysis
- **Audio Classification**: For photos with associated audio/video
- Environment sounds, music, speech detection
- **Cross-Modal Retrieval**: Search photos using audio descriptions
### 5.2 3D Understanding
- **Stereo Vision**: Process photo pairs for depth information
- **3D Scene Reconstruction**: Build 3D models from photo sequences
- **AR/VR Integration**: Spatial photo organization in 3D space
### 5.3 Advanced Generation
- **Style Transfer**: Apply artistic styles to photos locally
- **Photo Enhancement**: AI-powered photo improvement
- Denoising, super-resolution, colorization
- **Creative Variants**: Generate artistic variations of photos
## Technical Implementation Strategy
### Model Selection Criteria
1. **Size Constraints**: Prioritize smaller models (<500MB each)
2. **Performance**: Ensure real-time processing on consumer hardware
3. **Accuracy**: Balance model size vs classification quality
4. **Compatibility**: Ensure Transformers.js support
### Infrastructure Enhancements
- **Model Caching**: Intelligent model loading/unloading
- **Web Workers**: Background processing to maintain UI responsiveness
- **Progressive Loading**: Load models on-demand based on user actions
- **Offline Support**: Full functionality without internet connection
### Data Management
- **Embedding Storage**: Efficient vector storage for similarity search
- **Incremental Processing**: Process only new/changed photos
- **Backup Integration**: Sync AI-generated metadata across devices
## Success Metrics
### User Experience
- **Search Accuracy**: Percentage of successful photo searches
- **Tagging Efficiency**: Reduction in manual tagging time
- **Discovery Rate**: How often users find unexpected relevant photos
### Performance
- **Processing Speed**: Photos processed per minute
- **Memory Usage**: RAM consumption during batch operations
- **Model Load Time**: Time to initialize AI models
### Quality
- **Tag Precision**: Accuracy of automatically generated tags
- **User Satisfaction**: Approval rate of AI suggestions
- **Coverage**: Percentage of photos with meaningful tags
## Resource Requirements
### Development
- **Model Research**: Evaluate and test new Transformers.js models
- **Performance Optimization**: GPU acceleration, WebGL optimizations
- **UI/UX Design**: Intuitive interfaces for AI-powered features
### Infrastructure
- **Testing Framework**: Automated testing for AI model accuracy
- **Benchmarking**: Performance testing across different hardware
- **Documentation**: User guides for AI features
## Risk Mitigation
### Privacy & Security
- **Local Processing**: All AI models run locally, no data leaves device
- **Data Encryption**: Encrypt AI-generated metadata
- **User Control**: Always allow manual override of AI decisions
### Performance
- **Graceful Degradation**: Fallback to simpler models on low-end devices
- **Memory Management**: Prevent out-of-memory errors during batch processing
- **User Feedback**: Clear progress indicators and cancellation options
### Model Updates
- **Backward Compatibility**: Ensure new models work with existing data
- **Migration Tools**: Convert between different model outputs
- **Version Management**: Track which AI models generated which tags
---
This roadmap prioritizes **local-first AI** with no cloud dependencies, ensuring privacy while delivering powerful photo organization capabilities. Each phase builds upon previous work while introducing new capabilities for comprehensive photo understanding and search.

198
API_DOCUMENTATION.md Normal file
View File

@ -0,0 +1,198 @@
# API Documentation Setup Guide
## Overview
I've set up **automatic API documentation** using `next-swagger-doc` that stays in sync with your code changes. Here's how it works:
## ✅ What's Implemented
### 1. **Documentation Generator** (`src/lib/swagger.ts`)
- Automatically scans your API routes in `src/app/api/`
- Generates OpenAPI 3.0 spec from JSDoc comments
- Includes all schemas, examples, and descriptions
### 2. **Documentation Viewer** (`/api/docs/page`)
- Interactive Swagger UI interface
- Try-it-out functionality for testing endpoints
- Dark/light mode support
### 3. **API Endpoint** (`/api/docs`)
- Serves the generated OpenAPI spec as JSON
- Can be consumed by external tools
## 🚀 Usage
### Access Documentation
Visit `http://localhost:3000/api/docs/page` to see your interactive API documentation.
### Add Documentation to New Routes
Add JSDoc comments above your route handlers:
```typescript
/**
* @swagger
* /api/your-endpoint:
* post:
* summary: Brief description of what this endpoint does
* description: Detailed description with more context
* tags: [YourTag]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* param1: { type: 'string', description: 'Parameter description' }
* param2: { type: 'number', minimum: 0, maximum: 1 }
* responses:
* 200:
* description: Success response
* content:
* application/json:
* schema:
* type: object
* properties:
* result: { type: 'string' }
* 400:
* description: Bad request
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
export async function POST(request: NextRequest) {
// Your route handler code
}
```
## 📋 Example Documentation
I've already documented the **batch classification endpoint** as an example:
```typescript
/**
* @swagger
* /api/classify/batch:
* post:
* summary: Batch classify photos using AI models
* description: Process multiple photos with AI classification using ViT for objects and optionally CLIP for artistic/style concepts.
* tags: [Classification]
* requestBody:
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/BatchClassifyRequest'
* examples:
* basic:
* summary: Basic batch classification
* value:
* limit: 10
* minConfidence: 0.3
* onlyUntagged: true
* comprehensive:
* summary: Comprehensive mode with dual models
* value:
* comprehensive: true
* minConfidence: 0.05
* maxResults: 25
*/
```
## 🎯 Benefits
### 1. **Always Up-to-Date**
- Documentation is generated from your actual code
- No separate docs to maintain
- Automatically reflects API changes
### 2. **Interactive Testing**
- Built-in "Try it out" functionality
- Test endpoints directly from documentation
- See real request/response examples
### 3. **Developer Experience**
- Comprehensive schemas and examples
- Clear parameter descriptions
- Error response documentation
### 4. **Integration Ready**
- Standard OpenAPI 3.0 format
- Can be imported into Postman, Insomnia
- Works with code generators
## 🔧 Extending Documentation
### Add More Route Documentation
For each API route, add JSDoc comments with:
1. **Summary**: One-line description
2. **Description**: Detailed explanation
3. **Tags**: Group related endpoints
4. **Parameters**: Query parameters, path parameters
5. **Request Body**: Expected input schema
6. **Responses**: All possible response codes and schemas
7. **Examples**: Real usage examples
### Custom Schemas
Define reusable schemas in `src/lib/swagger.ts`:
```typescript
components: {
schemas: {
YourCustomSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Unique identifier' },
name: { type: 'string', description: 'Display name' }
},
required: ['id', 'name']
}
}
}
```
### Examples with Multiple Scenarios
```typescript
examples:
basic_usage:
summary: Basic usage
value: { param: "value" }
advanced_usage:
summary: Advanced with all options
value: { param: "value", advanced: true }
```
## 🎨 Customization
### Styling
- Documentation UI automatically matches your app's theme
- Supports dark/light mode switching
### Organization
- Use **tags** to group related endpoints
- Order endpoints by adding them to the `tags` array in swagger.ts
### Authentication
- Add authentication schemas when needed
- Document API keys, bearer tokens, etc.
## 📝 Next Steps
1. **Document Remaining Routes**: Add JSDoc comments to all your API endpoints
2. **Add Examples**: Include realistic request/response examples
3. **Test Documentation**: Use the interactive UI to verify all endpoints work
4. **Export for External Use**: Generate OpenAPI spec for Postman/other tools
## 🚨 Important Notes
- The documentation page is at `/api/docs/page` (not just `/api/docs`)
- Swagger UI requires client-side rendering, so it's in the `page.tsx` file
- The generator automatically scans all files in `src/app/api/` for JSDoc comments
- Restart the dev server after adding new documentation to see changes
Your API documentation will automatically stay in sync as you develop new features! 🎉

40
CLAUDE.md Normal file
View File

@ -0,0 +1,40 @@
# nextjs project to display and organize photos
- uses tailwindcss for styling
- uses next/image for image optimization
- uses next/font for font optimization
- uses notejs 22
# Roadmap
[x] Set up Next.js project with typescript (https://nextjs.org/docs/app/getting-started/installation)
[x] Install Tailwind CSS
[x] Configure next/image
[x] Configure next/font
[x] Set up TypeScript
[x] initialize git repo with appropriate .gitignore
[x] Create responsive layout with Tailwind CSS
[x] Integrate localdb for backend photo index data
[x] create service to index photos from local filesystem
[x] Create photo gallery page
[ ] Implement photo organization features (albums, tags, moving files)
[ ] Optimize for performance and SEO
# Claude Instructions
- Never automatically change or add files without user confirmation
- Never try to run the dev server, user will always manually run dev
- Run builds automatically to check for errors
# Code Standards
- Files over 300 lines should be split into multiple files
- Use functional components with hooks
- Use Tailwind CSS for all styling, no custom CSS unless absolutely necessary
- Use next/image for all images
- Use next/font for all fonts
- Write clear, concise, and well-documented code
- Double check approach against current frameworks and libraries
- Always ask for user confirmation before making large changes
# Audit
- When asked if there are libraries to accomblish custom functionality, check npm
- When asked for alternatives give multiple options with pros and cons
- don't change any code unless I confirm

View File

@ -1,2 +1,26 @@
# photos
# Photos Gallery
A Next.js application for displaying and organizing photos.
## Prerequisites
### macOS CIFS Share Access
If you're running this application on macOS and accessing photos from a mounted CIFS share, you'll need to grant your terminal application full disk access:
1. Open **System Preferences****Security & Privacy** → **Privacy**
2. Select **Full Disk Access** from the left sidebar
3. Click the lock icon and enter your password to make changes
4. Click the **+** button and add your terminal application (e.g., Terminal.app, iTerm2, etc.)
5. Restart your terminal application
This is required because macOS restricts access to network-mounted drives without explicit permission.
## Getting Started
```bash
npm install
npm run dev
```
Open [http://localhost:3000](http://localhost:3000) in your browser.

35
next.config.js Normal file
View File

@ -0,0 +1,35 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
formats: ['image/webp', 'image/avif'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
// Allow local patterns for our API
localPatterns: [
{
pathname: '/api/photos/**',
}
]
},
// Suppress React strict mode warnings for third-party components
webpack: (config, { dev, isServer }) => {
if (dev) {
// Suppress specific warnings from swagger-ui-react in development
config.ignoreWarnings = [
...(config.ignoreWarnings || []),
/UNSAFE_componentWillReceiveProps/,
/componentWillReceiveProps/,
/UNSAFE_componentWillMount/,
/componentWillMount/,
/UNSAFE_componentWillUpdate/,
/componentWillUpdate/,
/strict mode is not recommended/,
/ModelCollapse/,
/OperationContainer/,
]
}
return config
},
}
module.exports = nextConfig

6688
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "photos",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@tabler/icons-react": "^3.34.1",
"@types/better-sqlite3": "^7.6.13",
"@xenova/transformers": "^2.17.2",
"better-sqlite3": "^12.2.0",
"exif-reader": "^2.0.2",
"glob": "^11.0.3",
"next": "^15.5.0",
"next-swagger-doc": "^0.4.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-share": "^5.2.2",
"redoc": "^2.5.0",
"sharp": "^0.34.3",
"swagger-ui-react": "^5.27.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.12",
"@types/node": "^24.3.0",
"@types/react": "^19.1.11",
"@types/react-dom": "^19.1.8",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"string-replace-loader": "^3.2.0",
"tailwindcss": "^4.1.12",
"typescript": "^5.9.2"
}
}

6
postcss.config.mjs Normal file
View File

@ -0,0 +1,6 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

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,210 @@
import { NextRequest, NextResponse } from 'next/server'
import { photoService } from '@/lib/photo-service'
import { imageClassifier } from '@/lib/image-classifier'
import { existsSync } from 'fs'
interface BatchCaptionRequest {
directory?: string
limit?: number
offset?: number
overwrite?: boolean // Overwrite existing captions
onlyUncaptioned?: boolean // Only process photos without captions
}
export async function POST(request: NextRequest) {
try {
const body: BatchCaptionRequest = await request.json()
const {
directory,
limit = 10, // Process 10 photos at a time to avoid memory issues
offset = 0,
overwrite = false,
onlyUncaptioned = true
} = body
// Initialize captioner
console.log('[BATCH CAPTION] Initializing image captioner...')
await imageClassifier.initializeCaptioner()
// Get photos to process
console.log('[BATCH CAPTION] Getting photos to caption...')
let photos = photoService.getPhotos({
directory,
limit,
offset,
sortBy: 'created_at',
sortOrder: 'ASC'
})
// Filter to only uncaptioned photos if requested
if (onlyUncaptioned && !overwrite) {
photos = photos.filter(photo => !photo.description || photo.description.trim() === '')
}
console.log(`[BATCH CAPTION] Processing ${photos.length} photos...`)
const results = []
let processed = 0
let successful = 0
let failed = 0
let skipped = 0
for (const photo of photos) {
try {
processed++
console.log(`[BATCH CAPTION] Processing ${processed}/${photos.length}: ${photo.filename}`)
// Skip if already has caption and not overwriting
if (photo.description && photo.description.trim() !== '' && !overwrite) {
console.log(`[BATCH CAPTION] Photo already has caption, skipping: ${photo.filename}`)
skipped++
results.push({
photoId: photo.id,
filename: photo.filename,
existingCaption: photo.description,
skipped: true
})
continue
}
// Try to get cached thumbnail first, fall back to file path
let imageSource: string | Buffer = photo.filepath
let usingThumbnail = false
const thumbnailBlob = photoService.getCachedThumbnail(photo.id, 300) // Use larger thumbnail for better captioning
if (thumbnailBlob) {
console.log(`[BATCH CAPTION] Using cached thumbnail: ${photo.filename}`)
imageSource = thumbnailBlob
usingThumbnail = true
} else {
// Check if file exists for fallback
if (!existsSync(photo.filepath)) {
console.warn(`[BATCH CAPTION] File not found and no thumbnail: ${photo.filepath}`)
failed++
results.push({
photoId: photo.id,
filename: photo.filename,
error: 'File not found and no cached thumbnail'
})
continue
}
console.log(`[BATCH CAPTION] Using original file (no thumbnail): ${photo.filename}`)
}
// Generate caption with fallback handling
let captionResult
try {
captionResult = await imageClassifier.captionImage(imageSource)
} catch (error) {
if (usingThumbnail && existsSync(photo.filepath)) {
console.warn(`[BATCH CAPTION] Thumbnail failed for ${photo.filename}, falling back to original file:`, error)
try {
// Fallback to original file
captionResult = await imageClassifier.captionImage(photo.filepath)
console.log(`[BATCH CAPTION] Fallback to original file successful: ${photo.filename}`)
} catch (fallbackError) {
console.error(`[BATCH CAPTION] Both thumbnail and original file failed for ${photo.filename}:`, fallbackError)
throw fallbackError
}
} else {
throw error
}
}
// Update photo with generated caption
const updatedPhoto = photoService.updatePhoto(photo.id, {
description: captionResult.caption
})
if (updatedPhoto) {
successful++
results.push({
photoId: photo.id,
filename: photo.filename,
previousCaption: photo.description || null,
newCaption: captionResult.caption,
confidence: captionResult.confidence
})
console.log(`[BATCH CAPTION] Generated caption for ${photo.filename}: "${captionResult.caption}"`)
} else {
failed++
results.push({
photoId: photo.id,
filename: photo.filename,
error: 'Failed to update photo with caption'
})
}
// Log progress every 5 photos
if (processed % 5 === 0) {
console.log(`[BATCH CAPTION] Progress: ${processed}/${photos.length} (${successful} successful, ${skipped} skipped, ${failed} failed)`)
}
} catch (error) {
console.error(`[BATCH CAPTION] Error processing ${photo.filename}:`, error)
failed++
results.push({
photoId: photo.id,
filename: photo.filename,
error: error instanceof Error ? error.message : 'Unknown error'
})
}
}
console.log(`[BATCH CAPTION] Completed: ${successful} successful, ${skipped} skipped, ${failed} failed`)
return NextResponse.json({
summary: {
processed,
successful,
skipped,
failed,
overwrite,
onlyUncaptioned
},
results: results.slice(0, 20), // Limit results in response for performance
hasMore: photos.length === limit // Indicate if there might be more photos to process
})
} catch (error) {
console.error('[BATCH CAPTION] Error:', error)
return NextResponse.json(
{ error: 'Failed to batch caption images' },
{ status: 500 }
)
}
}
// Get captioning status
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const directory = searchParams.get('directory') || undefined
// Get total photos
const allPhotos = photoService.getPhotos({ directory })
// Get photos with captions
const photosWithCaptions = allPhotos.filter(photo =>
photo.description && photo.description.trim() !== ''
)
return NextResponse.json({
total: allPhotos.length,
captioned: photosWithCaptions.length,
uncaptioned: allPhotos.length - photosWithCaptions.length,
captionedPercentage: Math.round((photosWithCaptions.length / allPhotos.length) * 100),
captionerReady: imageClassifier.isCaptionerReady()
})
} catch (error) {
console.error('[BATCH CAPTION STATUS] Error:', error)
return NextResponse.json(
{ error: 'Failed to get captioning status' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,179 @@
import { NextRequest, NextResponse } from 'next/server'
import { photoService } from '@/lib/photo-service'
import { imageClassifier } from '@/lib/image-classifier'
import { existsSync } from 'fs'
interface CaptionRequest {
photoId?: string
photoIds?: string[]
overwrite?: boolean // Overwrite existing captions
}
export async function POST(request: NextRequest) {
try {
const body: CaptionRequest = await request.json()
if (!body.photoId && !body.photoIds) {
return NextResponse.json(
{ error: 'Either photoId or photoIds must be provided' },
{ status: 400 }
)
}
// Initialize captioner if not already done
console.log('[CAPTION API] Initializing image captioner...')
await imageClassifier.initializeCaptioner()
const photoIds = body.photoId ? [body.photoId] : body.photoIds!
const overwrite = body.overwrite || false
const results = []
for (const photoId of photoIds) {
try {
console.log(`[CAPTION API] Processing photo: ${photoId}`)
// Get photo from database
const photo = photoService.getPhoto(photoId)
if (!photo) {
console.warn(`[CAPTION API] Photo not found: ${photoId}`)
results.push({
photoId,
error: 'Photo not found',
success: false
})
continue
}
// Skip if already has caption and not overwriting
if (photo.description && !overwrite) {
console.log(`[CAPTION API] Photo already has caption, skipping: ${photo.filename}`)
results.push({
photoId,
filename: photo.filename,
existingCaption: photo.description,
skipped: true,
success: true
})
continue
}
// Try to get cached thumbnail first, fall back to file path
let imageSource: string | Buffer = photo.filepath
const thumbnailBlob = photoService.getCachedThumbnail(photoId, 300) // Use larger thumbnail for better captioning
if (thumbnailBlob) {
console.log(`[CAPTION API] Using cached thumbnail for captioning: ${photo.filename}`)
imageSource = thumbnailBlob
} else {
// Check if file exists for fallback
if (!existsSync(photo.filepath)) {
console.warn(`[CAPTION API] Photo file not found and no thumbnail: ${photo.filepath}`)
results.push({
photoId,
error: 'Photo file not found and no cached thumbnail',
success: false,
filepath: photo.filepath
})
continue
}
console.log(`[CAPTION API] Using original file for captioning (no thumbnail): ${photo.filepath}`)
}
// Generate caption with fallback handling
let captionResult
try {
captionResult = await imageClassifier.captionImage(imageSource)
} catch (error) {
if (thumbnailBlob && existsSync(photo.filepath)) {
console.warn(`[CAPTION API] Thumbnail failed for ${photo.filename}, falling back to original file:`, error)
try {
// Fallback to original file
captionResult = await imageClassifier.captionImage(photo.filepath)
console.log(`[CAPTION API] Fallback to original file successful: ${photo.filename}`)
} catch (fallbackError) {
console.error(`[CAPTION API] Both thumbnail and original file failed for ${photo.filename}:`, fallbackError)
throw fallbackError
}
} else {
throw error
}
}
// Update photo with generated caption
const updatedPhoto = photoService.updatePhoto(photoId, {
description: captionResult.caption
})
if (updatedPhoto) {
results.push({
photoId,
filename: photo.filename,
previousCaption: photo.description || null,
newCaption: captionResult.caption,
confidence: captionResult.confidence,
success: true
})
console.log(`[CAPTION API] Generated caption for ${photo.filename}: "${captionResult.caption}"`)
} else {
results.push({
photoId,
error: 'Failed to update photo with caption',
success: false
})
}
} catch (error) {
console.error(`[CAPTION API] Error processing photo ${photoId}:`, error)
results.push({
photoId,
error: error instanceof Error ? error.message : 'Unknown error',
success: false
})
}
}
const successful = results.filter(r => r.success).length
const failed = results.filter(r => !r.success).length
const skipped = results.filter(r => 'skipped' in r && r.skipped).length
console.log(`[CAPTION API] Completed: ${successful} successful, ${skipped} skipped, ${failed} failed`)
return NextResponse.json({
results,
summary: {
total: photoIds.length,
successful,
skipped,
failed,
overwrite
}
})
} catch (error) {
console.error('[CAPTION API] Error:', error)
return NextResponse.json(
{ error: 'Failed to generate captions' },
{ status: 500 }
)
}
}
// Health check endpoint
export async function GET() {
try {
const isReady = imageClassifier.isCaptionerReady()
return NextResponse.json({
status: 'ok',
captionerReady: isReady,
message: isReady ? 'Captioner ready' : 'Captioner not initialized'
})
} catch (error) {
return NextResponse.json(
{ error: 'Service unavailable' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,378 @@
import { NextRequest, NextResponse } from 'next/server'
import { photoService } from '@/lib/photo-service'
import { imageClassifier } from '@/lib/image-classifier'
import { existsSync } from 'fs'
interface BatchClassifyRequest {
directory?: string
limit?: number
offset?: number
minConfidence?: number
onlyUntagged?: boolean
dryRun?: boolean
customLabels?: string[]
maxResults?: number
comprehensive?: boolean // Use both ViT + CLIP for more tags
categories?: {
general?: string[]
time?: string[]
weather?: string[]
subjects?: string[]
locations?: string[]
style?: string[]
seasons?: string[]
}
}
/**
* @swagger
* /api/classify/batch:
* post:
* summary: Batch classify photos using AI models
* description: Process multiple photos with AI classification using ViT for objects and optionally CLIP for artistic/style concepts. Supports comprehensive mode for maximum tag diversity.
* tags: [Classification]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/BatchClassifyRequest'
* examples:
* basic:
* summary: Basic batch classification
* value:
* limit: 10
* minConfidence: 0.3
* onlyUntagged: true
* comprehensive:
* summary: Comprehensive mode with dual models
* value:
* limit: 5
* comprehensive: true
* minConfidence: 0.05
* maxResults: 25
* onlyUntagged: true
* responses:
* 200:
* description: Classification results with summary statistics
* content:
* application/json:
* schema:
* type: object
* properties:
* summary:
* type: object
* properties:
* processed: { type: 'integer', description: 'Number of photos processed' }
* successful: { type: 'integer', description: 'Number of successful classifications' }
* failed: { type: 'integer', description: 'Number of failed classifications' }
* totalTagsAdded: { type: 'integer', description: 'Total tags added to database' }
* config: { $ref: '#/components/schemas/ClassifierConfig' }
* results:
* type: array
* items:
* type: object
* properties:
* photoId: { type: 'string' }
* filename: { type: 'string' }
* tagsAdded: { type: 'integer' }
* topTags:
* type: array
* items:
* $ref: '#/components/schemas/ClassificationResult'
* hasMore: { type: 'boolean', description: 'Whether more photos are available to process' }
* 400:
* description: Invalid request parameters
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 500:
* description: Server error during classification
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
export async function POST(request: NextRequest) {
try {
const body: BatchClassifyRequest = await request.json()
const {
directory,
limit = 50, // Process 50 photos at a time to avoid memory issues
offset = 0,
minConfidence = 0.3,
onlyUntagged = false,
dryRun = false,
customLabels,
maxResults,
comprehensive = false,
categories
} = body
// Build classifier configuration for this batch
const classifierConfig = {
minConfidence: comprehensive ? 0.05 : minConfidence, // Lower threshold for comprehensive mode
maxResults: comprehensive ? 25 : (maxResults || 10), // More results for comprehensive mode
customLabels,
categories
}
// Initialize classifier(s)
if (comprehensive) {
console.log('[BATCH CLASSIFY] Initializing comprehensive classifiers (ViT + CLIP)...')
await Promise.all([
imageClassifier.initialize(),
imageClassifier.initializeZeroShot()
])
} else {
console.log('[BATCH CLASSIFY] Initializing image classifier...')
await imageClassifier.initialize()
}
// Get photos to process
console.log('[BATCH CLASSIFY] Getting photos to classify...')
let photos = photoService.getPhotos({
directory,
limit,
offset,
sortBy: 'created_at',
sortOrder: 'ASC'
})
// Filter to only untagged photos if requested
if (onlyUntagged) {
photos = photos.filter(photo => {
const tags = photoService.getPhotoTags(photo.id)
return tags.length === 0
})
}
console.log(`[BATCH CLASSIFY] Processing ${photos.length} photos...`)
const results = []
let processed = 0
let successful = 0
let failed = 0
let totalTagsAdded = 0
for (const photo of photos) {
try {
processed++
console.log(`[BATCH CLASSIFY] Processing ${processed}/${photos.length}: ${photo.filename}`)
// Try to get cached thumbnail first, fall back to file path
let imageSource: string | Buffer = photo.filepath
let usingThumbnail = false
const thumbnailBlob = photoService.getCachedThumbnail(photo.id, 200) // Use 200px thumbnail
if (thumbnailBlob) {
console.log(`[BATCH CLASSIFY] Using cached thumbnail: ${photo.filename}`)
imageSource = thumbnailBlob
usingThumbnail = true
} else {
// Check if file exists for fallback
if (!existsSync(photo.filepath)) {
console.warn(`[BATCH CLASSIFY] File not found and no thumbnail: ${photo.filepath}`)
failed++
continue
}
console.log(`[BATCH CLASSIFY] Using original file (no thumbnail): ${photo.filename}`)
}
// Get existing tags if any
const existingTags = photoService.getPhotoTags(photo.id)
// Classify the image with fallback handling
let classifications
try {
if (comprehensive) {
const comprehensiveResult = await imageClassifier.classifyImageComprehensive(imageSource, classifierConfig)
classifications = comprehensiveResult.combinedResults
console.log(`[BATCH CLASSIFY] Comprehensive: ${comprehensiveResult.objectClassification.length} object + ${comprehensiveResult.styleClassification.length} style tags`)
} else {
classifications = await imageClassifier.classifyImage(imageSource, customLabels, classifierConfig)
}
} catch (error) {
if (usingThumbnail && existsSync(photo.filepath)) {
console.warn(`[BATCH CLASSIFY] Thumbnail failed for ${photo.filename}, falling back to original file:`, error)
try {
// Fallback to original file
if (comprehensive) {
const comprehensiveResult = await imageClassifier.classifyImageComprehensive(photo.filepath, classifierConfig)
classifications = comprehensiveResult.combinedResults
} else {
classifications = await imageClassifier.classifyImage(photo.filepath, customLabels, classifierConfig)
}
console.log(`[BATCH CLASSIFY] Fallback to original file successful: ${photo.filename}`)
} catch (fallbackError) {
console.error(`[BATCH CLASSIFY] Both thumbnail and original file failed for ${photo.filename}:`, fallbackError)
throw fallbackError
}
} else {
throw error
}
}
// Filter by confidence and exclude existing tags
// Note: classifications are already filtered by confidence in classifyImage
const existingTagNames = existingTags.map(tag => tag.name.toLowerCase())
const newTags = classifications
.filter(result => !existingTagNames.includes(result.label.toLowerCase()))
.map(result => ({ name: result.label, confidence: result.score }))
if (dryRun) {
// Just log what would be added
results.push({
photoId: photo.id,
filename: photo.filename,
existingTags: existingTags.length,
newClassifications: newTags,
wouldAdd: newTags.length
})
} else {
// Actually add the tags
const addedCount = photoService.addPhotoTags(photo.id, newTags)
totalTagsAdded += addedCount
results.push({
photoId: photo.id,
filename: photo.filename,
existingTags: existingTags.length,
classificationsFound: classifications.length,
newTagsAdded: addedCount,
topTags: newTags.slice(0, 5) // Show top 5 tags for logging
})
}
successful++
// Log progress every 10 photos
if (processed % 10 === 0) {
console.log(`[BATCH CLASSIFY] Progress: ${processed}/${photos.length} (${successful} successful, ${failed} failed)`)
}
} catch (error) {
console.error(`[BATCH CLASSIFY] Error processing ${photo.filename}:`, error)
failed++
results.push({
photoId: photo.id,
filename: photo.filename,
error: error instanceof Error ? error.message : 'Unknown error'
})
}
}
console.log(`[BATCH CLASSIFY] Completed: ${successful} successful, ${failed} failed, ${totalTagsAdded} total tags added`)
return NextResponse.json({
summary: {
processed,
successful,
failed,
totalTagsAdded,
config: classifierConfig,
dryRun,
onlyUntagged
},
results: dryRun ? results : results.slice(0, 10), // Limit results in response for performance
hasMore: photos.length === limit // Indicate if there might be more photos to process
})
} catch (error) {
console.error('[BATCH CLASSIFY] Error:', error)
return NextResponse.json(
{ error: 'Failed to batch classify images' },
{ status: 500 }
)
}
}
/**
* @swagger
* /api/classify/batch:
* get:
* summary: Get classification status and statistics
* description: Returns statistics about photo classification status including total photos, tagged/untagged counts, and most common tags.
* tags: [Classification]
* parameters:
* - in: query
* name: directory
* schema:
* type: string
* description: Optional directory to filter statistics
* example: /Users/photos/2024
* responses:
* 200:
* description: Classification status and statistics
* content:
* application/json:
* schema:
* type: object
* properties:
* total: { type: 'integer', description: 'Total number of photos' }
* tagged: { type: 'integer', description: 'Number of photos with tags' }
* untagged: { type: 'integer', description: 'Number of photos without tags' }
* taggedPercentage: { type: 'integer', description: 'Percentage of photos with tags' }
* topTags:
* type: array
* items:
* type: object
* properties:
* name: { type: 'string', description: 'Tag name' }
* count: { type: 'integer', description: 'Number of photos with this tag' }
* classifierReady: { type: 'boolean', description: 'Whether AI classifier is ready' }
* 500:
* description: Server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const directory = searchParams.get('directory') || undefined
// Get total photos
const allPhotos = photoService.getPhotos({ directory })
// Get photos with tags
const photosWithTags = allPhotos.filter(photo => {
const tags = photoService.getPhotoTags(photo.id)
return tags.length > 0
})
// Get most common tags
const tagCounts: { [tagName: string]: number } = {}
allPhotos.forEach(photo => {
const tags = photoService.getPhotoTags(photo.id)
tags.forEach(tag => {
tagCounts[tag.name] = (tagCounts[tag.name] || 0) + 1
})
})
const topTags = Object.entries(tagCounts)
.sort(([,a], [,b]) => b - a)
.slice(0, 20)
.map(([name, count]) => ({ name, count }))
return NextResponse.json({
total: allPhotos.length,
tagged: photosWithTags.length,
untagged: allPhotos.length - photosWithTags.length,
taggedPercentage: Math.round((photosWithTags.length / allPhotos.length) * 100),
topTags,
classifierReady: imageClassifier.isReady()
})
} catch (error) {
console.error('[BATCH CLASSIFY STATUS] Error:', error)
return NextResponse.json(
{ error: 'Failed to get classification status' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,171 @@
import { NextRequest, NextResponse } from 'next/server'
import { photoService } from '@/lib/photo-service'
import { imageClassifier } from '@/lib/image-classifier'
import { existsSync } from 'fs'
interface ComprehensiveClassifyRequest {
photoId?: string
photoIds?: string[]
minConfidence?: number
maxResults?: number
dryRun?: boolean
}
export async function POST(request: NextRequest) {
try {
const body: ComprehensiveClassifyRequest = await request.json()
if (!body.photoId && !body.photoIds) {
return NextResponse.json(
{ error: 'Either photoId or photoIds must be provided' },
{ status: 400 }
)
}
// Initialize both classifiers
console.log('[COMPREHENSIVE API] Initializing classifiers...')
await Promise.all([
imageClassifier.initialize(),
imageClassifier.initializeZeroShot()
])
const photoIds = body.photoId ? [body.photoId] : body.photoIds!
const dryRun = body.dryRun || false
const config = {
minConfidence: body.minConfidence || 0.05, // Lower threshold for more tags
maxResults: body.maxResults || 25 // More results
}
const results = []
for (const photoId of photoIds) {
try {
console.log(`[COMPREHENSIVE API] Processing photo: ${photoId}`)
// Get photo from database
const photo = photoService.getPhoto(photoId)
if (!photo) {
console.warn(`[COMPREHENSIVE API] Photo not found: ${photoId}`)
results.push({
photoId,
error: 'Photo not found',
success: false
})
continue
}
// Try to get cached thumbnail first, fall back to file path
let imageSource: string | Buffer = photo.filepath
const thumbnailBlob = photoService.getCachedThumbnail(photoId, 300) // Larger thumbnail for better analysis
if (thumbnailBlob) {
console.log(`[COMPREHENSIVE API] Using cached thumbnail: ${photo.filename}`)
imageSource = thumbnailBlob
} else {
if (!existsSync(photo.filepath)) {
console.warn(`[COMPREHENSIVE API] Photo file not found: ${photo.filepath}`)
results.push({
photoId,
error: 'Photo file not found and no cached thumbnail',
success: false
})
continue
}
console.log(`[COMPREHENSIVE API] Using original file: ${photo.filepath}`)
}
// Run comprehensive classification
const comprehensive = await imageClassifier.classifyImageComprehensive(imageSource, config)
if (dryRun) {
results.push({
photoId,
filename: photo.filename,
objectTags: comprehensive.objectClassification,
styleTags: comprehensive.styleClassification,
combinedTags: comprehensive.combinedResults,
totalTags: comprehensive.combinedResults.length,
success: true,
dryRun: true
})
} else {
// Save all combined tags to database
const tagsToAdd = comprehensive.combinedResults.map(result => ({
name: result.label,
confidence: result.score
}))
const addedCount = photoService.addPhotoTags(photoId, tagsToAdd)
results.push({
photoId,
filename: photo.filename,
objectTagsFound: comprehensive.objectClassification.length,
styleTagsFound: comprehensive.styleClassification.length,
totalTagsAdded: addedCount,
topTags: comprehensive.combinedResults.slice(0, 10),
success: true
})
console.log(`[COMPREHENSIVE API] Added ${addedCount} comprehensive tags to ${photo.filename}`)
}
} catch (error) {
console.error(`[COMPREHENSIVE API] Error processing photo ${photoId}:`, error)
results.push({
photoId,
error: error instanceof Error ? error.message : 'Unknown error',
success: false
})
}
}
const successful = results.filter(r => r.success).length
const failed = results.filter(r => !r.success).length
console.log(`[COMPREHENSIVE API] Completed: ${successful} successful, ${failed} failed`)
return NextResponse.json({
results,
summary: {
total: photoIds.length,
successful,
failed,
config,
dryRun,
note: 'Uses both ViT (objects) and CLIP (style/artistic concepts) for comprehensive tagging'
}
})
} catch (error) {
console.error('[COMPREHENSIVE API] Error:', error)
return NextResponse.json(
{ error: 'Failed to run comprehensive classification' },
{ status: 500 }
)
}
}
// Health check
export async function GET() {
try {
const vitReady = imageClassifier.isReady()
const clipReady = imageClassifier.isCaptionerReady() // We'll add isZeroShotReady later
return NextResponse.json({
status: 'ok',
models: {
vit: vitReady ? 'ready' : 'not initialized',
clip: 'initializing on first use'
},
description: 'Comprehensive classification using ViT for objects + CLIP for style/artistic concepts',
expectedTags: '15-25 tags per image covering objects, photography styles, lighting, mood, composition'
})
} catch (error) {
return NextResponse.json(
{ error: 'Service unavailable' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,109 @@
import { NextRequest, NextResponse } from 'next/server'
import { imageClassifier, ClassifierConfig } from '@/lib/image-classifier'
// Get current classifier configuration
export async function GET() {
try {
const config = imageClassifier.getConfig()
return NextResponse.json({
config,
isReady: imageClassifier.isReady(),
modelType: 'standard-image-classification',
modelName: 'Vision Transformer (ViT)',
note: 'Uses ImageNet classes - custom labels are not supported with standard image classification',
supportedSettings: {
minConfidence: 'Minimum confidence threshold (0-1)',
maxResults: 'Maximum number of results (1-50)'
},
imageNetInfo: {
totalClasses: 1000,
description: 'ImageNet dataset with 1000 object classes including animals, objects, vehicles, nature, food, and more'
}
})
} catch (error) {
console.error('[CLASSIFIER CONFIG GET] Error:', error)
return NextResponse.json(
{ error: 'Failed to get classifier configuration' },
{ status: 500 }
)
}
}
// Update classifier configuration
export async function POST(request: NextRequest) {
try {
const body: Partial<ClassifierConfig> = await request.json()
// Validate configuration
if (body.minConfidence !== undefined) {
if (typeof body.minConfidence !== 'number' || body.minConfidence < 0 || body.minConfidence > 1) {
return NextResponse.json(
{ error: 'minConfidence must be a number between 0 and 1' },
{ status: 400 }
)
}
}
if (body.maxResults !== undefined) {
if (typeof body.maxResults !== 'number' || body.maxResults < 1 || body.maxResults > 50) {
return NextResponse.json(
{ error: 'maxResults must be a number between 1 and 50' },
{ status: 400 }
)
}
}
if (body.customLabels !== undefined) {
return NextResponse.json(
{ error: 'customLabels are not supported with standard image classification. The model uses ImageNet classes.' },
{ status: 400 }
)
}
if (body.categories !== undefined) {
return NextResponse.json(
{ error: 'categories are not supported with standard image classification. The model uses ImageNet classes.' },
{ status: 400 }
)
}
// Update configuration
imageClassifier.updateConfig(body)
const updatedConfig = imageClassifier.getConfig()
return NextResponse.json({
success: true,
config: updatedConfig,
message: 'Classifier configuration updated successfully'
})
} catch (error) {
console.error('[CLASSIFIER CONFIG POST] Error:', error)
return NextResponse.json(
{ error: 'Failed to update classifier configuration' },
{ status: 500 }
)
}
}
// Reset classifier configuration to defaults
export async function DELETE() {
try {
imageClassifier.resetConfig()
const config = imageClassifier.getConfig()
return NextResponse.json({
success: true,
config,
message: 'Classifier configuration reset to defaults'
})
} catch (error) {
console.error('[CLASSIFIER CONFIG DELETE] Error:', error)
return NextResponse.json(
{ error: 'Failed to reset classifier configuration' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,176 @@
import { NextRequest, NextResponse } from 'next/server'
import { photoService } from '@/lib/photo-service'
import { imageClassifier } from '@/lib/image-classifier'
import { existsSync } from 'fs'
interface ClassifyRequest {
photoId?: string
photoIds?: string[]
minConfidence?: number
dryRun?: boolean // Just return classifications without saving
customLabels?: string[]
maxResults?: number
categories?: {
general?: string[]
time?: string[]
weather?: string[]
subjects?: string[]
locations?: string[]
style?: string[]
seasons?: string[]
}
}
export async function POST(request: NextRequest) {
try {
const body: ClassifyRequest = await request.json()
if (!body.photoId && !body.photoIds) {
return NextResponse.json(
{ error: 'Either photoId or photoIds must be provided' },
{ status: 400 }
)
}
// Initialize classifier if not already done
console.log('[CLASSIFY API] Initializing image classifier...')
await imageClassifier.initialize()
const photoIds = body.photoId ? [body.photoId] : body.photoIds!
const minConfidence = body.minConfidence || 0.3
const dryRun = body.dryRun || false
// Build classifier configuration
const classifierConfig = {
minConfidence,
maxResults: body.maxResults,
customLabels: body.customLabels,
categories: body.categories
}
const results = []
for (const photoId of photoIds) {
try {
console.log(`[CLASSIFY API] Processing photo: ${photoId}`)
// Get photo from database
const photo = photoService.getPhoto(photoId)
if (!photo) {
console.warn(`[CLASSIFY API] Photo not found: ${photoId}`)
results.push({
photoId,
error: 'Photo not found',
success: false
})
continue
}
// Try to get cached thumbnail first, fall back to file path
let imageSource: string | Buffer = photo.filepath
const thumbnailBlob = photoService.getCachedThumbnail(photoId, 200) // Use 200px thumbnail for classification
if (thumbnailBlob) {
console.log(`[CLASSIFY API] Using cached thumbnail for classification: ${photo.filename}`)
imageSource = thumbnailBlob
} else {
// Check if file exists for fallback
if (!existsSync(photo.filepath)) {
console.warn(`[CLASSIFY API] Photo file not found and no thumbnail: ${photo.filepath}`)
results.push({
photoId,
error: 'Photo file not found and no cached thumbnail',
success: false,
filepath: photo.filepath
})
continue
}
console.log(`[CLASSIFY API] Using original file for classification (no thumbnail): ${photo.filepath}`)
}
// Classify the image with configuration
const sourceDesc = thumbnailBlob ? `thumbnail for ${photo.filename}` : photo.filepath
console.log(`[CLASSIFY API] Classifying image: ${sourceDesc}`)
const classifications = await imageClassifier.classifyImage(imageSource, body.customLabels, classifierConfig)
// Filter by confidence (already done by classifyImage, but keep for backward compatibility)
const filteredTags = classifications
.map(result => ({ name: result.label, confidence: result.score }))
if (dryRun) {
// Just return the classifications without saving
results.push({
photoId,
filename: photo.filename,
classifications: filteredTags,
success: true,
dryRun: true
})
} else {
// Save tags to database
const addedCount = photoService.addPhotoTags(photoId, filteredTags)
results.push({
photoId,
filename: photo.filename,
classificationsFound: classifications.length,
tagsAdded: addedCount,
tags: filteredTags,
success: true
})
console.log(`[CLASSIFY API] Added ${addedCount} tags to photo: ${photo.filename}`)
}
} catch (error) {
console.error(`[CLASSIFY API] Error processing photo ${photoId}:`, error)
results.push({
photoId,
error: error instanceof Error ? error.message : 'Unknown error',
success: false
})
}
}
const successful = results.filter(r => r.success).length
const failed = results.filter(r => !r.success).length
console.log(`[CLASSIFY API] Completed: ${successful} successful, ${failed} failed`)
return NextResponse.json({
results,
summary: {
total: photoIds.length,
successful,
failed,
config: classifierConfig,
dryRun
}
})
} catch (error) {
console.error('[CLASSIFY API] Error:', error)
return NextResponse.json(
{ error: 'Failed to classify images' },
{ status: 500 }
)
}
}
// Health check endpoint
export async function GET() {
try {
const isReady = imageClassifier.isReady()
return NextResponse.json({
status: 'ok',
classifierReady: isReady,
message: isReady ? 'Classifier ready' : 'Classifier not initialized'
})
} catch (error) {
return NextResponse.json(
{ error: 'Service unavailable' },
{ 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 }
)
}
}

15
src/app/api/docs/route.ts Normal file
View File

@ -0,0 +1,15 @@
import { NextResponse } from 'next/server'
import { getApiDocs } from '@/lib/swagger'
export async function GET() {
try {
const spec = await getApiDocs()
return NextResponse.json(spec)
} catch (error) {
console.error('Error generating API docs:', error)
return NextResponse.json(
{ error: 'Failed to generate API documentation' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,62 @@
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 type = searchParams.get('type') || 'hashes'
if (type === 'hashes') {
// Return duplicate hash records
const duplicateHashes = photoService.getDuplicateHashes()
return NextResponse.json({
success: true,
duplicates: duplicateHashes,
count: duplicateHashes.length
})
} else if (type === 'photos') {
// Return photos that have duplicates
const duplicatePhotos = photoService.getPhotosWithDuplicates()
return NextResponse.json({
success: true,
photos: duplicatePhotos,
count: duplicatePhotos.length
})
} else if (type === 'by-hash') {
// Return photos for a specific hash
const hash = searchParams.get('hash')
if (!hash) {
return NextResponse.json(
{ error: 'Hash parameter is required for by-hash type' },
{ status: 400 }
)
}
const photos = photoService.getPhotosByHash(hash)
return NextResponse.json({
success: true,
hash,
photos,
count: photos.length
})
} else {
return NextResponse.json(
{ error: 'Invalid type parameter. Use: hashes, photos, or by-hash' },
{ status: 400 }
)
}
} catch (error) {
console.error('Error fetching duplicates:', error)
return NextResponse.json(
{ error: 'Failed to fetch duplicates' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,91 @@
import { NextRequest, NextResponse } from 'next/server'
import { photoService } from '@/lib/photo-service'
import { createReadStream, existsSync, statSync } from 'fs'
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 }
)
}
// Check if the file exists
if (!existsSync(photo.filepath)) {
return NextResponse.json(
{ error: 'Photo file not found' },
{ status: 404 }
)
}
// Get file stats
const stats = statSync(photo.filepath)
const fileSize = stats.size
// Determine content type based on file extension
const extension = photo.filepath.toLowerCase().split('.').pop()
const contentTypeMap: Record<string, string> = {
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'png': 'image/png',
'gif': 'image/gif',
'bmp': 'image/bmp',
'webp': 'image/webp',
'tiff': 'image/tiff',
'tif': 'image/tiff',
'svg': 'image/svg+xml',
'ico': 'image/x-icon'
}
const contentType = contentTypeMap[extension || ''] || 'application/octet-stream'
// Handle range requests for large images
const range = request.headers.get('range')
if (range) {
const parts = range.replace(/bytes=/, '').split('-')
const start = parseInt(parts[0], 10)
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1
const chunksize = (end - start) + 1
const stream = createReadStream(photo.filepath, { start, end })
return new NextResponse(stream as any, {
status: 206,
headers: {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunksize.toString(),
'Content-Type': contentType,
},
})
} else {
// Serve full file
const stream = createReadStream(photo.filepath)
return new NextResponse(stream as any, {
status: 200,
headers: {
'Content-Type': contentType,
'Content-Length': fileSize.toString(),
'Cache-Control': 'public, max-age=31536000, immutable', // Cache for 1 year
},
})
}
} catch (error) {
console.error('Error serving full-resolution image:', error)
return NextResponse.json(
{ error: 'Failed to serve image' },
{ 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,21 @@
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 tags = photoService.getPhotoTags(id)
return NextResponse.json(tags)
} catch (error) {
console.error('Error fetching photo tags:', error)
return NextResponse.json(
{ error: 'Failed to fetch photo tags' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from 'next/server'
import { photoService } from '@/lib/photo-service'
import sharp from 'sharp'
import { existsSync } from 'fs'
import { join } from 'path'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const { searchParams } = new URL(request.url)
// Get size parameter (default: 200px)
const size = parseInt(searchParams.get('size') || '200')
const maxSize = Math.min(Math.max(size, 50), 800) // Clamp between 50-800px
const photo = photoService.getPhoto(id)
if (!photo) {
return NextResponse.json(
{ error: 'Photo not found' },
{ status: 404 }
)
}
// Check for cached thumbnail first
const cachedThumbnail = photoService.getCachedThumbnail(id, maxSize)
if (cachedThumbnail) {
// Return cached thumbnail immediately
return new NextResponse(cachedThumbnail, {
status: 200,
headers: {
'Content-Type': 'image/jpeg',
'Cache-Control': 'public, max-age=31536000, immutable',
'Content-Length': cachedThumbnail.length.toString(),
'X-Cache': 'HIT', // Debug header
},
})
}
// Check if the file exists
if (!existsSync(photo.filepath)) {
return NextResponse.json(
{ error: 'Photo file not found' },
{ status: 404 }
)
}
// Generate thumbnail using Sharp
const thumbnail = await sharp(photo.filepath)
.resize(maxSize, maxSize, {
fit: 'inside',
withoutEnlargement: true,
background: { r: 240, g: 240, b: 240, alpha: 1 } // Light gray background
})
.jpeg({ quality: 85 })
.toBuffer()
// Asynchronously update the database cache (don't wait for it)
setImmediate(() => {
try {
photoService.updateThumbnailCache(id, maxSize, thumbnail)
} catch (error) {
console.error(`Failed to cache thumbnail for photo ${id}:`, error)
}
})
return new NextResponse(thumbnail as any, {
status: 200,
headers: {
'Content-Type': 'image/jpeg',
'Cache-Control': 'public, max-age=31536000, immutable', // Cache for 1 year
'Content-Length': thumbnail.length.toString(),
'X-Cache': 'MISS', // Debug header
},
})
} catch (error) {
console.error('Error generating thumbnail:', error)
return NextResponse.json(
{ error: 'Failed to generate thumbnail' },
{ 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) {
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
}
// Get photos with pagination
const photos = photoService.getPhotos(options)
// Get total count for pagination metadata
const totalCountOptions = { ...options }
delete totalCountOptions.limit
delete totalCountOptions.offset
const totalPhotos = photoService.getPhotos(totalCountOptions)
const totalCount = totalPhotos.length
// Calculate pagination metadata
const limit = options.limit || 20
const offset = options.offset || 0
const hasMore = (offset + photos.length) < totalCount
const currentPage = Math.floor(offset / limit) + 1
const totalPages = Math.ceil(totalCount / limit)
return NextResponse.json({
photos,
pagination: {
total: totalCount,
count: photos.length,
limit,
offset,
hasMore,
currentPage,
totalPages
}
})
} 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 }
)
}
}

84
src/app/api/scan/route.ts Normal file
View File

@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from 'next/server'
import { photoService } from '@/lib/photo-service'
import { scanDirectory } from '@/lib/file-scanner'
export async function POST(request: NextRequest) {
const startTime = Date.now()
let directoryPath: string = ''
try {
const body = await request.json()
directoryPath = body.directoryPath
console.log(`[SCAN API] Received scan request for directory: ${directoryPath}`)
if (!directoryPath || typeof directoryPath !== 'string') {
console.error('[SCAN API] Invalid directory path provided:', directoryPath)
return NextResponse.json(
{ error: 'Directory path is required' },
{ status: 400 }
)
}
// Validate directory exists
const directoryRecord = photoService.getDirectoryByPath(directoryPath)
if (!directoryRecord) {
console.error(`[SCAN API] Directory not found in database: ${directoryPath}`)
return NextResponse.json(
{ error: 'Directory not found in database' },
{ status: 404 }
)
}
console.log(`[SCAN API] Starting background scan for: ${directoryPath}`)
console.log(`[SCAN API] Directory record:`, {
id: directoryRecord.id,
name: directoryRecord.name,
lastScanned: directoryRecord.last_scanned,
photoCount: directoryRecord.photo_count
})
// Update directory scan status immediately
photoService.createOrUpdateDirectory({
path: directoryPath,
name: directoryRecord.name,
last_scanned: new Date().toISOString(),
photo_count: directoryRecord.photo_count,
total_size: directoryRecord.total_size
})
console.log(`[SCAN API] Updated directory last_scanned timestamp`)
// Start the scanning process in the background
// Don't await this - let it run asynchronously
scanDirectory(directoryPath).then(result => {
const duration = Date.now() - startTime
console.log(`[SCAN API] Background scan completed for ${directoryPath}:`, {
duration: `${duration}ms`,
result
})
}).catch(error => {
const duration = Date.now() - startTime
console.error(`[SCAN API] Background scan failed for ${directoryPath} after ${duration}ms:`, error)
})
const responseTime = Date.now() - startTime
console.log(`[SCAN API] Responding immediately after ${responseTime}ms`)
// Return immediately
return NextResponse.json({
success: true,
message: 'Directory scan started in background',
directoryPath,
startTime: new Date().toISOString()
})
} catch (error) {
const responseTime = Date.now() - startTime
console.error(`[SCAN API] Error starting directory scan for ${directoryPath} after ${responseTime}ms:`, error)
return NextResponse.json(
{ error: 'Failed to start directory scan' },
{ 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

@ -0,0 +1,146 @@
import { NextRequest, NextResponse } from 'next/server'
import { photoService } from '@/lib/photo-service'
interface ClearTagsRequest {
directory?: string
confirmClear?: boolean // Safety flag to prevent accidental clears
}
export async function POST(request: NextRequest) {
try {
const body: ClearTagsRequest = await request.json()
const { directory, confirmClear = false } = body
// Safety check - require explicit confirmation
if (!confirmClear) {
return NextResponse.json(
{ error: 'confirmClear must be true to proceed with clearing tags' },
{ status: 400 }
)
}
console.log('[CLEAR TAGS] Starting tag clearing process...')
// Get photos to clear tags from
let photos = photoService.getPhotos({ directory })
if (photos.length === 0) {
return NextResponse.json({
message: 'No photos found to clear tags from',
cleared: 0,
directory: directory || 'all'
})
}
console.log(`[CLEAR TAGS] Found ${photos.length} photos to process`)
let totalTagsCleared = 0
let photosProcessed = 0
const results = []
for (const photo of photos) {
try {
// Get current tags for this photo
const currentTags = photoService.getPhotoTags(photo.id)
const tagCount = currentTags.length
if (tagCount > 0) {
// Clear all tags for this photo
const cleared = photoService.clearPhotoTags(photo.id)
totalTagsCleared += cleared
results.push({
photoId: photo.id,
filename: photo.filename,
tagsCleared: cleared,
success: true
})
console.log(`[CLEAR TAGS] Cleared ${cleared} tags from ${photo.filename}`)
} else {
results.push({
photoId: photo.id,
filename: photo.filename,
tagsCleared: 0,
success: true,
note: 'No tags to clear'
})
}
photosProcessed++
// Log progress every 100 photos
if (photosProcessed % 100 === 0) {
console.log(`[CLEAR TAGS] Progress: ${photosProcessed}/${photos.length} photos processed, ${totalTagsCleared} tags cleared`)
}
} catch (error) {
console.error(`[CLEAR TAGS] Error processing photo ${photo.filename}:`, error)
results.push({
photoId: photo.id,
filename: photo.filename,
error: error instanceof Error ? error.message : 'Unknown error',
success: false
})
}
}
console.log(`[CLEAR TAGS] Completed: ${photosProcessed} photos processed, ${totalTagsCleared} total tags cleared`)
return NextResponse.json({
summary: {
photosProcessed,
totalTagsCleared,
directory: directory || 'all photos',
success: true
},
results: results.slice(0, 20), // Limit results for performance
message: `Successfully cleared ${totalTagsCleared} tags from ${photosProcessed} photos`
})
} catch (error) {
console.error('[CLEAR TAGS] Error:', error)
return NextResponse.json(
{ error: 'Failed to clear tags' },
{ status: 500 }
)
}
}
// Get count of tags that would be cleared (for confirmation)
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const directory = searchParams.get('directory') || undefined
// Get photos
const photos = photoService.getPhotos({ directory })
let totalTags = 0
let photosWithTags = 0
photos.forEach(photo => {
const tags = photoService.getPhotoTags(photo.id)
if (tags.length > 0) {
totalTags += tags.length
photosWithTags++
}
})
return NextResponse.json({
total: photos.length,
photosWithTags,
totalTags,
directory: directory || 'all photos',
warning: 'This operation will permanently remove all tags. Use POST with confirmClear: true to proceed.'
})
} catch (error) {
console.error('[CLEAR TAGS STATUS] Error:', error)
return NextResponse.json(
{ error: 'Failed to get tag clear status' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server'
import { photoService } from '@/lib/photo-service'
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { photoId } = body
const cleared = photoService.clearThumbnailCache(photoId)
return NextResponse.json({
success: true,
cleared,
message: photoId
? `Thumbnail cache cleared for photo ${photoId}`
: 'All thumbnail caches cleared'
})
} catch (error) {
console.error('Error clearing thumbnail cache:', error)
return NextResponse.json(
{ error: 'Failed to clear thumbnail cache' },
{ status: 500 }
)
}
}
export async function DELETE() {
try {
// Clear all thumbnail caches
const cleared = photoService.clearThumbnailCache()
return NextResponse.json({
success: true,
cleared,
message: 'All thumbnail caches cleared'
})
} catch (error) {
console.error('Error clearing all thumbnail caches:', error)
return NextResponse.json(
{ error: 'Failed to clear thumbnail caches' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,88 @@
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()
if (!directory || typeof directory !== 'string') {
return NextResponse.json({
valid: false,
error: 'Directory path is required',
suggestions: []
})
}
const trimmedInput = directory.trim()
// Always get suggestions using glob search
const suggestions = await getSuggestions(trimmedInput)
// 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: isValid,
path: isValid ? normalizedPath : undefined,
suggestions
})
} catch (error) {
return NextResponse.json({
valid: false,
error: 'Invalid directory path',
suggestions: []
}, { status: 400 })
}
}

11
src/app/docs/layout.tsx Normal file
View File

@ -0,0 +1,11 @@
export default function DocsLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{children}
</div>
)
}

237
src/app/docs/page.tsx Normal file
View File

@ -0,0 +1,237 @@
'use client'
import { useState, useEffect } from 'react'
import SwaggerUIWrapper from '@/components/SwaggerUIWrapper'
import './swagger-ui-tailwind.css'
export default function ApiDocsPage() {
const [spec, setSpec] = useState(null)
const [error, setError] = useState(null)
const [isDark, setIsDark] = useState(false)
// Dark mode detection
useEffect(() => {
const checkDarkMode = () => {
setIsDark(
window.matchMedia('(prefers-color-scheme: dark)').matches ||
document.documentElement.classList.contains('dark')
)
}
checkDarkMode()
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', checkDarkMode)
// Watch for class changes on html element
const observer = new MutationObserver(checkDarkMode)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
})
return () => {
mediaQuery.removeEventListener('change', checkDarkMode)
observer.disconnect()
}
}, [])
useEffect(() => {
fetch('/api/docs')
.then(res => {
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`)
}
return res.json()
})
.then(setSpec)
.catch(error => {
console.error('Failed to load API docs:', error)
setError(error.message)
})
}, [])
if (error) {
return (
<div className="min-h-screen bg-white dark:bg-gray-900 flex items-center justify-center">
<div className="text-center">
<div className="text-red-500 text-6xl mb-4"></div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Failed to Load API Documentation
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Error: {error}
</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Retry
</button>
</div>
</div>
)
}
if (!spec) {
return (
<div className="min-h-screen bg-white dark:bg-gray-900 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading API Documentation...</p>
</div>
</div>
)
}
return (
<div className={`min-h-screen transition-colors duration-200 ${isDark ? 'dark' : ''}`}>
{/* Full-width Header */}
<header className="bg-gradient-to-r from-blue-600 to-purple-600 text-white">
<div className="container mx-auto px-6 py-12">
<div className="max-w-4xl mx-auto text-center">
<div className="inline-flex items-center gap-4 mb-6">
<div className="w-16 h-16 bg-white/20 backdrop-blur-sm rounded-2xl flex items-center justify-center">
<span className="text-white font-bold text-2xl">API</span>
</div>
<div className="text-left">
<h1 className="text-4xl font-bold mb-2">
Photo Gallery AI API
</h1>
<p className="text-blue-100 text-lg">
Interactive Documentation
</p>
</div>
</div>
<p className="text-xl text-blue-50 leading-relaxed max-w-3xl mx-auto mb-8">
Complete API reference for AI-powered photo organization.
Features dual-model classification, image captioning, and comprehensive tag management.
</p>
<div className="flex items-center justify-center gap-6 text-blue-100">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
<span className="text-sm font-medium">100% Local Processing</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-yellow-400 rounded-full"></div>
<span className="text-sm font-medium">No Cloud Dependencies</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-purple-400 rounded-full"></div>
<span className="text-sm font-medium">OpenAPI 3.0</span>
</div>
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="bg-white dark:bg-gray-900">
<div className="container mx-auto px-6 py-12 max-w-6xl">
{/* Quick Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mb-12">
<div className="text-center">
<div className="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-2">8+</div>
<div className="text-sm text-gray-600 dark:text-gray-400">API Endpoints</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-green-600 dark:text-green-400 mb-2">3</div>
<div className="text-sm text-gray-600 dark:text-gray-400">AI Models</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-purple-600 dark:text-purple-400 mb-2">25+</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Tags per Photo</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-yellow-600 dark:text-yellow-400 mb-2"></div>
<div className="text-sm text-gray-600 dark:text-gray-400">Privacy First</div>
</div>
</div>
{/* Feature Highlights */}
<div className="grid md:grid-cols-3 gap-6 mb-12">
<div className="bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 p-6 rounded-xl border border-blue-200 dark:border-blue-800">
<div className="w-12 h-12 bg-blue-600 rounded-lg flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
AI Classification
</h3>
<p className="text-gray-600 dark:text-gray-400 text-sm">
ViT + CLIP models for comprehensive object and style recognition
</p>
</div>
<div className="bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 p-6 rounded-xl border border-green-200 dark:border-green-800">
<div className="w-12 h-12 bg-green-600 rounded-lg flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Image Captioning
</h3>
<p className="text-gray-600 dark:text-gray-400 text-sm">
BLIP model generates natural language descriptions
</p>
</div>
<div className="bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 p-6 rounded-xl border border-purple-200 dark:border-purple-800">
<div className="w-12 h-12 bg-purple-600 rounded-lg flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Batch Processing
</h3>
<p className="text-gray-600 dark:text-gray-400 text-sm">
Process entire photo libraries with progress tracking
</p>
</div>
</div>
{/* API Documentation Section */}
<div className="bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 overflow-hidden shadow-xl">
{/* Documentation Content */}
<div className="p-8">
<SwaggerUIWrapper
spec={spec}
requestInterceptor={(req) => {
return req
}}
responseInterceptor={(res) => {
return res
}}
/>
</div>
</div>
</div>
</main>
{/* Footer */}
<footer className="bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
<div className="container mx-auto px-6 py-8 text-center">
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
<div className="text-sm text-gray-600 dark:text-gray-400">
Generated automatically from code annotations Always up-to-date
</div>
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
<span className="flex items-center gap-1">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
OpenAPI 3.0
</span>
<span className="flex items-center gap-1">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
Interactive Testing
</span>
</div>
</div>
</div>
</footer>
</div>
)
}

View File

@ -0,0 +1,312 @@
/* Swagger UI Tailwind Integration */
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
/* Root variables using Tailwind theme */
.swagger-ui {
/* Color scheme */
--swagger-ui-bg-primary: theme('colors.white');
--swagger-ui-bg-secondary: theme('colors.gray.50');
--swagger-ui-bg-tertiary: theme('colors.gray.100');
--swagger-ui-text-primary: theme('colors.gray.900');
--swagger-ui-text-secondary: theme('colors.gray.600');
--swagger-ui-text-muted: theme('colors.gray.400');
--swagger-ui-border: theme('colors.gray.200');
--swagger-ui-accent: theme('colors.blue.600');
--swagger-ui-accent-hover: theme('colors.blue.700');
--swagger-ui-success: theme('colors.green.600');
--swagger-ui-warning: theme('colors.yellow.600');
--swagger-ui-error: theme('colors.red.600');
/* Typography */
--swagger-ui-font-family: theme('fontFamily.sans');
--swagger-ui-font-mono: theme('fontFamily.mono');
--swagger-ui-font-size-base: theme('fontSize.sm');
--swagger-ui-font-size-lg: theme('fontSize.base');
--swagger-ui-font-size-xl: theme('fontSize.lg');
/* Spacing */
--swagger-ui-spacing-xs: theme('spacing.1');
--swagger-ui-spacing-sm: theme('spacing.2');
--swagger-ui-spacing-md: theme('spacing.4');
--swagger-ui-spacing-lg: theme('spacing.6');
--swagger-ui-spacing-xl: theme('spacing.8');
/* Shadows */
--swagger-ui-shadow-sm: theme('boxShadow.sm');
--swagger-ui-shadow-md: theme('boxShadow.md');
--swagger-ui-shadow-lg: theme('boxShadow.lg');
/* Border radius */
--swagger-ui-radius-sm: theme('borderRadius.sm');
--swagger-ui-radius-md: theme('borderRadius.md');
--swagger-ui-radius-lg: theme('borderRadius.lg');
}
/* Dark mode variables */
.dark .swagger-ui {
--swagger-ui-bg-primary: theme('colors.gray.900');
--swagger-ui-bg-secondary: theme('colors.gray.800');
--swagger-ui-bg-tertiary: theme('colors.gray.700');
--swagger-ui-text-primary: theme('colors.white');
--swagger-ui-text-secondary: theme('colors.gray.300');
--swagger-ui-text-muted: theme('colors.gray.500');
--swagger-ui-border: theme('colors.gray.600');
--swagger-ui-accent: theme('colors.blue.400');
--swagger-ui-accent-hover: theme('colors.blue.300');
--swagger-ui-success: theme('colors.green.400');
--swagger-ui-warning: theme('colors.yellow.400');
--swagger-ui-error: theme('colors.red.400');
}
/* Base styling with Tailwind utilities */
.swagger-ui {
@apply font-sans text-sm;
background-color: var(--swagger-ui-bg-primary);
color: var(--swagger-ui-text-primary);
font-family: var(--swagger-ui-font-family);
}
/* Header styling */
.swagger-ui .info {
@apply border-b border-gray-200 dark:border-gray-700 pb-6 mb-8;
}
.swagger-ui .info .title {
@apply text-3xl font-bold text-gray-900 dark:text-white mb-4;
color: var(--swagger-ui-text-primary);
}
.swagger-ui .info .description {
@apply text-gray-600 dark:text-gray-300 leading-relaxed;
color: var(--swagger-ui-text-secondary);
}
/* Operation blocks */
.swagger-ui .opblock {
@apply border border-gray-200 dark:border-gray-700 rounded-lg mb-4 overflow-hidden shadow-sm;
background-color: var(--swagger-ui-bg-primary);
border-color: var(--swagger-ui-border);
box-shadow: var(--swagger-ui-shadow-sm);
border-radius: var(--swagger-ui-radius-lg);
}
.swagger-ui .opblock .opblock-summary {
@apply px-4 py-3 cursor-pointer transition-colors duration-200;
background-color: var(--swagger-ui-bg-secondary);
}
.swagger-ui .opblock .opblock-summary:hover {
@apply bg-gray-100 dark:bg-gray-700;
background-color: var(--swagger-ui-bg-tertiary);
}
/* HTTP method colors */
.swagger-ui .opblock.opblock-get .opblock-summary {
@apply bg-blue-50 dark:bg-blue-900/20 border-l-4 border-blue-500;
}
.swagger-ui .opblock.opblock-post .opblock-summary {
@apply bg-green-50 dark:bg-green-900/20 border-l-4 border-green-500;
}
.swagger-ui .opblock.opblock-put .opblock-summary {
@apply bg-yellow-50 dark:bg-yellow-900/20 border-l-4 border-yellow-500;
}
.swagger-ui .opblock.opblock-delete .opblock-summary {
@apply bg-red-50 dark:bg-red-900/20 border-l-4 border-red-500;
}
.swagger-ui .opblock.opblock-patch .opblock-summary {
@apply bg-purple-50 dark:bg-purple-900/20 border-l-4 border-purple-500;
}
/* Method labels */
.swagger-ui .opblock .opblock-summary-method {
@apply px-3 py-1 rounded text-white text-xs font-semibold uppercase;
border-radius: var(--swagger-ui-radius-sm);
}
.swagger-ui .opblock.opblock-get .opblock-summary-method {
@apply bg-blue-600 dark:bg-blue-500;
}
.swagger-ui .opblock.opblock-post .opblock-summary-method {
@apply bg-green-600 dark:bg-green-500;
}
.swagger-ui .opblock.opblock-put .opblock-summary-method {
@apply bg-yellow-600 dark:bg-yellow-500;
}
.swagger-ui .opblock.opblock-delete .opblock-summary-method {
@apply bg-red-600 dark:bg-red-500;
}
.swagger-ui .opblock.opblock-patch .opblock-summary-method {
@apply bg-purple-600 dark:bg-purple-500;
}
/* Operation summary text */
.swagger-ui .opblock-summary-path {
@apply font-mono text-sm text-gray-700 dark:text-gray-300;
font-family: var(--swagger-ui-font-mono);
}
.swagger-ui .opblock-summary-description {
@apply text-gray-600 dark:text-gray-400 text-sm ml-4;
}
/* Parameters and request body */
.swagger-ui .parameters-container {
@apply bg-gray-50 dark:bg-gray-800 p-4;
background-color: var(--swagger-ui-bg-secondary);
}
.swagger-ui .parameter__name {
@apply font-mono text-sm font-medium text-gray-900 dark:text-white;
font-family: var(--swagger-ui-font-mono);
color: var(--swagger-ui-text-primary);
}
.swagger-ui .parameter__type {
@apply text-blue-600 dark:text-blue-400 text-xs;
color: var(--swagger-ui-accent);
}
.swagger-ui .parameter__deprecated {
@apply line-through text-gray-400;
}
/* Response section */
.swagger-ui .responses-wrapper {
@apply border-t border-gray-200 dark:border-gray-700;
border-color: var(--swagger-ui-border);
}
.swagger-ui .response-col_status {
@apply font-mono font-bold;
font-family: var(--swagger-ui-font-mono);
}
/* Status code colors */
.swagger-ui .response-col_status .response-undocumented {
@apply text-gray-600 dark:text-gray-400;
}
.swagger-ui .responses-table td {
@apply p-3 border-b border-gray-100 dark:border-gray-700;
border-color: var(--swagger-ui-border);
}
/* Try it out button */
.swagger-ui .btn.try-out__btn {
@apply bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors;
background-color: var(--swagger-ui-accent);
border-radius: var(--swagger-ui-radius-md);
}
.swagger-ui .btn.try-out__btn:hover {
background-color: var(--swagger-ui-accent-hover);
}
.swagger-ui .btn.execute {
@apply bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded-md font-medium transition-colors;
background-color: var(--swagger-ui-success);
}
.swagger-ui .btn.cancel {
@apply bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-md transition-colors;
}
.swagger-ui .btn.clear {
@apply bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md transition-colors;
}
/* Input fields */
.swagger-ui input[type=text],
.swagger-ui input[type=email],
.swagger-ui input[type=password],
.swagger-ui textarea,
.swagger-ui select {
@apply border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent;
border-color: var(--swagger-ui-border);
background-color: var(--swagger-ui-bg-primary);
color: var(--swagger-ui-text-primary);
border-radius: var(--swagger-ui-radius-md);
}
/* Code blocks */
.swagger-ui .highlight-code,
.swagger-ui .microlight {
@apply bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md p-4 font-mono text-sm;
background-color: var(--swagger-ui-bg-secondary);
border-color: var(--swagger-ui-border);
font-family: var(--swagger-ui-font-mono);
border-radius: var(--swagger-ui-radius-md);
}
/* Models section */
.swagger-ui .model-container {
@apply bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg;
background-color: var(--swagger-ui-bg-secondary);
border-color: var(--swagger-ui-border);
border-radius: var(--swagger-ui-radius-lg);
}
.swagger-ui .model-box {
@apply p-4;
}
.swagger-ui .model .property {
@apply py-2 border-b border-gray-100 dark:border-gray-700;
border-color: var(--swagger-ui-border);
}
/* Schema section */
.swagger-ui .model-title {
@apply text-lg font-semibold text-gray-900 dark:text-white mb-2;
color: var(--swagger-ui-text-primary);
}
/* Tags */
.swagger-ui .opblock-tag {
@apply text-2xl font-bold text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700 pb-2 mb-4;
color: var(--swagger-ui-text-primary);
border-color: var(--swagger-ui-border);
}
/* Authorization */
.swagger-ui .auth-container {
@apply bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4;
border-radius: var(--swagger-ui-radius-lg);
}
/* Loading and errors */
.swagger-ui .loading-container {
@apply text-center py-8;
}
.swagger-ui .errors-wrapper {
@apply bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 text-red-700 dark:text-red-300;
background-color: color-mix(in srgb, var(--swagger-ui-error) 10%, transparent);
border-color: var(--swagger-ui-error);
border-radius: var(--swagger-ui-radius-lg);
}
/* Responsive design */
@media (max-width: 768px) {
.swagger-ui .opblock-summary {
@apply px-3 py-2;
}
.swagger-ui .info .title {
@apply text-2xl;
}
.swagger-ui .parameters-container {
@apply p-3;
}
}

19
src/app/globals.css Normal file
View File

@ -0,0 +1,19 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}

25
src/app/layout.tsx Normal file
View File

@ -0,0 +1,25 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import ConditionalLayout from '@/components/ConditionalLayout'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Photos App',
description: 'A Next.js app to display and organize photos',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>
<ConditionalLayout>{children}</ConditionalLayout>
</body>
</html>
)
}

19
src/app/page.tsx Normal file
View File

@ -0,0 +1,19 @@
import PhotoGrid from "@/components/PhotoGrid";
export default function Home() {
return (
<div className="p-6">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
All Photos
</h1>
<p className="text-gray-600 dark:text-gray-400">
Your photo collection
</p>
</div>
<PhotoGrid showMetadata={true} thumbnailSize="medium" />
</div>
)
}

View File

@ -0,0 +1,35 @@
'use client'
import { useState } from 'react'
import { IconApi, IconBook, IconExternalLink } from '@tabler/icons-react'
export default function ApiDocsLink() {
const [showTooltip, setShowTooltip] = useState(false)
return (
<div className="relative">
<a
href="/docs"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors duration-200"
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
<IconApi className="w-4 h-4" />
<span className="hidden sm:inline">API Docs</span>
<IconExternalLink className="w-3 h-3 opacity-50" />
</a>
{showTooltip && (
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 text-xs text-white bg-gray-900 dark:bg-gray-700 rounded-lg shadow-lg whitespace-nowrap z-10">
<div className="flex items-center gap-2">
<IconBook className="w-3 h-3" />
Interactive API Documentation
</div>
<div className="absolute top-full left-1/2 transform -translate-x-1/2 w-2 h-2 bg-gray-900 dark:bg-gray-700 rotate-45"></div>
</div>
)}
</div>
)
}

48
src/components/Button.tsx Normal file
View File

@ -0,0 +1,48 @@
import { ButtonHTMLAttributes, ReactNode } from 'react'
import { Icon } from '@tabler/icons-react'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary'
children: ReactNode
enabled?: boolean
leftIcon?: Icon
iconSize?: number
}
export default function Button({
variant = 'primary',
children,
className = '',
enabled = true,
disabled,
leftIcon: LeftIcon,
iconSize = 16,
...props
}: ButtonProps) {
// Determine if button should be disabled
const isDisabled = disabled || !enabled
const baseClasses = `${LeftIcon ? 'px-3 py-2' : 'px-12 py-2'} rounded font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 transform flex items-center justify-center gap-2`
const variantClasses = {
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}
disabled={isDisabled}
{...props}
>
{LeftIcon && <LeftIcon size={iconSize} />}
{children}
</button>
)
}

View File

@ -0,0 +1,26 @@
'use client'
import { usePathname } from 'next/navigation'
import MainLayout from './MainLayout'
interface ConditionalLayoutProps {
children: React.ReactNode
}
export default function ConditionalLayout({ children }: ConditionalLayoutProps) {
const pathname = usePathname()
// Routes that should not use the main layout (with sidebar)
const excludedRoutes = ['/docs']
const shouldUseMainLayout = !excludedRoutes.some(route =>
pathname.startsWith(route)
)
if (shouldUseMainLayout) {
return <MainLayout>{children}</MainLayout>
}
// For excluded routes, render children directly
return <>{children}</>
}

View File

@ -0,0 +1,252 @@
'use client'
import { useState, useEffect } from 'react'
import { IconFolder, IconClock, IconTrash, IconScan } 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 [scanningDirectories, setScanningDirectories] = useState<Set<string>>(new Set())
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)
}
}
const scanDirectory = async (directory: Directory) => {
const requestStartTime = Date.now()
console.log(`[CLIENT] Starting scan request for directory: ${directory.path}`)
console.log(`[CLIENT] Directory details:`, {
id: directory.id,
name: directory.name,
path: directory.path,
lastScanned: directory.last_scanned,
photoCount: directory.photo_count
})
try {
// Mark directory as scanning
setScanningDirectories(prev => new Set(prev).add(directory.path))
console.log(`[CLIENT] Marked directory as scanning: ${directory.path}`)
console.log(`[CLIENT] Sending POST request to /api/scan`)
const response = await fetch('/api/scan', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ directoryPath: directory.path }),
})
const requestDuration = Date.now() - requestStartTime
console.log(`[CLIENT] API response received after ${requestDuration}ms, status: ${response.status}`)
if (!response.ok) {
const errorText = await response.text()
console.error(`[CLIENT] API request failed:`, errorText)
throw new Error(`Failed to start directory scan: ${response.status} ${errorText}`)
}
const result = await response.json()
console.log(`[CLIENT] Directory scan started successfully:`, result)
console.log(`[CLIENT] Background scan is now running for: ${directory.path}`)
// Remove scanning status after a brief delay (scan runs in background)
setTimeout(() => {
console.log(`[CLIENT] Removing scanning status for: ${directory.path}`)
setScanningDirectories(prev => {
const newSet = new Set(prev)
newSet.delete(directory.path)
return newSet
})
// Refresh directory list to show updated last_scanned time
console.log(`[CLIENT] Refreshing directory list to show updated scan time`)
fetchDirectories()
}, 2000)
} catch (error) {
const requestDuration = Date.now() - requestStartTime
console.error(`[CLIENT] Error starting directory scan for ${directory.path} after ${requestDuration}ms:`, error)
// Remove scanning status on error
setScanningDirectories(prev => {
const newSet = new Set(prev)
newSet.delete(directory.path)
return newSet
})
}
}
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>
<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 className="flex gap-2 mt-2">
<Button
leftIcon={IconScan}
onClick={(e) => {
e.stopPropagation()
scanDirectory(directory)
}}
disabled={scanningDirectories.has(directory.path)}
>
{scanningDirectories.has(directory.path) ? 'Scanning...' : 'Scan'}
</Button>
</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

@ -0,0 +1,267 @@
'use client'
import { useState, useCallback, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { IconCheck, IconX } from '@tabler/icons-react'
import Button from './Button'
interface DirectoryModalProps {
isOpen: boolean
onClose: () => void
onSave: (directory: string) => void
onDirectoryListRefresh?: () => void
}
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)
const [validationTimeout, setValidationTimeout] = useState<NodeJS.Timeout | null>(null)
const [mounted, setMounted] = useState(false)
const [suggestions, setSuggestions] = useState<string[]>([])
const [showSuggestions, setShowSuggestions] = useState(false)
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1)
const suggestionRefs = useRef<(HTMLDivElement | null)[]>([])
useEffect(() => {
setMounted(true)
return () => setMounted(false)
}, [])
const validateDirectory = useCallback(async (path: string) => {
if (!path.trim()) {
setIsValid(null)
return
}
setIsValidating(true)
try {
const response = await fetch('/api/validate-directory', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ directory: path }),
})
const result = await response.json()
setIsValid(result.valid)
setSuggestions(result.suggestions || [])
setShowSuggestions(result.suggestions && result.suggestions.length > 0)
setSelectedSuggestionIndex(-1)
suggestionRefs.current = []
} catch (error) {
setIsValid(false)
} finally {
setIsValidating(false)
}
}, [])
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setDirectory(value)
setShowSuggestions(false) // Hide suggestions while typing
setSelectedSuggestionIndex(-1)
if (validationTimeout) {
clearTimeout(validationTimeout)
}
const timeout = setTimeout(() => {
validateDirectory(value)
}, 300)
setValidationTimeout(timeout)
}
const handleSuggestionClick = (suggestion: string) => {
setDirectory(suggestion)
setShowSuggestions(false)
setSelectedSuggestionIndex(-1)
validateDirectory(suggestion)
}
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) {
case 'ArrowDown':
e.preventDefault()
setSelectedSuggestionIndex(prev => {
const newIndex = prev === -1 ? 0 : (prev < suggestions.length - 1 ? prev + 1 : 0)
setTimeout(() => {
suggestionRefs.current[newIndex]?.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
})
}, 0)
return newIndex
})
break
case 'ArrowUp':
e.preventDefault()
setSelectedSuggestionIndex(prev => {
const newIndex = prev > 0 ? prev - 1 : suggestions.length - 1
setTimeout(() => {
suggestionRefs.current[newIndex]?.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
})
}, 0)
return newIndex
})
break
case 'Enter':
e.preventDefault()
if (selectedSuggestionIndex >= 0 && selectedSuggestionIndex < suggestions.length) {
handleSuggestionClick(suggestions[selectedSuggestionIndex])
} else if (directory.trim() && isValid) {
handleSave()
}
break
case 'Escape':
setShowSuggestions(false)
setSelectedSuggestionIndex(-1)
break
}
}
if (!isOpen || !mounted) return null
console.log('Modal is rendering with isOpen:', isOpen)
const handleSave = async () => {
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())
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()
}
}
}
const handleClose = () => {
setDirectory('')
setIsValid(null)
setSuggestions([])
setShowSuggestions(false)
setSelectedSuggestionIndex(-1)
if (validationTimeout) {
clearTimeout(validationTimeout)
}
onClose()
}
const modalContent = (
<div className="fixed inset-0 bg-black bg-opacity-50 z-[9999] flex items-center justify-center p-4">
<div
className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl w-full max-w-lg max-h-[90vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="p-6 pb-4 border-b border-gray-200 dark:border-gray-600">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Select Directory to Scan
</h2>
</div>
{/* Content Area - Scrollable */}
<div className="flex-1 p-6 min-h-0">
<div className="relative">
<input
type="text"
value={directory}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={() => setShowSuggestions(suggestions.length > 0)}
placeholder="/path/to/photos"
className="w-full p-2 pr-10 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
autoFocus
/>
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
{isValidating && (
<div className="animate-spin rounded-full h-4 w-4 border-2 border-blue-600 border-t-transparent" />
)}
{!isValidating && isValid === true && (
<IconCheck className="h-4 w-4 text-green-600" />
)}
{!isValidating && (isValid === false || (directory && isValid === null)) && (
<IconX className="h-4 w-4 text-red-600" />
)}
</div>
{/* Suggestions - Now with better scrolling */}
{showSuggestions && suggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded shadow-lg max-h-64 overflow-y-auto z-10">
{suggestions.map((suggestion, index) => (
<div
key={index}
ref={(el) => { suggestionRefs.current[index] = el }}
onClick={() => handleSuggestionClick(suggestion)}
className={`px-3 py-2 cursor-pointer text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-600 last:border-b-0 ${
index === selectedSuggestionIndex
? 'bg-blue-100 dark:bg-blue-900'
: 'hover:bg-gray-100 dark:hover:bg-gray-600'
}`}
>
<div className="truncate" title={suggestion}>
{suggestion}
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Fixed Footer with Buttons */}
<div className="p-6 pt-4 border-t border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-750 rounded-b-lg">
<div className="flex gap-2 justify-end">
<Button
onClick={handleClose}
variant="secondary"
>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={!isValid || isValidating}
variant="primary"
>
Save
</Button>
</div>
</div>
</div>
</div>
)
return createPortal(modalContent, document.body)
}

34
src/components/Header.tsx Normal file
View File

@ -0,0 +1,34 @@
'use client'
import Button from './Button'
import {useState} from "react";
interface HeaderProps {
onSelectDirectory: () => void
}
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">
<div className="flex justify-between items-center h-16">
<div className="flex items-center">
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
Photos
</h1>
</div>
<nav className="md:flex space-x-8">
<Button
onClick={onSelectDirectory}
variant="secondary"
className="text-sm"
>
Select Directory
</Button>
</nav>
</div>
</div>
</header>
)
}

View File

@ -0,0 +1,543 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { IconX, IconZoomIn, IconZoomOut, IconMaximize, IconRotate, IconChevronLeft, IconChevronRight, IconShare } from '@tabler/icons-react'
import { Photo } from '@/types/photo'
interface ImageModalProps {
photo: Photo | null
isOpen: boolean
onClose: () => void
photos?: Photo[] // Array of all photos for navigation
onNavigate?: (photo: Photo) => void // Callback for photo navigation
}
export default function ImageModal({ photo, isOpen, onClose, photos = [], onNavigate }: ImageModalProps) {
const [mounted, setMounted] = useState(false)
const [imageLoaded, setImageLoaded] = useState(false)
const [imageError, setImageError] = useState(false)
const [zoom, setZoom] = useState(1)
const [position, setPosition] = useState({ x: 0, y: 0 })
const [isDragging, setIsDragging] = useState(false)
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
const [rotation, setRotation] = useState(0)
const [tags, setTags] = useState<Array<{id: string, name: string, color: string}>>([])
const imageRef = useRef<HTMLImageElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
// Find current photo index for navigation
const currentPhotoIndex = photo ? photos.findIndex(p => p.id === photo.id) : -1
const hasPrevious = currentPhotoIndex > 0
const hasNext = currentPhotoIndex < photos.length - 1
useEffect(() => {
setMounted(true)
return () => setMounted(false)
}, [])
// Reset state when photo changes
useEffect(() => {
if (photo) {
setImageLoaded(false)
setImageError(false)
setZoom(1)
setPosition({ x: 0, y: 0 })
setRotation(0)
// Fetch tags for this photo
const fetchTags = async () => {
try {
const response = await fetch(`/api/photos/${photo.id}/tags`)
if (response.ok) {
const photoTags = await response.json()
setTags(photoTags)
}
} catch (error) {
console.warn('Failed to fetch tags for photo:', photo.id)
setTags([])
}
}
fetchTags()
} else {
setTags([])
}
}, [photo])
// Navigation functions
const handlePrevious = () => {
if (hasPrevious && onNavigate) {
onNavigate(photos[currentPhotoIndex - 1])
}
}
const handleNext = () => {
if (hasNext && onNavigate) {
onNavigate(photos[currentPhotoIndex + 1])
}
}
// Handle keyboard shortcuts
useEffect(() => {
if (!isOpen) return
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'Escape':
onClose()
break
case 'ArrowLeft':
e.preventDefault()
handlePrevious()
break
case 'ArrowRight':
e.preventDefault()
handleNext()
break
case '+':
case '=':
e.preventDefault()
handleZoomIn()
break
case '-':
e.preventDefault()
handleZoomOut()
break
case '0':
e.preventDefault()
handleResetZoom()
break
case 'r':
case 'R':
e.preventDefault()
handleRotate()
break
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [isOpen, zoom, currentPhotoIndex])
// Calculate initial zoom to fit image in viewport
const calculateFitZoom = useCallback(() => {
if (!imageRef.current || !containerRef.current || !photo?.width || !photo?.height) {
return 1
}
const container = containerRef.current
const containerWidth = container.clientWidth - 64 // padding
const containerHeight = container.clientHeight - 64 // padding
const scaleX = containerWidth / photo.width
const scaleY = containerHeight / photo.height
return Math.min(scaleX, scaleY, 1) // Don't scale up smaller images
}, [photo?.width, photo?.height])
const handleImageLoad = () => {
setImageLoaded(true)
setImageError(false)
// Set initial zoom to fit the image
const fitZoom = calculateFitZoom()
setZoom(fitZoom)
}
const handleImageError = () => {
setImageError(true)
setImageLoaded(false)
}
const handleZoomIn = () => {
setZoom(prev => Math.min(prev * 1.25, 5))
}
const handleZoomOut = () => {
setZoom(prev => Math.max(prev * 0.8, 0.1))
}
const handleResetZoom = () => {
const fitZoom = calculateFitZoom()
setZoom(fitZoom)
setPosition({ x: 0, y: 0 })
}
const handleRotate = () => {
setRotation(prev => (prev + 90) % 360)
}
// Mouse drag handling
const handleMouseDown = (e: React.MouseEvent) => {
if (zoom <= 1) return // Only allow dragging when zoomed in
setIsDragging(true)
setDragStart({
x: e.clientX - position.x,
y: e.clientY - position.y
})
}
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!isDragging) return
setPosition({
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y
})
}, [isDragging, dragStart])
const handleMouseUp = useCallback(() => {
setIsDragging(false)
}, [])
// Attach global mouse events for dragging
useEffect(() => {
if (isDragging) {
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
}
}, [isDragging, handleMouseMove, handleMouseUp])
// Mouse wheel zoom
const handleWheel = (e: React.WheelEvent) => {
e.preventDefault()
const delta = e.deltaY < 0 ? 1.1 : 0.9
setZoom(prev => Math.min(Math.max(prev * delta, 0.1), 5))
}
if (!isOpen || !photo || !mounted) return null
const formatMetadata = () => {
let metadata: any = {}
try {
metadata = photo.metadata ? JSON.parse(photo.metadata) : {}
} catch (error) {
return null
}
const exif = metadata.exif || {}
return {
camera: [exif.camera_make, exif.camera_model].filter(Boolean).join(' '),
settings: [
exif.f_number && `f/${exif.f_number}`,
exif.exposure_time && `${exif.exposure_time >= 1 ? exif.exposure_time : `1/${Math.round(1/exif.exposure_time)}`}s`,
exif.iso_speed && `ISO ${exif.iso_speed}`,
exif.focal_length && `${Math.round(exif.focal_length)}mm`
].filter(Boolean).join(' • '),
dimensions: photo.width && photo.height ? `${photo.width} × ${photo.height}` : null,
fileSize: formatFileSize(photo.filesize),
dateTime: exif.date_time_original || photo.created_at
}
}
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const formatDate = (dateString: string) => {
try {
return new Date(dateString).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
} catch {
return 'Unknown'
}
}
const metaInfo = formatMetadata()
// Extract location information for social sharing
const getLocationInfo = () => {
let metadata: any = {}
try {
metadata = photo.metadata ? JSON.parse(photo.metadata) : {}
} catch (error) {
return null
}
const exif = metadata.exif || {}
if (exif.gps && (exif.gps.latitude || exif.gps.longitude)) {
return {
latitude: exif.gps.latitude,
longitude: exif.gps.longitude,
locationString: `📍 ${exif.gps.latitude?.toFixed(4)}, ${exif.gps.longitude?.toFixed(4)}`
}
}
return null
}
const locationInfo = getLocationInfo()
// Build enhanced share quote with location
const buildShareQuote = () => {
const baseQuote = `Check out this photo: ${photo.filename}`
if (locationInfo) {
return `${baseQuote} ${locationInfo.locationString}`
}
if (metaInfo?.camera) {
return `${baseQuote} - Shot with ${metaInfo.camera}`
}
return baseQuote
}
// Native sharing with actual file
const handleNativeShare = async () => {
try {
// Check if Web Share API is supported
if (!navigator.share) {
// Fallback to copying URL to clipboard
await navigator.clipboard.writeText(`${window.location.origin}/api/photos/${photo.id}/full`)
alert('Photo URL copied to clipboard!')
return
}
// Fetch the image as a blob
const response = await fetch(`/api/photos/${photo.id}/full`)
if (!response.ok) throw new Error('Failed to fetch image')
const blob = await response.blob()
const file = new File([blob], photo.filename, { type: blob.type })
// Check if file sharing is supported
if (navigator.canShare && !navigator.canShare({ files: [file] })) {
// Fallback to sharing just text and URL
await navigator.share({
title: photo.filename,
text: buildShareQuote(),
url: `${window.location.origin}/api/photos/${photo.id}/full`
})
} else {
// Share with the actual file
await navigator.share({
title: photo.filename,
text: buildShareQuote(),
files: [file]
})
}
} catch (error) {
console.error('Sharing failed:', error)
// Ultimate fallback - copy to clipboard
try {
await navigator.clipboard.writeText(`${window.location.origin}/api/photos/${photo.id}/full`)
alert('Sharing failed, but photo URL copied to clipboard!')
} catch (clipboardError) {
console.error('Clipboard access failed:', clipboardError)
alert('Sharing not supported on this device')
}
}
}
const modalContent = (
<div
className="fixed inset-0 bg-black bg-opacity-90 z-[9999] flex flex-col"
onClick={onClose}
>
{/* Header */}
<div className="flex items-center justify-between p-4 bg-black bg-opacity-50 text-white">
<div className="flex-1 min-w-0">
<h2 className="text-lg font-medium truncate">{photo.filename}</h2>
{metaInfo && (
<div className="text-sm text-gray-300 space-y-1">
{metaInfo.camera && <div>{metaInfo.camera}</div>}
{metaInfo.settings && <div>{metaInfo.settings}</div>}
<div className="flex gap-4 text-xs">
{metaInfo.dimensions && <span>{metaInfo.dimensions}</span>}
<span>{metaInfo.fileSize}</span>
{metaInfo.dateTime && <span>{formatDate(metaInfo.dateTime)}</span>}
</div>
</div>
)}
{/* Tag pills */}
{tags.length > 0 && (
<div className="mt-2">
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<span
key={tag.id}
className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium text-white"
style={{ backgroundColor: tag.color }}
>
{tag.name}
</span>
))}
</div>
</div>
)}
{/* AI-generated caption */}
{photo.description && (
<div className="mt-3 p-2 bg-black bg-opacity-30 rounded text-sm text-gray-200">
<div className="flex items-start gap-2">
<span className="text-blue-400 text-xs font-medium mt-0.5">AI:</span>
<span className="flex-1">{photo.description}</span>
</div>
</div>
)}
</div>
{/* Controls */}
<div className="flex items-center gap-2 ml-4">
{photos.length > 1 && (
<>
<button
onClick={(e) => { e.stopPropagation(); handlePrevious() }}
disabled={!hasPrevious}
className={`p-2 rounded transition-colors ${
hasPrevious
? 'hover:bg-white hover:bg-opacity-10'
: 'opacity-50 cursor-not-allowed'
}`}
title="Previous image (←)"
>
<IconChevronLeft className="w-5 h-5" />
</button>
<span className="text-sm px-2 min-w-[4rem] text-center">
{currentPhotoIndex + 1} / {photos.length}
</span>
<button
onClick={(e) => { e.stopPropagation(); handleNext() }}
disabled={!hasNext}
className={`p-2 rounded transition-colors ${
hasNext
? 'hover:bg-white hover:bg-opacity-10'
: 'opacity-50 cursor-not-allowed'
}`}
title="Next image (→)"
>
<IconChevronRight className="w-5 h-5" />
</button>
<div className="w-px h-6 bg-gray-600 mx-1" />
</>
)}
<button
onClick={(e) => { e.stopPropagation(); handleZoomOut() }}
className="p-2 rounded hover:bg-white hover:bg-opacity-10 transition-colors"
title="Zoom out (-)"
>
<IconZoomOut className="w-5 h-5" />
</button>
<span className="text-sm px-2 min-w-[4rem] text-center">
{Math.round(zoom * 100)}%
</span>
<button
onClick={(e) => { e.stopPropagation(); handleZoomIn() }}
className="p-2 rounded hover:bg-white hover:bg-opacity-10 transition-colors"
title="Zoom in (+)"
>
<IconZoomIn className="w-5 h-5" />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleResetZoom() }}
className="p-2 rounded hover:bg-white hover:bg-opacity-10 transition-colors"
title="Fit to screen (0)"
>
<IconMaximize className="w-5 h-5" />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleRotate() }}
className="p-2 rounded hover:bg-white hover:bg-opacity-10 transition-colors"
title="Rotate (R)"
>
<IconRotate className="w-5 h-5" />
</button>
{/* Native Share Button */}
{mounted && (
<button
onClick={(e) => { e.stopPropagation(); handleNativeShare() }}
className="p-2 rounded hover:bg-white hover:bg-opacity-10 transition-colors"
title={locationInfo ? "Share photo (includes location)" : "Share photo"}
>
<IconShare className="w-5 h-5" />
</button>
)}
<button
onClick={(e) => { e.stopPropagation(); onClose() }}
className="p-2 rounded hover:bg-white hover:bg-opacity-10 transition-colors"
title="Close (Esc)"
>
<IconX className="w-5 h-5" />
</button>
</div>
</div>
{/* Image Container */}
<div
ref={containerRef}
className="flex-1 relative overflow-hidden cursor-grab"
onClick={(e) => e.stopPropagation()}
onWheel={handleWheel}
>
{!imageLoaded && !imageError && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white border-t-transparent"></div>
</div>
)}
{imageError && (
<div className="absolute inset-0 flex items-center justify-center text-white">
<div className="text-center">
<div className="text-6xl mb-4">📷</div>
<div>Failed to load image</div>
<div className="text-sm text-gray-400 mt-2">{photo.filepath}</div>
</div>
</div>
)}
{photo && (
<img
ref={imageRef}
src={`/api/photos/${photo.id}/full`}
alt={photo.filename}
className={`absolute top-1/2 left-1/2 max-w-none transition-transform duration-200 ${
isDragging ? 'cursor-grabbing' : zoom > 1 ? 'cursor-grab' : 'cursor-default'
}`}
style={{
transform: `translate(calc(-50% + ${position.x}px), calc(-50% + ${position.y}px)) scale(${zoom}) rotate(${rotation}deg)`,
transformOrigin: 'center center'
}}
onLoad={handleImageLoad}
onError={handleImageError}
onMouseDown={handleMouseDown}
draggable={false}
/>
)}
</div>
{/* Footer with keyboard shortcuts */}
<div className="p-2 bg-black bg-opacity-50 text-center text-xs text-gray-400">
Keyboard: <span className="text-gray-300">Esc</span> close <span className="text-gray-300"></span> navigate <span className="text-gray-300">+/-</span> zoom <span className="text-gray-300">0</span> fit <span className="text-gray-300">R</span> rotate <span className="text-gray-300">drag</span> to pan
</div>
</div>
)
return createPortal(modalContent, document.body)
}

View File

@ -0,0 +1,45 @@
'use client'
import { useState } from 'react'
import Header from './Header'
import Sidebar from './Sidebar'
import DirectoryModal from './DirectoryModal'
interface MainLayoutProps {
children: React.ReactNode
}
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 onSelectDirectory={() => setIsModalOpen(true)} />
<div className="flex">
<Sidebar refreshTrigger={directoryListRefreshTrigger} />
<main className="flex-1 min-h-screen">
{children}
</main>
</div>
{isModalOpen && (
<DirectoryModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSave={handleDirectorySave}
onDirectoryListRefresh={handleDirectoryListRefresh}
/>
)}
</div>
)
}

View File

@ -0,0 +1,834 @@
'use client'
import { useState, useEffect } from 'react'
import PhotoThumbnail from './PhotoThumbnail'
import ImageModal from './ImageModal'
import { Photo } from '@/types/photo'
import { IconPhoto, IconFilter, IconSearch, IconSortAscending, IconSortDescending, IconBrain, IconMessage, IconTrash } from '@tabler/icons-react'
interface PhotoGridProps {
directoryPath?: string
showMetadata?: boolean
thumbnailSize?: 'small' | 'medium' | 'large'
}
type SortOption = 'created_at' | 'modified_at' | 'filename' | 'filesize'
type SortOrder = 'ASC' | 'DESC'
export default function PhotoGrid({
directoryPath,
showMetadata = true,
thumbnailSize = 'medium'
}: PhotoGridProps) {
const [photos, setPhotos] = useState<Photo[]>([])
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState<string | null>(null)
const [sortBy, setSortBy] = useState<SortOption>('created_at')
const [sortOrder, setSortOrder] = useState<SortOrder>('DESC')
const [searchTerm, setSearchTerm] = useState('')
const [selectedPhoto, setSelectedPhoto] = useState<Photo | null>(null)
const [pagination, setPagination] = useState({
total: 0,
hasMore: false,
currentPage: 1,
limit: 20,
offset: 0
})
const [isClassifying, setIsClassifying] = useState(false)
const [classificationStatus, setClassificationStatus] = useState<any>(null)
const [classificationProgress, setClassificationProgress] = useState<{
total: number
processed: number
successful: number
failed: number
totalTagsAdded: number
} | null>(null)
const [isCaptioning, setIsCaptioning] = useState(false)
const [captionProgress, setCaptionProgress] = useState<{
total: number
processed: number
successful: number
failed: number
skipped: number
} | null>(null)
const [captionStatus, setCaptionStatus] = useState<any>(null)
const [isClearing, setIsClearing] = useState(false)
const [clearStatus, setClearStatus] = useState<any>(null)
const fetchPhotos = async (reset = true) => {
try {
if (reset) {
setLoading(true)
setPhotos([])
} else {
setLoadingMore(true)
}
setError(null)
const offset = reset ? 0 : pagination.offset
const params = new URLSearchParams({
sortBy,
sortOrder,
limit: pagination.limit.toString(),
offset: offset.toString()
})
if (directoryPath) {
params.append('directory', directoryPath)
}
const response = await fetch(`/api/photos?${params}`)
if (!response.ok) {
throw new Error('Failed to fetch photos')
}
const data = await response.json()
if (reset) {
setPhotos(data.photos || [])
} else {
// Prevent duplicates by filtering out photos we already have using functional update
setPhotos(prev => {
const incomingPhotos = data.photos || []
const newPhotos = incomingPhotos.filter(newPhoto =>
!prev.some(existingPhoto => existingPhoto.id === newPhoto.id)
)
console.log(`[PHOTO GRID] Received ${incomingPhotos.length} photos, ${newPhotos.length} are new, ${incomingPhotos.length - newPhotos.length} were duplicates`)
return [...prev, ...newPhotos]
})
}
setPagination({
total: data.pagination.total,
hasMore: data.pagination.hasMore,
currentPage: data.pagination.currentPage,
limit: data.pagination.limit,
offset: offset + (data.photos || []).length
})
} catch (error) {
console.error('Error fetching photos:', error)
setError('Failed to load photos')
} finally {
setLoading(false)
setLoadingMore(false)
}
}
const loadMorePhotos = () => {
if (!loadingMore && pagination.hasMore) {
console.log(`[PHOTO GRID] Loading more photos: offset=${pagination.offset}, current photos=${photos.length}`)
fetchPhotos(false)
}
}
useEffect(() => {
fetchPhotos()
}, [directoryPath, sortBy, sortOrder])
// Infinite scroll effect
useEffect(() => {
let scrollTimeout: NodeJS.Timeout | null = null
const handleScroll = () => {
if (scrollTimeout) return // Prevent multiple calls
scrollTimeout = setTimeout(() => {
if (
window.innerHeight + document.documentElement.scrollTop + 1000 >=
document.documentElement.offsetHeight
) {
loadMorePhotos()
}
scrollTimeout = null
}, 100) // 100ms throttle
}
window.addEventListener('scroll', handleScroll)
return () => {
window.removeEventListener('scroll', handleScroll)
if (scrollTimeout) clearTimeout(scrollTimeout)
}
}, [loadingMore, pagination.hasMore])
// Filter photos based on search term
const filteredPhotos = photos.filter(photo => {
if (!searchTerm) return true
const searchLower = searchTerm.toLowerCase()
// Search in filename
if (photo.filename.toLowerCase().includes(searchLower)) return true
// Search in metadata
try {
const metadata = photo.metadata ? JSON.parse(photo.metadata) : {}
const exif = metadata.exif || {}
// Search in camera info
if (exif.camera_make?.toLowerCase().includes(searchLower)) return true
if (exif.camera_model?.toLowerCase().includes(searchLower)) return true
if (exif.lens_model?.toLowerCase().includes(searchLower)) return true
} catch (error) {
// Ignore metadata parsing errors for search
}
return false
})
const handlePhotoClick = (photo: Photo) => {
setSelectedPhoto(photo)
}
const handleSortChange = (newSortBy: SortOption) => {
if (newSortBy === sortBy) {
// Toggle sort order if same field
setSortOrder(sortOrder === 'ASC' ? 'DESC' : 'ASC')
} else {
setSortBy(newSortBy)
setSortOrder('DESC') // Default to descending for new field
}
// Reset pagination when sorting changes
setPagination(prev => ({ ...prev, currentPage: 1, hasMore: false, offset: 0 }))
}
// Classification functions
const handleBatchClassify = async () => {
if (isClassifying) return
setIsClassifying(true)
setClassificationProgress(null)
try {
console.log('[PHOTO GRID] Starting full database batch classification...')
// First, get the total count of untagged photos
const statusResponse = await fetch('/api/classify/batch')
if (!statusResponse.ok) {
throw new Error('Failed to get classification status')
}
const status = await statusResponse.json()
const totalUntagged = status.untagged
if (totalUntagged === 0) {
alert('All photos are already tagged!')
return
}
console.log(`[PHOTO GRID] Found ${totalUntagged} untagged photos to process`)
// Initialize progress tracking
setClassificationProgress({
total: totalUntagged,
processed: 0,
successful: 0,
failed: 0,
totalTagsAdded: 0
})
const batchSize = 5 // Process 5 photos at a time
let offset = 0
let totalProcessed = 0
let totalSuccessful = 0
let totalFailed = 0
let totalTagsAdded = 0
// Process photos in batches
while (totalProcessed < totalUntagged) {
console.log(`[PHOTO GRID] Processing batch: offset=${offset}, batchSize=${batchSize}`)
const response = await fetch('/api/classify/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
limit: batchSize,
offset: offset,
minConfidence: 0.3,
onlyUntagged: true
}),
})
if (!response.ok) {
throw new Error('Failed to classify batch')
}
const result = await response.json()
console.log(`[PHOTO GRID] Batch result:`, result.summary)
// Update totals
totalProcessed += result.summary.processed
totalSuccessful += result.summary.successful
totalFailed += result.summary.failed
totalTagsAdded += result.summary.totalTagsAdded
// Update progress
setClassificationProgress({
total: totalUntagged,
processed: totalProcessed,
successful: totalSuccessful,
failed: totalFailed,
totalTagsAdded
})
// If no more photos to process, break
if (!result.hasMore || result.summary.processed === 0) {
console.log('[PHOTO GRID] No more photos to process')
break
}
offset += result.summary.processed
// Small delay between batches to prevent overwhelming the system
await new Promise(resolve => setTimeout(resolve, 500))
}
console.log(`[PHOTO GRID] Classification complete: ${totalSuccessful} successful, ${totalFailed} failed, ${totalTagsAdded} total tags added`)
alert(`Classification complete!\n${totalSuccessful} photos processed\n${totalTagsAdded} tags added\n${totalFailed > 0 ? `${totalFailed} failed` : ''}`)
// Refresh photos to show new tags
fetchPhotos()
// Refresh classification status
fetchClassificationStatus()
} catch (error) {
console.error('[PHOTO GRID] Classification error:', error)
alert('Failed to classify photos. Check console for details.')
} finally {
setIsClassifying(false)
setClassificationProgress(null)
}
}
const fetchClassificationStatus = async () => {
try {
const response = await fetch(`/api/classify/batch${directoryPath ? `?directory=${encodeURIComponent(directoryPath)}` : ''}`)
if (response.ok) {
const status = await response.json()
setClassificationStatus(status)
}
} catch (error) {
console.warn('Failed to fetch classification status:', error)
}
}
const fetchCaptionStatus = async () => {
try {
const response = await fetch(`/api/caption/batch${directoryPath ? `?directory=${encodeURIComponent(directoryPath)}` : ''}`)
if (response.ok) {
const status = await response.json()
setCaptionStatus(status)
}
} catch (error) {
console.warn('Failed to fetch caption status:', error)
}
}
const fetchClearStatus = async () => {
try {
const response = await fetch(`/api/tags/clear${directoryPath ? `?directory=${encodeURIComponent(directoryPath)}` : ''}`)
if (response.ok) {
const status = await response.json()
setClearStatus(status)
}
} catch (error) {
console.warn('Failed to fetch clear status:', error)
}
}
// Clear all tags function
const handleClearAllTags = async () => {
if (isClearing) return
// Get status for confirmation if not available
let statusToUse = clearStatus
if (!statusToUse) {
try {
const response = await fetch(`/api/tags/clear${directoryPath ? `?directory=${encodeURIComponent(directoryPath)}` : ''}`)
if (response.ok) {
statusToUse = await response.json()
setClearStatus(statusToUse)
}
} catch (error) {
console.warn('Failed to fetch clear status:', error)
}
}
if (!statusToUse || statusToUse.totalTags === 0) {
alert('No tags found to clear.')
return
}
const confirmMessage = directoryPath
? `This will permanently delete ${statusToUse.totalTags} tags from ${statusToUse.photosWithTags} photos in "${directoryPath}".\n\nThis action cannot be undone. Are you sure?`
: `This will permanently delete ${statusToUse.totalTags} tags from ${statusToUse.photosWithTags} photos across your entire database.\n\nThis action cannot be undone. Are you sure?`
if (!confirm(confirmMessage)) {
return
}
setIsClearing(true)
try {
console.log('[PHOTO GRID] Starting clear all tags...')
const response = await fetch('/api/tags/clear', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
directory: directoryPath,
confirmClear: true
}),
})
if (!response.ok) {
throw new Error('Failed to clear tags')
}
const result = await response.json()
console.log('[PHOTO GRID] Clear tags result:', result)
alert(`Tags cleared successfully!\n${result.summary.totalTagsCleared} tags removed from ${result.summary.photosProcessed} photos`)
// Refresh photos to reflect cleared tags
fetchPhotos()
// Refresh statuses
fetchClassificationStatus()
fetchClearStatus()
} catch (error) {
console.error('[PHOTO GRID] Clear tags error:', error)
alert('Failed to clear tags. Check console for details.')
} finally {
setIsClearing(false)
}
}
// Caption functions
const handleBatchCaption = async () => {
if (isCaptioning) return
setIsCaptioning(true)
setCaptionProgress(null)
try {
console.log('[PHOTO GRID] Starting full database batch captioning...')
// First, get the total count of uncaptioned photos
const statusResponse = await fetch('/api/caption/batch')
if (!statusResponse.ok) {
throw new Error('Failed to get caption status')
}
const status = await statusResponse.json()
const totalUncaptioned = status.uncaptioned
if (totalUncaptioned === 0) {
alert('All photos already have captions!')
return
}
console.log(`[PHOTO GRID] Found ${totalUncaptioned} uncaptioned photos to process`)
// Initialize progress tracking
setCaptionProgress({
total: totalUncaptioned,
processed: 0,
successful: 0,
failed: 0,
skipped: 0
})
const batchSize = 3 // Process 3 photos at a time (captioning is more intensive)
let offset = 0
let totalProcessed = 0
let totalSuccessful = 0
let totalFailed = 0
let totalSkipped = 0
// Process photos in batches
while (totalProcessed < totalUncaptioned) {
console.log(`[PHOTO GRID] Processing caption batch: offset=${offset}, batchSize=${batchSize}`)
const response = await fetch('/api/caption/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
limit: batchSize,
offset: offset,
onlyUncaptioned: true,
overwrite: false
}),
})
if (!response.ok) {
throw new Error('Failed to caption batch')
}
const result = await response.json()
console.log(`[PHOTO GRID] Caption batch result:`, result.summary)
// Update totals
totalProcessed += result.summary.processed
totalSuccessful += result.summary.successful
totalFailed += result.summary.failed
totalSkipped += result.summary.skipped
// Update progress
setCaptionProgress({
total: totalUncaptioned,
processed: totalProcessed,
successful: totalSuccessful,
failed: totalFailed,
skipped: totalSkipped
})
// If no more photos to process, break
if (!result.hasMore || result.summary.processed === 0) {
console.log('[PHOTO GRID] No more photos to caption')
break
}
offset += result.summary.processed
// Longer delay between batches for captioning (more intensive)
await new Promise(resolve => setTimeout(resolve, 1000))
}
console.log(`[PHOTO GRID] Captioning complete: ${totalSuccessful} successful, ${totalSkipped} skipped, ${totalFailed} failed`)
alert(`Captioning complete!\n${totalSuccessful} photos captioned\n${totalSkipped > 0 ? `${totalSkipped} skipped\n` : ''}${totalFailed > 0 ? `${totalFailed} failed` : ''}`)
// Refresh photos to show new captions
fetchPhotos()
// Refresh caption status
fetchCaptionStatus()
} catch (error) {
console.error('[PHOTO GRID] Captioning error:', error)
alert('Failed to caption photos. Check console for details.')
} finally {
setIsCaptioning(false)
setCaptionProgress(null)
}
}
// Fetch classification, caption, and clear status when component mounts or directory changes
useEffect(() => {
fetchClassificationStatus()
fetchCaptionStatus()
fetchClearStatus()
}, [directoryPath])
// Grid classes based on thumbnail size - made thumbnails larger
const gridClasses = {
small: 'grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 xl:grid-cols-12 gap-2',
medium: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4',
large: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6'
}
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent mb-4"></div>
<p className="text-gray-600 dark:text-gray-400">Loading photos...</p>
</div>
)
}
if (error) {
return (
<div className="flex flex-col items-center justify-center py-12 text-red-600 dark:text-red-400">
<IconPhoto className="w-12 h-12 mb-4 opacity-50" />
<p className="text-lg font-medium mb-2">Error Loading Photos</p>
<p className="text-sm opacity-75">{error}</p>
<button
onClick={fetchPhotos}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
Try Again
</button>
</div>
)
}
if (photos.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400">
<IconPhoto className="w-16 h-16 mb-4 opacity-50" />
<p className="text-xl font-medium mb-2">No Photos Found</p>
<p className="text-sm opacity-75">
{directoryPath
? `No photos found in ${directoryPath}`
: 'No photos have been scanned yet. Select a directory and click scan to get started.'
}
</p>
</div>
)
}
return (
<div className="p-6 space-y-6">
{/* Classification Progress Bar */}
{isClassifying && classificationProgress && (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<IconBrain className="w-4 h-4 text-purple-600 animate-pulse" />
<span className="text-sm font-medium text-gray-900 dark:text-white">
Auto-tagging in progress...
</span>
</div>
<span className="text-sm text-gray-500 dark:text-gray-400">
{classificationProgress.processed}/{classificationProgress.total}
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-2">
<div
className="bg-purple-600 h-2 rounded-full transition-all duration-300"
style={{
width: `${(classificationProgress.processed / classificationProgress.total) * 100}%`
}}
/>
</div>
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400">
<span>
{classificationProgress.successful} successful
{classificationProgress.failed > 0 && ` • ✗ ${classificationProgress.failed} failed`}
</span>
<span>{classificationProgress.totalTagsAdded} tags added</span>
</div>
</div>
)}
{/* Captioning Progress Bar */}
{isCaptioning && captionProgress && (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<IconMessage className="w-4 h-4 text-blue-600 animate-pulse" />
<span className="text-sm font-medium text-gray-900 dark:text-white">
Generating captions...
</span>
</div>
<span className="text-sm text-gray-500 dark:text-gray-400">
{captionProgress.processed}/{captionProgress.total}
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{
width: `${(captionProgress.processed / captionProgress.total) * 100}%`
}}
/>
</div>
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400">
<span>
{captionProgress.successful} successful
{captionProgress.skipped > 0 && ` • ⏭ ${captionProgress.skipped} skipped`}
{captionProgress.failed > 0 && ` • ✗ ${captionProgress.failed} failed`}
</span>
<span>{captionProgress.successful} captions generated</span>
</div>
</div>
)}
{/* Controls */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<div className="flex items-center gap-4">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
Photos {directoryPath && `in ${directoryPath.split('/').pop()}`}
</h2>
<span className="text-sm text-gray-500 dark:text-gray-400">
{filteredPhotos.length} of {pagination.total} photo{pagination.total !== 1 ? 's' : ''}
{pagination.hasMore && <span className="ml-1"> Loading more...</span>}
</span>
</div>
<div className="flex items-center gap-3">
{/* Classification Button */}
<button
onClick={handleBatchClassify}
disabled={isClassifying || isCaptioning || pagination.total === 0}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
isClassifying || isCaptioning || pagination.total === 0
? 'bg-gray-300 dark:bg-gray-600 text-gray-500 cursor-not-allowed'
: 'bg-purple-600 hover:bg-purple-700 text-white'
}`}
title={
pagination.total === 0
? 'No photos to classify'
: classificationStatus
? `${classificationStatus.untagged} photos need tagging`
: 'Auto-tag all photos with AI (processes 5 at a time)'
}
>
<IconBrain className={`w-4 h-4 ${isClassifying ? 'animate-pulse' : ''}`} />
{isClassifying ? (
classificationProgress ? (
`${classificationProgress.processed}/${classificationProgress.total}`
) : 'Starting...'
) : 'Auto-tag All'}
{!isClassifying && !isCaptioning && classificationStatus && classificationStatus.untagged > 0 && (
<span className="ml-1 px-2 py-1 text-xs bg-purple-500 rounded-full">
{classificationStatus.untagged}
</span>
)}
</button>
{/* Caption Button */}
<button
onClick={handleBatchCaption}
disabled={isClassifying || isCaptioning || pagination.total === 0}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
isClassifying || isCaptioning || pagination.total === 0
? 'bg-gray-300 dark:bg-gray-600 text-gray-500 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700 text-white'
}`}
title={
pagination.total === 0
? 'No photos to caption'
: captionStatus
? `${captionStatus.uncaptioned} photos need captions`
: 'Generate detailed captions for all photos (processes 3 at a time)'
}
>
<IconMessage className={`w-4 h-4 ${isCaptioning ? 'animate-pulse' : ''}`} />
{isCaptioning ? (
captionProgress ? (
`${captionProgress.processed}/${captionProgress.total}`
) : 'Starting...'
) : 'Caption All'}
{!isClassifying && !isCaptioning && captionStatus && captionStatus.uncaptioned > 0 && (
<span className="ml-1 px-2 py-1 text-xs bg-blue-500 rounded-full">
{captionStatus.uncaptioned}
</span>
)}
</button>
{/* Clear All Tags Button */}
<button
onClick={handleClearAllTags}
disabled={isClassifying || isCaptioning || isClearing || pagination.total === 0}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
isClassifying || isCaptioning || isClearing || pagination.total === 0
? 'bg-gray-300 dark:bg-gray-600 text-gray-500 cursor-not-allowed'
: 'bg-red-600 hover:bg-red-700 text-white'
}`}
title={
pagination.total === 0
? 'No photos to clear tags from'
: clearStatus
? `Clear ${clearStatus.totalTags} tags from ${clearStatus.photosWithTags} photos`
: 'Clear all tags from photos (permanent action)'
}
>
<IconTrash className={`w-4 h-4 ${isClearing ? 'animate-pulse' : ''}`} />
{isClearing ? 'Clearing...' : 'Clear All Tags'}
{!isClearing && !isClassifying && !isCaptioning && clearStatus && clearStatus.totalTags > 0 && (
<span className="ml-1 px-2 py-1 text-xs bg-red-500 rounded-full">
{clearStatus.totalTags}
</span>
)}
</button>
{/* Search */}
<div className="relative">
<IconSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Search photos..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
{/* Sort controls */}
<div className="flex items-center gap-2">
<IconFilter className="w-4 h-4 text-gray-400" />
<select
value={sortBy}
onChange={(e) => handleSortChange(e.target.value as SortOption)}
className="border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm px-3 py-2 focus:ring-2 focus:ring-blue-500"
>
<option value="created_at">Date Created</option>
<option value="modified_at">Date Modified</option>
<option value="filename">Filename</option>
<option value="filesize">File Size</option>
</select>
<button
onClick={() => setSortOrder(sortOrder === 'ASC' ? 'DESC' : 'ASC')}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
title={`Sort ${sortOrder === 'ASC' ? 'Descending' : 'Ascending'}`}
>
{sortOrder === 'ASC' ? <IconSortAscending className="w-4 h-4" /> : <IconSortDescending className="w-4 h-4" />}
</button>
</div>
</div>
</div>
{/* Photo Grid */}
<div className={`grid ${gridClasses[thumbnailSize]}`}>
{filteredPhotos.map((photo) => (
<PhotoThumbnail
key={`${photo.id}-${photo.filepath}`}
photo={photo}
size={thumbnailSize}
showMetadata={showMetadata}
onPhotoClick={handlePhotoClick}
/>
))}
</div>
{/* Load More Button/Indicator */}
{pagination.hasMore && !loading && (
<div className="flex flex-col items-center justify-center py-8">
{loadingMore ? (
<>
<div className="animate-spin rounded-full h-8 w-8 border-4 border-blue-600 border-t-transparent mb-2"></div>
<p className="text-sm text-gray-600 dark:text-gray-400">Loading more photos...</p>
</>
) : (
<button
onClick={loadMorePhotos}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Load More Photos
</button>
)}
</div>
)}
{/* End of Results */}
{!loading && !loadingMore && !pagination.hasMore && photos.length > 0 && (
<div className="flex flex-col items-center justify-center py-8 text-gray-500 dark:text-gray-400">
<p className="text-sm">You've reached the end of your photos</p>
<p className="text-xs mt-1">Showing all {pagination.total} photos</p>
</div>
)}
{/* Full Resolution Image Modal */}
<ImageModal
photo={selectedPhoto}
isOpen={!!selectedPhoto}
onClose={() => setSelectedPhoto(null)}
photos={filteredPhotos}
onNavigate={setSelectedPhoto}
/>
</div>
)
}

View File

@ -0,0 +1,270 @@
'use client'
import { useState, useEffect } from 'react'
import { IconCamera, IconMapPin, IconCalendar, IconEye, IconHeart, IconStar } from '@tabler/icons-react'
import { Photo } from '@/types/photo'
interface PhotoThumbnailProps {
photo: Photo
size?: 'small' | 'medium' | 'large'
showMetadata?: boolean
onPhotoClick?: (photo: Photo) => void
}
export default function PhotoThumbnail({
photo,
size = 'medium',
showMetadata = false,
onPhotoClick
}: PhotoThumbnailProps) {
const [imageError, setImageError] = useState(false)
const [showDetails, setShowDetails] = useState(false)
const [tags, setTags] = useState<Array<{id: string, name: string, color: string}>>([])
// Fetch tags for this photo
useEffect(() => {
const fetchTags = async () => {
try {
const response = await fetch(`/api/photos/${photo.id}/tags`)
if (response.ok) {
const photoTags = await response.json()
setTags(photoTags)
}
} catch (error) {
console.warn('Failed to fetch tags for photo:', photo.id)
}
}
fetchTags()
}, [photo.id])
// Parse metadata
let metadata: any = {}
try {
metadata = photo.metadata ? JSON.parse(photo.metadata) : {}
} catch (error) {
console.warn('Failed to parse photo metadata:', error)
}
const exif = metadata.exif || {}
// Size configurations - keep square containers for grid layout
const sizeConfig = {
small: {
container: 'aspect-square',
thumbnail: 150
},
medium: {
container: 'aspect-square',
thumbnail: 200
},
large: {
container: 'aspect-square',
thumbnail: 300
}
}
const config = sizeConfig[size]
// Format metadata for display
const formatExposureTime = (time: number) => {
if (time >= 1) return `${time}s`
return `1/${Math.round(1 / time)}s`
}
const formatFocalLength = (length: number) => `${Math.round(length)}mm`
const formatISO = (iso: number | number[]) => {
const isoValue = Array.isArray(iso) ? iso[0] : iso
return `ISO ${isoValue}`
}
const formatDate = (dateString: string) => {
try {
return new Date(dateString).toLocaleDateString()
} catch {
return 'Unknown'
}
}
const formatDateTime = (dateString: string) => {
try {
return new Date(dateString).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
} catch {
return 'Unknown'
}
}
// Get the best available date from EXIF data
const getBestDate = () => {
if (exif.date_time_original) return exif.date_time_original
if (exif.date_time_digitized) return exif.date_time_digitized
if (exif.date_time) return exif.date_time
if (photo.created_at) return photo.created_at
return null
}
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
return (
<div
className={`relative group cursor-pointer overflow-hidden rounded-lg shadow-md hover:shadow-lg transition-all duration-200 bg-gray-100 dark:bg-gray-800 ${config.container} w-full`}
onClick={() => onPhotoClick?.(photo)}
onMouseEnter={() => setShowDetails(true)}
onMouseLeave={() => setShowDetails(false)}
>
{/* Thumbnail Image */}
{!imageError ? (
<img
src={`/api/photos/${photo.id}/thumbnail?size=${config.thumbnail}`}
alt={photo.filename}
className="absolute inset-0 w-full h-full object-contain transition-transform duration-200 group-hover:scale-105"
onError={() => setImageError(true)}
loading="lazy"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400 dark:text-gray-600">
<IconCamera size={32} />
</div>
)}
{/* Favorite indicator */}
{photo.favorite && (
<div className="absolute top-2 right-2 z-10">
<IconHeart className="w-5 h-5 text-red-500 fill-current" />
</div>
)}
{/* Rating indicator */}
{photo.rating && photo.rating > 0 && (
<div className="absolute top-2 left-2 z-10 flex">
{[...Array(photo.rating)].map((_, i) => (
<IconStar key={i} className="w-4 h-4 text-yellow-400 fill-current" />
))}
</div>
)}
{/* Tag pills */}
{tags.length > 0 && (
<div className="absolute bottom-2 left-2 right-2 z-10">
<div className="flex flex-wrap gap-1">
{tags.slice(0, size === 'large' ? 6 : size === 'medium' ? 4 : 2).map((tag) => (
<span
key={tag.id}
className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium text-white bg-black bg-opacity-70 backdrop-blur-sm"
style={{ backgroundColor: `${tag.color}cc` }} // Add some transparency
title={tag.name}
>
{tag.name}
</span>
))}
{tags.length > (size === 'large' ? 6 : size === 'medium' ? 4 : 2) && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium text-white bg-gray-700 bg-opacity-80">
+{tags.length - (size === 'large' ? 6 : size === 'medium' ? 4 : 2)}
</span>
)}
</div>
</div>
)}
{/* Metadata overlay - appears at bottom, leaving image visible */}
{showMetadata && (showDetails || size === 'large') && (
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black via-black/80 to-transparent text-white p-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<div className="space-y-1 text-xs">
{/* Filename */}
<div className="font-medium truncate" title={photo.filename}>
{photo.filename}
</div>
{/* Camera info and settings in a compact row */}
<div className="flex items-center justify-between text-gray-300">
{(exif.camera_make || exif.camera_model) && (
<div className="flex items-center gap-1 truncate flex-1 mr-2">
<IconCamera className="w-3 h-3 flex-shrink-0" />
<span className="truncate">
{[exif.camera_make, exif.camera_model].filter(Boolean).join(' ')}
</span>
</div>
)}
{/* Photo settings - compact display */}
{(exif.f_number || exif.exposure_time || exif.iso_speed) && (
<div className="flex items-center gap-2 text-xs">
{exif.f_number && <span>f/{exif.f_number}</span>}
{exif.exposure_time && <span>{formatExposureTime(exif.exposure_time)}</span>}
{exif.iso_speed && <span>{formatISO(exif.iso_speed)}</span>}
</div>
)}
</div>
{/* Second row: location, date, dimensions */}
<div className="flex items-center justify-between text-gray-400 text-xs">
<div className="flex items-center gap-3">
{/* GPS location */}
{exif.gps && (exif.gps.latitude || exif.gps.longitude) && (
<div className="flex items-center gap-1">
<IconMapPin className="w-3 h-3" />
<span>{exif.gps.latitude?.toFixed(2)}, {exif.gps.longitude?.toFixed(2)}</span>
</div>
)}
{/* Date taken with time */}
{getBestDate() && (
<div className="flex items-center gap-1">
<IconCalendar className="w-3 h-3" />
<span title={`Original: ${exif.date_time_original || 'N/A'}\nDigitized: ${exif.date_time_digitized || 'N/A'}\nModified: ${exif.date_time || 'N/A'}`}>
{formatDateTime(getBestDate()!)}
</span>
</div>
)}
</div>
{/* Image dimensions and file size */}
<div className="text-right">
{photo.width && photo.height && (
<div>{photo.width} × {photo.height}</div>
)}
<div>{formatFileSize(photo.filesize)}</div>
</div>
</div>
{/* Third row: Additional EXIF date info for large thumbnails */}
{size === 'large' && (exif.date_time_original || exif.date_time_digitized || exif.date_time) && (
<div className="text-gray-500 text-xs mt-1 space-y-0.5">
{exif.date_time_original && exif.date_time_original !== getBestDate() && (
<div>📷 Taken: {formatDateTime(exif.date_time_original)}</div>
)}
{exif.date_time_digitized && exif.date_time_digitized !== getBestDate() && (
<div>💾 Digitized: {formatDateTime(exif.date_time_digitized)}</div>
)}
{exif.date_time && exif.date_time !== getBestDate() && (
<div>📝 Modified: {formatDateTime(exif.date_time)}</div>
)}
</div>
)}
</div>
</div>
)}
{/* Simple overlay for small sizes */}
{size === 'small' && showDetails && (
<div className="absolute inset-0 bg-black bg-opacity-50 text-white p-2 flex items-end opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<div className="text-xs font-medium truncate" title={photo.filename}>
{photo.filename}
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,29 @@
'use client'
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 overflow-y-auto`}>
<DirectoryList
onDirectorySelect={handleDirectorySelect}
selectedDirectory={selectedDirectory}
refreshTrigger={refreshTrigger}
/>
</aside>
)
}

View File

@ -0,0 +1,78 @@
'use client'
import { useEffect } from 'react'
import dynamic from 'next/dynamic'
// Dynamically import Swagger UI with SSR disabled
const SwaggerUIComponent = dynamic(() => import('swagger-ui-react'), {
ssr: false,
loading: () => <div className="text-center p-8 text-gray-600 dark:text-gray-400">Loading API Documentation...</div>
})
interface SwaggerUIWrapperProps {
spec: any
[key: string]: any
}
export default function SwaggerUIWrapper({ spec, ...props }: SwaggerUIWrapperProps) {
useEffect(() => {
// Store original console methods
const originalError = console.error
const originalWarn = console.warn
// Override console methods to filter out React strict mode warnings
const filterMessage = (message: any) => {
if (typeof message === 'string') {
return (
message.includes('UNSAFE_componentWillReceiveProps') ||
message.includes('componentWillReceiveProps') ||
message.includes('UNSAFE_componentWillMount') ||
message.includes('componentWillMount') ||
message.includes('UNSAFE_componentWillUpdate') ||
message.includes('componentWillUpdate') ||
message.includes('strict mode is not recommended') ||
message.includes('ModelCollapse') ||
message.includes('OperationContainer')
)
}
return false
}
console.error = (...args) => {
if (!filterMessage(args[0])) {
originalError.apply(console, args)
}
}
console.warn = (...args) => {
if (!filterMessage(args[0])) {
originalWarn.apply(console, args)
}
}
// Cleanup function to restore original console methods
return () => {
console.error = originalError
console.warn = originalWarn
}
}, [])
if (!spec) {
return null
}
return (
<SwaggerUIComponent
spec={spec}
docExpansion="list"
defaultModelsExpandDepth={1}
tryItOutEnabled={true}
displayOperationId={false}
displayRequestDuration={true}
filter={true}
showExtensions={true}
showCommonExtensions={true}
{...props}
/>
)
}

198
src/lib/database.ts Normal file
View File

@ -0,0 +1,198 @@
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
thumbnail_blob BLOB, -- Cached thumbnail image data
thumbnail_size INTEGER, -- Size parameter used for cached thumbnail
thumbnail_generated_at DATETIME, -- When thumbnail was generated
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 image_hashes table for duplicate detection
database.exec(`
CREATE TABLE IF NOT EXISTS image_hashes (
id TEXT PRIMARY KEY,
sha256_hash TEXT NOT NULL UNIQUE,
first_seen_at DATETIME DEFAULT CURRENT_TIMESTAMP,
file_count INTEGER DEFAULT 1
)
`)
// Create photo_hashes junction table to associate photos with their hashes
database.exec(`
CREATE TABLE IF NOT EXISTS photo_hashes (
photo_id TEXT NOT NULL,
hash_id TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (photo_id, hash_id),
FOREIGN KEY (photo_id) REFERENCES photos (id) ON DELETE CASCADE,
FOREIGN KEY (hash_id) REFERENCES image_hashes (id) ON DELETE CASCADE
)
`)
// Create photo_conflicts table for files with same path but different content
database.exec(`
CREATE TABLE IF NOT EXISTS photo_conflicts (
id TEXT PRIMARY KEY,
filename TEXT NOT NULL,
filepath TEXT NOT NULL,
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
thumbnail_blob BLOB, -- Cached thumbnail image data
thumbnail_size INTEGER, -- Size parameter used for cached thumbnail
thumbnail_generated_at DATETIME, -- When thumbnail was generated
indexed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
conflict_detected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
original_photo_id TEXT, -- Reference to the original photo record
conflict_reason TEXT -- Why this was flagged as a conflict
)
`)
// 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);
CREATE INDEX IF NOT EXISTS idx_image_hashes_sha256 ON image_hashes (sha256_hash);
CREATE INDEX IF NOT EXISTS idx_photo_hashes_photo_id ON photo_hashes (photo_id);
CREATE INDEX IF NOT EXISTS idx_photo_hashes_hash_id ON photo_hashes (hash_id);
CREATE INDEX IF NOT EXISTS idx_photos_thumbnail_size ON photos (thumbnail_size);
CREATE INDEX IF NOT EXISTS idx_photos_thumbnail_generated_at ON photos (thumbnail_generated_at);
CREATE INDEX IF NOT EXISTS idx_photo_conflicts_filepath ON photo_conflicts (filepath);
CREATE INDEX IF NOT EXISTS idx_photo_conflicts_original_photo_id ON photo_conflicts (original_photo_id);
CREATE INDEX IF NOT EXISTS idx_photo_conflicts_detected_at ON photo_conflicts (conflict_detected_at);
`)
}
export function closeDatabase() {
if (db) {
db.close()
db = null
}
}
// Graceful shutdown
process.on('exit', closeDatabase)
process.on('SIGINT', closeDatabase)
process.on('SIGTERM', closeDatabase)

459
src/lib/file-scanner.ts Normal file
View File

@ -0,0 +1,459 @@
import { readdir, stat } from 'fs/promises'
import { createReadStream } from 'fs'
import { join, extname, basename } from 'path'
import { photoService } from './photo-service'
import { randomUUID, createHash } from 'crypto'
import sharp from 'sharp'
import exifReader from 'exif-reader'
// Supported image file extensions
const SUPPORTED_EXTENSIONS = new Set([
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.tif', '.ico', '.svg'
])
interface ScanResult {
totalFiles: number
photosAdded: number
photosSkipped: number
errors: number
}
export async function scanDirectory(directoryPath: string): Promise<ScanResult> {
const scanStartTime = Date.now()
console.log(`[FILE SCANNER] ========================================`)
console.log(`[FILE SCANNER] Starting scan of directory: ${directoryPath}`)
console.log(`[FILE SCANNER] Start time: ${new Date().toISOString()}`)
const result: ScanResult = {
totalFiles: 0,
photosAdded: 0,
photosSkipped: 0,
errors: 0
}
try {
console.log(`[FILE SCANNER] Beginning recursive directory scan...`)
await scanDirectoryRecursive(directoryPath, directoryPath, result)
const scanDuration = Date.now() - scanStartTime
console.log(`[FILE SCANNER] Recursive scan completed in ${scanDuration}ms`)
console.log(`[FILE SCANNER] Files processed: ${result.totalFiles}`)
console.log(`[FILE SCANNER] Photos added: ${result.photosAdded}`)
console.log(`[FILE SCANNER] Photos skipped: ${result.photosSkipped}`)
console.log(`[FILE SCANNER] Errors encountered: ${result.errors}`)
// Update directory statistics
console.log(`[FILE SCANNER] Updating directory statistics...`)
const directoryRecord = photoService.getDirectoryByPath(directoryPath)
if (directoryRecord) {
const directoryPhotos = photoService.getPhotos({ directory: directoryPath })
const totalSize = directoryPhotos.reduce((sum, photo) => sum + photo.filesize, 0)
console.log(`[FILE SCANNER] Directory stats: ${directoryPhotos.length} photos, ${totalSize} bytes`)
photoService.createOrUpdateDirectory({
path: directoryPath,
name: directoryRecord.name,
last_scanned: new Date().toISOString(),
photo_count: directoryPhotos.length,
total_size: totalSize
})
console.log(`[FILE SCANNER] Directory record updated successfully`)
} else {
console.warn(`[FILE SCANNER] Directory record not found for ${directoryPath}`)
}
const totalDuration = Date.now() - scanStartTime
console.log(`[FILE SCANNER] ========================================`)
console.log(`[FILE SCANNER] Scan completed for ${directoryPath}`)
console.log(`[FILE SCANNER] Total duration: ${totalDuration}ms`)
console.log(`[FILE SCANNER] Final result:`, result)
console.log(`[FILE SCANNER] ========================================`)
return result
} catch (error) {
const totalDuration = Date.now() - scanStartTime
console.error(`[FILE SCANNER] ========================================`)
console.error(`[FILE SCANNER] Error scanning directory ${directoryPath} after ${totalDuration}ms:`, error)
console.error(`[FILE SCANNER] Partial result:`, result)
console.error(`[FILE SCANNER] ========================================`)
result.errors++
return result
}
}
async function scanDirectoryRecursive(
currentPath: string,
basePath: string,
result: ScanResult
): Promise<void> {
try {
const entries = await readdir(currentPath, { withFileTypes: true })
for (const entry of entries) {
const fullPath = join(currentPath, entry.name)
try {
if (entry.isDirectory()) {
// Skip hidden directories and common non-photo directories
if (!entry.name.startsWith('.') &&
!['node_modules', 'dist', 'build', 'temp', 'cache'].includes(entry.name.toLowerCase())) {
await scanDirectoryRecursive(fullPath, basePath, result)
}
} else if (entry.isFile()) {
result.totalFiles++
const ext = extname(entry.name).toLowerCase()
if (SUPPORTED_EXTENSIONS.has(ext)) {
await processPhotoFile(fullPath, basePath, result)
}
}
} catch (fileError) {
console.error(`Error processing ${fullPath}:`, fileError)
result.errors++
}
}
} catch (error) {
console.error(`Error reading directory ${currentPath}:`, error)
result.errors++
}
}
async function processPhotoFile(
filePath: string,
basePath: string,
result: ScanResult
): Promise<void> {
const filename = basename(filePath)
let stats: any = null
let photoData: any = null
try {
stats = await stat(filePath)
// Compute SHA256 hash first for conflict detection
console.log(`[FILE SCANNER] Computing SHA256 hash for: ${filename}`)
const sha256Hash = await computeFileHash(filePath)
console.log(`[FILE SCANNER] Computed hash for ${filename}: ${sha256Hash}`)
// Check for conflicts with existing photos
const conflictCheck = photoService.checkForPhotoConflict(filePath, sha256Hash)
if (conflictCheck.isDuplicate) {
console.info(`[FILE SCANNER] Skipping duplicate file (same path, same content): ${filename}`)
result.photosSkipped++
return
}
if (conflictCheck.hasConflict && conflictCheck.existingPhoto) {
console.warn(`[FILE SCANNER] CONFLICT DETECTED: File ${filename} has same path but different content than existing photo`)
// Create basic photo record for the conflicting file
photoData = {
filename,
filepath: filePath,
directory: basePath,
filesize: stats.size,
created_at: stats.birthtime.toISOString(),
modified_at: stats.mtime.toISOString(),
favorite: false,
metadata: JSON.stringify({
extension: extname(filename).toLowerCase(),
scanned_at: new Date().toISOString()
})
}
// Try to extract image metadata (width, height, format)
try {
const metadata = await extractImageMetadata(filePath)
Object.assign(photoData, metadata)
} catch (metadataError) {
console.warn(`[FILE SCANNER] Could not extract metadata for ${filePath}:`, metadataError)
}
// Store in conflicts table
const conflictReason = `File has same path as existing photo but different SHA256 hash. Original hash: ${conflictCheck.existingPhoto.id}, New hash: ${sha256Hash}`
const conflictId = photoService.createPhotoConflict(photoData, conflictCheck.existingPhoto.id, conflictReason)
console.warn(`[FILE SCANNER] Stored conflict record with ID: ${conflictId}`)
result.errors++
return
}
// Create basic photo record for new file
photoData = {
filename,
filepath: filePath,
directory: basePath,
filesize: stats.size,
created_at: stats.birthtime.toISOString(),
modified_at: stats.mtime.toISOString(),
favorite: false,
metadata: JSON.stringify({
extension: extname(filename).toLowerCase(),
scanned_at: new Date().toISOString()
})
}
// Try to extract image metadata (width, height, format)
try {
const metadata = await extractImageMetadata(filePath)
Object.assign(photoData, metadata)
} catch (metadataError) {
console.warn(`[FILE SCANNER] Could not extract metadata for ${filePath}:`, metadataError)
}
console.log(`[FILE SCANNER] Creating photo record for: ${filename}`)
console.log(`[FILE SCANNER] Photo data:`, photoData)
// Debug: Log each value and its type before database insertion
console.log(`[FILE SCANNER] Debug - checking photoData types:`)
Object.entries(photoData).forEach(([key, value]) => {
console.log(` ${key}:`, typeof value, value)
})
// Create photo record
const photo = photoService.createPhoto(photoData)
result.photosAdded++
console.log(`[FILE SCANNER] Successfully added photo: ${filename}`)
// Store SHA256 hash
try {
// Create or update hash record
const hashRecord = photoService.createOrUpdateImageHash(sha256Hash)
// Associate photo with hash
const associated = photoService.associatePhotoWithHash(photo.id, hashRecord.id)
console.log(`[FILE SCANNER] Associated photo with hash: ${associated}`)
} catch (hashError) {
console.error(`[FILE SCANNER] Error storing hash for ${filePath}:`, hashError)
// Continue processing even if hash storage fails
}
// Log progress every 100 files
if ((result.photosAdded + result.photosSkipped) % 100 === 0) {
console.log(`[FILE SCANNER] Progress: ${result.photosAdded + result.photosSkipped} photos processed (${result.photosAdded} added, ${result.photosSkipped} skipped, ${result.errors} errors)`)
}
} catch (error) {
console.error(`[FILE SCANNER] Error processing photo ${filePath}:`, error)
console.error(`[FILE SCANNER] Photo data that failed:`, {
filename,
filepath: filePath,
directory: basePath,
filesize: stats ? stats.size : 'unknown',
photoData: photoData ? Object.keys(photoData) : 'not created'
})
result.errors++
}
}
async function extractImageMetadata(filePath: string): Promise<{
width?: number
height?: number
format?: string
metadata?: string
}> {
try {
const ext = extname(filePath).toLowerCase()
// Skip SVG files as Sharp doesn't handle them well
if (ext === '.svg') {
return { format: 'SVG' }
}
// Use Sharp to get basic image information and EXIF data
const image = sharp(filePath)
const metadata = await image.metadata()
const result: {
width?: number
height?: number
format?: string
metadata?: string
} = {
width: typeof metadata.width === 'number' ? metadata.width : undefined,
height: typeof metadata.height === 'number' ? metadata.height : undefined,
format: metadata.format?.toUpperCase() || 'Unknown'
}
// Extract EXIF data if available
if (metadata.exif) {
try {
const exifData = exifReader(metadata.exif)
// Parse and store relevant EXIF information
const exifInfo: Record<string, any> = {}
// Use any type to handle dynamic EXIF structure
const exif: any = exifData
// Image information
if (exif.image || exif.Image) {
const imageData = exif.image || exif.Image
if (imageData.Make) exifInfo.camera_make = imageData.Make
if (imageData.Model) exifInfo.camera_model = imageData.Model
if (imageData.Software) exifInfo.software = imageData.Software
if (imageData.DateTime) exifInfo.date_time = imageData.DateTime
if (imageData.Orientation) exifInfo.orientation = imageData.Orientation
if (imageData.XResolution) exifInfo.x_resolution = imageData.XResolution
if (imageData.YResolution) exifInfo.y_resolution = imageData.YResolution
}
// Photo-specific EXIF data
if (exif.exif || exif.Exif) {
const photoData = exif.exif || exif.Exif
if (photoData.DateTimeOriginal) exifInfo.date_time_original = photoData.DateTimeOriginal
if (photoData.DateTimeDigitized) exifInfo.date_time_digitized = photoData.DateTimeDigitized
if (photoData.ExposureTime) exifInfo.exposure_time = photoData.ExposureTime
if (photoData.FNumber) exifInfo.f_number = photoData.FNumber
if (photoData.ExposureProgram) exifInfo.exposure_program = photoData.ExposureProgram
if (photoData.ISOSpeedRatings) exifInfo.iso_speed = photoData.ISOSpeedRatings
if (photoData.FocalLength) exifInfo.focal_length = photoData.FocalLength
if (photoData.Flash) exifInfo.flash = photoData.Flash
if (photoData.WhiteBalance) exifInfo.white_balance = photoData.WhiteBalance
if (photoData.ColorSpace) exifInfo.color_space = photoData.ColorSpace
if (photoData.LensModel) exifInfo.lens_model = photoData.LensModel
}
// GPS information
if (exif.gps || exif.GPS) {
const gpsData = exif.gps || exif.GPS
const gpsInfo: Record<string, any> = {}
if (gpsData.GPSLatitude && gpsData.GPSLatitudeRef) {
gpsInfo.latitude = convertDMSToDD(gpsData.GPSLatitude, gpsData.GPSLatitudeRef)
}
if (gpsData.GPSLongitude && gpsData.GPSLongitudeRef) {
gpsInfo.longitude = convertDMSToDD(gpsData.GPSLongitude, gpsData.GPSLongitudeRef)
}
if (gpsData.GPSAltitude && gpsData.GPSAltitudeRef !== undefined) {
gpsInfo.altitude = gpsData.GPSAltitudeRef === 1 ? -gpsData.GPSAltitude : gpsData.GPSAltitude
}
if (Object.keys(gpsInfo).length > 0) {
exifInfo.gps = gpsInfo
}
}
// Store EXIF data as JSON string if we found any relevant data
if (Object.keys(exifInfo).length > 0) {
result.metadata = JSON.stringify({
extension: ext,
scanned_at: new Date().toISOString(),
exif: exifInfo
})
}
} catch (exifError) {
console.warn(`Error parsing EXIF data for ${filePath}:`, exifError)
// Fall back to basic metadata
result.metadata = JSON.stringify({
extension: ext,
scanned_at: new Date().toISOString(),
exif_error: 'Failed to parse EXIF data'
})
}
} else {
// No EXIF data available
result.metadata = JSON.stringify({
extension: ext,
scanned_at: new Date().toISOString(),
exif: null
})
}
return result
} catch (error) {
console.warn(`Error extracting metadata for ${filePath}:`, error)
// Fall back to basic format detection
const ext = extname(filePath).toLowerCase()
const formatMap: Record<string, string> = {
'.jpg': 'JPEG',
'.jpeg': 'JPEG',
'.png': 'PNG',
'.gif': 'GIF',
'.bmp': 'BMP',
'.webp': 'WebP',
'.tiff': 'TIFF',
'.tif': 'TIFF',
'.ico': 'ICO',
'.svg': 'SVG'
}
return {
format: formatMap[ext] || 'Unknown',
metadata: JSON.stringify({
extension: ext,
scanned_at: new Date().toISOString(),
extraction_error: error instanceof Error ? error.message : 'Unknown error'
})
}
}
}
// Helper function to convert DMS (Degrees, Minutes, Seconds) to DD (Decimal Degrees)
function convertDMSToDD(dms: number[], ref: string): number {
if (!Array.isArray(dms) || dms.length < 3) return 0
const degrees = dms[0] || 0
const minutes = dms[1] || 0
const seconds = dms[2] || 0
let dd = degrees + minutes / 60 + seconds / 3600
if (ref === 'S' || ref === 'W') {
dd = -dd
}
return dd
}
// Helper function to compute SHA256 hash of a file
async function computeFileHash(filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
const hash = createHash('sha256')
const stream = createReadStream(filePath)
stream.on('data', (data) => {
hash.update(data)
})
stream.on('end', () => {
resolve(hash.digest('hex'))
})
stream.on('error', (error) => {
reject(error)
})
})
}
// Helper function to check if a path contains photos
export async function hasPhotos(directoryPath: string): Promise<boolean> {
try {
const entries = await readdir(directoryPath, { withFileTypes: true })
for (const entry of entries) {
if (entry.isFile()) {
const ext = extname(entry.name).toLowerCase()
if (SUPPORTED_EXTENSIONS.has(ext)) {
return true
}
} else if (entry.isDirectory() && !entry.name.startsWith('.')) {
const fullPath = join(directoryPath, entry.name)
if (await hasPhotos(fullPath)) {
return true
}
}
}
return false
} catch (error) {
console.error(`Error checking for photos in ${directoryPath}:`, error)
return false
}
}

593
src/lib/image-classifier.ts Normal file
View File

@ -0,0 +1,593 @@
import { pipeline, env, RawImage } from '@xenova/transformers'
// Configure to use local models (no internet required after first download)
env.allowLocalModels = true
env.allowRemoteModels = false
export interface ClassificationResult {
label: string
score: number
}
export interface CaptionResult {
caption: string
confidence?: number
}
export interface ClassifierConfig {
minConfidence?: number
maxResults?: number
customLabels?: string[]
categories?: {
general?: string[]
time?: string[]
weather?: string[]
subjects?: string[]
locations?: string[]
style?: string[]
seasons?: string[]
}
}
export class ImageClassifier {
private classifier: any = null
private captioner: any = null
private zeroShotClassifier: any = null // For custom concepts
private isInitialized: boolean = false
private isCaptionerInitialized: boolean = false
private isZeroShotInitialized: boolean = false
private initializationPromise: Promise<void> | null = null
private captionerInitializationPromise: Promise<void> | null = null
private zeroShotInitializationPromise: Promise<void> | null = null
private config: ClassifierConfig = {
minConfidence: 0.1,
maxResults: 10
}
async initialize(): Promise<void> {
if (this.isInitialized) return
if (this.initializationPromise) {
return this.initializationPromise
}
this.initializationPromise = this._initializeModel()
return this.initializationPromise
}
async initializeCaptioner(): Promise<void> {
if (this.isCaptionerInitialized) return
if (this.captionerInitializationPromise) {
return this.captionerInitializationPromise
}
this.captionerInitializationPromise = this._initializeCaptioner()
return this.captionerInitializationPromise
}
async initializeZeroShot(): Promise<void> {
if (this.isZeroShotInitialized) return
if (this.zeroShotInitializationPromise) {
return this.zeroShotInitializationPromise
}
this.zeroShotInitializationPromise = this._initializeZeroShot()
return this.zeroShotInitializationPromise
}
private async _initializeModel(): Promise<void> {
try {
console.log('[IMAGE CLASSIFIER] Initializing ViT model...')
// Use Vision Transformer for standard image classification
this.classifier = await pipeline(
'image-classification',
'Xenova/vit-base-patch16-224',
{
revision: 'main'
}
)
this.isInitialized = true
console.log('[IMAGE CLASSIFIER] ViT model initialized successfully')
} catch (error) {
console.error('[IMAGE CLASSIFIER] Failed to initialize ViT model:', error)
throw error
}
}
private async _initializeCaptioner(): Promise<void> {
try {
console.log('[IMAGE CAPTIONER] Initializing BLIP model...')
// Use BLIP for detailed image captioning
this.captioner = await pipeline(
'image-to-text',
'Xenova/blip-image-captioning-base',
{
revision: 'main'
}
)
this.isCaptionerInitialized = true
console.log('[IMAGE CAPTIONER] BLIP model initialized successfully')
} catch (error) {
console.error('[IMAGE CAPTIONER] Failed to initialize BLIP model:', error)
throw error
}
}
private async _initializeZeroShot(): Promise<void> {
try {
console.log('[ZERO-SHOT CLASSIFIER] Initializing CLIP model...')
// Use CLIP for zero-shot classification of artistic/style concepts
this.zeroShotClassifier = await pipeline(
'zero-shot-image-classification',
'Xenova/clip-vit-base-patch32',
{
revision: 'main'
}
)
this.isZeroShotInitialized = true
console.log('[ZERO-SHOT CLASSIFIER] CLIP model initialized successfully')
} catch (error) {
console.error('[ZERO-SHOT CLASSIFIER] Failed to initialize CLIP model:', error)
throw error
}
}
// Update classifier configuration
updateConfig(newConfig: Partial<ClassifierConfig>): void {
this.config = { ...this.config, ...newConfig }
console.log('[IMAGE CLASSIFIER] Configuration updated:', this.config)
}
// Get current labels based on config (for backward compatibility)
// Note: Standard image classification uses ImageNet labels, not custom ones
private getCurrentLabels(customLabels?: string[]): string[] {
console.log('[IMAGE CLASSIFIER] Note: Standard image classification uses ImageNet classes, custom labels are ignored')
return [] // Not used in standard image classification
}
async classifyImage(imageSource: string | Buffer, customLabels?: string[], config?: Partial<ClassifierConfig>): Promise<ClassificationResult[]> {
await this.initialize()
if (!this.classifier) {
throw new Error('Classifier not initialized')
}
// Merge temporary config if provided
const activeConfig = config ? { ...this.config, ...config } : this.config
try {
const sourceDesc = typeof imageSource === 'string' ? imageSource : 'thumbnail blob'
console.log(`[IMAGE CLASSIFIER] Classifying image: ${sourceDesc}`)
// Handle different input types for Transformers.js
let processedSource: string | RawImage
if (Buffer.isBuffer(imageSource)) {
console.log(`[IMAGE CLASSIFIER] Converting Buffer to RawImage (${imageSource.length} bytes)`)
// Validate buffer has minimum size
if (imageSource.length < 100) {
console.warn(`[IMAGE CLASSIFIER] Buffer too small (${imageSource.length} bytes), likely corrupted`)
throw new Error('Thumbnail data too small or corrupted')
}
try {
// Validate image format from magic bytes
const header = imageSource.subarray(0, 4)
let isValidImage = false
if (header[0] === 0xFF && header[1] === 0xD8) { // JPEG
isValidImage = true
} else if (header[0] === 0x89 && header[1] === 0x50 && header[2] === 0x4E && header[3] === 0x47) { // PNG
isValidImage = true
} else if (header[0] === 0x47 && header[1] === 0x49 && header[2] === 0x46) { // GIF
isValidImage = true
} else if (header[0] === 0x52 && header[1] === 0x49 && header[2] === 0x46 && header[3] === 0x46) { // WebP
isValidImage = true
}
if (!isValidImage) {
console.warn(`[IMAGE CLASSIFIER] Invalid image format detected in buffer: [${header[0]}, ${header[1]}, ${header[2]}, ${header[3]}]`)
throw new Error('Invalid image format in thumbnail data')
}
// Create RawImage directly from buffer
processedSource = await RawImage.fromBlob(new Blob([imageSource]))
console.log(`[IMAGE CLASSIFIER] Successfully created RawImage from buffer`)
} catch (error) {
console.error(`[IMAGE CLASSIFIER] Failed to create RawImage from buffer:`, error)
throw new Error(`Failed to process thumbnail data: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
} else {
// String path - pass through as is
processedSource = imageSource
}
// Standard image classification doesn't use custom labels - it returns ImageNet classes
const results = await this.classifier(processedSource, {
top_k: activeConfig.maxResults || 10
})
// Filter results by confidence threshold and format
const filteredResults = results
.filter((result: any) => result.score > (activeConfig.minConfidence || 0.1))
.map((result: any) => ({
label: this.cleanLabel(result.label),
score: Math.round(result.score * 1000) / 1000 // Round to 3 decimal places
}))
console.log(`[IMAGE CLASSIFIER] Found ${filteredResults.length} classifications for ${sourceDesc} (min confidence: ${activeConfig.minConfidence})`)
return filteredResults
} catch (error) {
console.error(`[IMAGE CLASSIFIER] Failed to classify image ${typeof imageSource === 'string' ? imageSource : 'thumbnail blob'}:`, error)
throw error
}
}
// Clean ImageNet labels to be more user-friendly
private cleanLabel(label: string): string {
// ImageNet labels often have format like "Egyptian cat, Egyptian Mau" or technical IDs
// Take the first part and clean it up
const cleaned = label
.split(',')[0] // Take first part before comma
.trim()
.toLowerCase()
.replace(/[_-]/g, ' ') // Replace underscores/hyphens with spaces
.replace(/\b\w/g, l => l.toUpperCase()) // Capitalize first letter of each word
return cleaned
}
// Convenience method for classifying from file path (legacy support)
async classifyImageFromPath(imagePath: string, customLabels?: string[]): Promise<ClassificationResult[]> {
return this.classifyImage(imagePath, customLabels)
}
// New method for classifying from thumbnail blob
async classifyImageFromBlob(imageBlob: Buffer, customLabels?: string[]): Promise<ClassificationResult[]> {
return this.classifyImage(imageBlob, customLabels)
}
async classifyImageWithCategories(imageSource: string | Buffer, config?: Partial<ClassifierConfig>): Promise<{ [category: string]: ClassificationResult[] }> {
// Standard image classification returns ImageNet classes
// We'll categorize them post-classification
const classifications = await this.classifyImage(imageSource, undefined, config)
const results: { [category: string]: ClassificationResult[] } = {
animals: [],
objects: [],
nature: [],
people: [],
vehicles: [],
food: [],
other: []
}
// Categorize ImageNet results based on common patterns
classifications.forEach(result => {
const label = result.label.toLowerCase()
if (this.isAnimal(label)) {
results.animals.push(result)
} else if (this.isNature(label)) {
results.nature.push(result)
} else if (this.isVehicle(label)) {
results.vehicles.push(result)
} else if (this.isFood(label)) {
results.food.push(result)
} else if (this.isPerson(label)) {
results.people.push(result)
} else if (this.isObject(label)) {
results.objects.push(result)
} else {
results.other.push(result)
}
})
// Remove empty categories
Object.keys(results).forEach(key => {
if (results[key].length === 0) {
delete results[key]
}
})
return results
}
// Helper methods to categorize ImageNet labels
private isAnimal(label: string): boolean {
const animalKeywords = ['dog', 'cat', 'bird', 'horse', 'cow', 'sheep', 'goat', 'pig', 'chicken', 'duck', 'tiger', 'lion', 'bear', 'elephant', 'giraffe', 'zebra', 'monkey', 'ape', 'wolf', 'fox', 'deer', 'rabbit', 'mouse', 'rat', 'hamster', 'fish', 'shark', 'whale', 'dolphin', 'seal', 'penguin', 'eagle', 'hawk', 'owl', 'parrot', 'canary', 'snake', 'lizard', 'turtle', 'frog', 'spider', 'butterfly', 'bee', 'ant', 'beetle', 'fly']
return animalKeywords.some(keyword => label.includes(keyword))
}
private isNature(label: string): boolean {
const natureKeywords = ['tree', 'flower', 'plant', 'leaf', 'grass', 'mountain', 'ocean', 'sea', 'lake', 'river', 'forest', 'beach', 'sky', 'cloud', 'sun', 'moon', 'star', 'rock', 'stone', 'sand', 'snow', 'ice', 'rain', 'landscape']
return natureKeywords.some(keyword => label.includes(keyword))
}
private isVehicle(label: string): boolean {
const vehicleKeywords = ['car', 'truck', 'bus', 'motorcycle', 'bicycle', 'plane', 'airplane', 'helicopter', 'boat', 'ship', 'train', 'locomotive', 'taxi', 'ambulance', 'fire truck', 'police car', 'van', 'suv', 'convertible', 'limousine']
return vehicleKeywords.some(keyword => label.includes(keyword))
}
private isFood(label: string): boolean {
const foodKeywords = ['pizza', 'burger', 'sandwich', 'bread', 'cake', 'cookie', 'apple', 'banana', 'orange', 'grape', 'strawberry', 'tomato', 'carrot', 'broccoli', 'potato', 'corn', 'rice', 'pasta', 'meat', 'chicken', 'beef', 'pork', 'fish', 'cheese', 'milk', 'coffee', 'tea', 'wine', 'beer', 'juice', 'water', 'soup', 'salad']
return foodKeywords.some(keyword => label.includes(keyword))
}
private isPerson(label: string): boolean {
const personKeywords = ['person', 'people', 'man', 'woman', 'child', 'baby', 'boy', 'girl', 'human', 'face', 'portrait']
return personKeywords.some(keyword => label.includes(keyword))
}
private isObject(label: string): boolean {
const objectKeywords = ['chair', 'table', 'bed', 'sofa', 'lamp', 'book', 'phone', 'computer', 'laptop', 'tv', 'camera', 'watch', 'clock', 'bottle', 'cup', 'glass', 'plate', 'bowl', 'knife', 'fork', 'spoon', 'bag', 'backpack', 'umbrella', 'hat', 'shoe', 'shirt', 'dress', 'jacket', 'pants']
return objectKeywords.some(keyword => label.includes(keyword))
}
// Generate tags for database storage
async generateTags(imageSource: string | Buffer, minConfidence?: number, config?: Partial<ClassifierConfig>): Promise<string[]> {
const activeConfig = config ? { ...this.config, ...config } : this.config
const confidenceThreshold = minConfidence ?? activeConfig.minConfidence ?? 0.3
const results = await this.classifyImage(imageSource, undefined, { ...activeConfig, minConfidence: confidenceThreshold })
return results.map(result => result.label)
}
// Get current configuration
getConfig(): ClassifierConfig {
return { ...this.config }
}
// Reset to default configuration
resetConfig(): void {
this.config = {
minConfidence: 0.1,
maxResults: 10
}
console.log('[IMAGE CLASSIFIER] Configuration reset to defaults')
}
// Generate detailed caption for image
async captionImage(imageSource: string | Buffer): Promise<CaptionResult> {
await this.initializeCaptioner()
if (!this.captioner) {
throw new Error('Captioner not initialized')
}
try {
const sourceDesc = typeof imageSource === 'string' ? imageSource : 'thumbnail blob'
console.log(`[IMAGE CAPTIONER] Generating caption for: ${sourceDesc}`)
// Handle different input types for Transformers.js
let processedSource: string | RawImage
if (Buffer.isBuffer(imageSource)) {
console.log(`[IMAGE CAPTIONER] Converting Buffer to RawImage (${imageSource.length} bytes)`)
// Validate buffer has minimum size
if (imageSource.length < 100) {
console.warn(`[IMAGE CAPTIONER] Buffer too small (${imageSource.length} bytes), likely corrupted`)
throw new Error('Thumbnail data too small or corrupted')
}
try {
// Validate image format from magic bytes
const header = imageSource.subarray(0, 4)
let isValidImage = false
if (header[0] === 0xFF && header[1] === 0xD8) { // JPEG
isValidImage = true
} else if (header[0] === 0x89 && header[1] === 0x50 && header[2] === 0x4E && header[3] === 0x47) { // PNG
isValidImage = true
} else if (header[0] === 0x47 && header[1] === 0x49 && header[2] === 0x46) { // GIF
isValidImage = true
} else if (header[0] === 0x52 && header[1] === 0x49 && header[2] === 0x46 && header[3] === 0x46) { // WebP
isValidImage = true
}
if (!isValidImage) {
console.warn(`[IMAGE CAPTIONER] Invalid image format detected in buffer: [${header[0]}, ${header[1]}, ${header[2]}, ${header[3]}]`)
throw new Error('Invalid image format in thumbnail data')
}
// Create RawImage directly from buffer
processedSource = await RawImage.fromBlob(new Blob([imageSource]))
console.log(`[IMAGE CAPTIONER] Successfully created RawImage from buffer`)
} catch (error) {
console.error(`[IMAGE CAPTIONER] Failed to create RawImage from buffer:`, error)
throw new Error(`Failed to process thumbnail data: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
} else {
// String path - pass through as is
processedSource = imageSource
}
// Generate caption
const result = await this.captioner(processedSource)
// Extract caption text (BLIP returns array with generated_text)
const caption = Array.isArray(result) && result.length > 0
? result[0].generated_text
: result.generated_text || 'No caption generated'
console.log(`[IMAGE CAPTIONER] Generated caption for ${sourceDesc}: "${caption}"`)
return {
caption: caption.trim(),
confidence: 1.0 // BLIP doesn't provide confidence scores
}
} catch (error) {
console.error(`[IMAGE CAPTIONER] Failed to caption image ${typeof imageSource === 'string' ? imageSource : 'thumbnail blob'}:`, error)
throw error
}
}
// Comprehensive classification using multiple models
async classifyImageComprehensive(imageSource: string | Buffer, config?: Partial<ClassifierConfig>): Promise<{
objectClassification: ClassificationResult[]
styleClassification: ClassificationResult[]
combinedResults: ClassificationResult[]
}> {
try {
console.log(`[COMPREHENSIVE CLASSIFIER] Starting comprehensive analysis`)
const activeConfig = config ? { ...this.config, ...config } : this.config
// Initialize both models
await Promise.all([
this.initialize(),
this.initializeZeroShot()
])
// Define style/artistic labels for zero-shot
const styleLabels = [
// Photography styles
'portrait photography', 'landscape photography', 'street photography', 'macro photography',
'architectural photography', 'nature photography', 'wildlife photography', 'sports photography',
'black and white photography', 'vintage photography', 'artistic photography', 'documentary photography',
// Lighting and mood
'golden hour lighting', 'blue hour lighting', 'dramatic lighting', 'soft lighting', 'natural lighting',
'moody atmosphere', 'bright and cheerful', 'dark and mysterious', 'romantic mood', 'energetic mood',
// Composition and style
'minimalist composition', 'symmetrical composition', 'rule of thirds', 'leading lines', 'depth of field',
'bokeh effect', 'high contrast', 'low contrast', 'saturated colors', 'muted colors',
// Time and weather
'sunrise', 'sunset', 'dawn', 'dusk', 'midday sun', 'cloudy weather', 'sunny day', 'overcast sky',
'stormy weather', 'foggy conditions', 'winter scene', 'spring scene', 'summer scene', 'autumn scene',
// Location types
'indoor scene', 'outdoor scene', 'urban environment', 'rural setting', 'beach scene', 'mountain landscape',
'forest setting', 'desert landscape', 'cityscape', 'countryside', 'waterfront', 'garden scene'
]
// Run both classifications in parallel
const [objectResults, styleResults] = await Promise.all([
this.classifyImage(imageSource, undefined, activeConfig),
this.classifyZeroShot(imageSource, styleLabels, activeConfig)
])
// Combine and deduplicate results
const combined = [...objectResults, ...styleResults]
.sort((a, b) => b.score - a.score)
.slice(0, activeConfig.maxResults || 15)
console.log(`[COMPREHENSIVE CLASSIFIER] Found ${objectResults.length} object tags, ${styleResults.length} style tags`)
return {
objectClassification: objectResults,
styleClassification: styleResults,
combinedResults: combined
}
} catch (error) {
console.error(`[COMPREHENSIVE CLASSIFIER] Failed to classify image:`, error)
throw error
}
}
// Zero-shot classification for artistic/style concepts
private async classifyZeroShot(imageSource: string | Buffer, labels: string[], config?: Partial<ClassifierConfig>): Promise<ClassificationResult[]> {
if (!this.zeroShotClassifier) {
throw new Error('Zero-shot classifier not initialized')
}
const activeConfig = config ? { ...this.config, ...config } : this.config
// Handle different input types (same logic as main classifier)
let processedSource: string | RawImage
if (Buffer.isBuffer(imageSource)) {
if (imageSource.length < 100) {
throw new Error('Thumbnail data too small or corrupted')
}
// Validate image format
const header = imageSource.subarray(0, 4)
let isValidImage = false
if (header[0] === 0xFF && header[1] === 0xD8) isValidImage = true // JPEG
else if (header[0] === 0x89 && header[1] === 0x50 && header[2] === 0x4E && header[3] === 0x47) isValidImage = true // PNG
else if (header[0] === 0x47 && header[1] === 0x49 && header[2] === 0x46) isValidImage = true // GIF
else if (header[0] === 0x52 && header[1] === 0x49 && header[2] === 0x46 && header[3] === 0x46) isValidImage = true // WebP
if (!isValidImage) {
throw new Error('Invalid image format in thumbnail data')
}
processedSource = await RawImage.fromBlob(new Blob([imageSource]))
} else {
processedSource = imageSource
}
const results = await this.zeroShotClassifier(processedSource, labels)
return results
.filter((result: any) => result.score > (activeConfig.minConfidence || 0.1))
.slice(0, Math.floor((activeConfig.maxResults || 10) / 2)) // Take half of max results
.map((result: any) => ({
label: result.label,
score: Math.round(result.score * 1000) / 1000
}))
}
// Generate both classification tags and detailed caption
async analyzeImage(imageSource: string | Buffer, customLabels?: string[], config?: Partial<ClassifierConfig>): Promise<{
classifications: ClassificationResult[]
caption: CaptionResult
comprehensive?: {
objectClassification: ClassificationResult[]
styleClassification: ClassificationResult[]
}
}> {
try {
console.log(`[IMAGE ANALYZER] Starting full analysis for image`)
// Run all analyses in parallel for better performance
const [classifications, caption, comprehensive] = await Promise.all([
this.classifyImage(imageSource, customLabels, config),
this.captionImage(imageSource),
this.classifyImageComprehensive(imageSource, config).catch(error => {
console.warn('[IMAGE ANALYZER] Comprehensive classification failed:', error)
return null
})
])
return {
classifications,
caption,
comprehensive: comprehensive ? {
objectClassification: comprehensive.objectClassification,
styleClassification: comprehensive.styleClassification
} : undefined
}
} catch (error) {
console.error(`[IMAGE ANALYZER] Failed to analyze image:`, error)
throw error
}
}
// Check if models are ready
isReady(): boolean {
return this.isInitialized && this.classifier !== null
}
// Check if captioner is ready
isCaptionerReady(): boolean {
return this.isCaptionerInitialized && this.captioner !== null
}
}
// Singleton instance
export const imageClassifier = new ImageClassifier()

613
src/lib/photo-service.ts Normal file
View File

@ -0,0 +1,613 @@
import { getDatabase } from './database'
import { Photo, Album, Tag, Directory, ImageHash, PhotoHash } from '@/types/photo'
import { randomUUID } from 'crypto'
export class PhotoService {
private db = getDatabase()
// Photo operations
createPhoto(photoData: Omit<Photo, 'id' | 'indexed_at' | 'thumbnail_blob' | 'thumbnail_size' | 'thumbnail_generated_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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
try {
// Sanitize each value to ensure it's a primitive type for SQLite
const sanitizeValue = (value: any): string | number | boolean | null => {
// Explicitly handle null and undefined
if (value === null || value === undefined) return null
// Handle primitive types
if (typeof value === 'string') return value
if (typeof value === 'number') return value
if (typeof value === 'boolean') return value
// Handle other types
if (typeof value === 'bigint') return Number(value)
if (value instanceof Date) return value.toISOString()
if (typeof value === 'object') {
// If it's null, return null (redundant but explicit)
if (value === null) return null
// Otherwise stringify the object
return JSON.stringify(value)
}
// Convert everything else to string
return String(value)
}
// Direct approach - build parameters with explicit type checking
const params = [
id, // string
photoData.filename, // string
photoData.filepath, // string
photoData.directory, // string
photoData.filesize, // number
photoData.created_at, // string
photoData.modified_at, // string
photoData.width || null, // number or null
photoData.height || null, // number or null
photoData.format || null, // string or null
photoData.favorite === true ? 1 : 0, // convert boolean to integer
null, // rating - always null for now
null, // description - always null for now
photoData.metadata || null // string or null
]
// Debug: Log each parameter type before database insertion
console.log(`[PHOTO SERVICE] Debug - checking SQL parameters for ${photoData.filename}:`)
const fieldNames = ['id', 'filename', 'filepath', 'directory', 'filesize', 'created_at', 'modified_at', 'width', 'height', 'format', 'favorite', 'rating', 'description', 'metadata']
params.forEach((value, index) => {
const valueStr = value === null ? 'NULL' : (typeof value === 'string' && value.length > 50 ? value.substring(0, 50) + '...' : value)
console.log(` ${fieldNames[index]}:`, typeof value, valueStr)
// Additional check for SQLite compatibility
const isValidSQLiteValue = value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'bigint'
if (!isValidSQLiteValue) {
console.error(` ❌ INVALID SQLite value for ${fieldNames[index]}:`, value, typeof value)
}
})
// Use spread operator with the clean params array
insertPhoto.run(...params)
return this.getPhoto(id)!
} catch (error) {
console.error(`[PHOTO SERVICE] Error creating photo ${photoData.filename}:`, error)
console.error(`[PHOTO SERVICE] Photo data:`, photoData)
throw error
}
}
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
}
// Hash operations
createOrUpdateImageHash(sha256Hash: string): ImageHash {
const existingHash = this.getImageHashBySha256(sha256Hash)
if (existingHash) {
// Update file count
const updateHash = this.db.prepare(`
UPDATE image_hashes SET file_count = file_count + 1 WHERE sha256_hash = ?
`)
updateHash.run(sha256Hash)
return this.getImageHashBySha256(sha256Hash)!
} else {
// Create new hash record
const id = randomUUID()
const insertHash = this.db.prepare(`
INSERT INTO image_hashes (id, sha256_hash, file_count) VALUES (?, ?, 1)
`)
insertHash.run(id, sha256Hash)
return this.getImageHash(id)!
}
}
getImageHash(id: string): ImageHash | null {
const selectHash = this.db.prepare('SELECT * FROM image_hashes WHERE id = ?')
return selectHash.get(id) as ImageHash | null
}
getImageHashBySha256(sha256Hash: string): ImageHash | null {
const selectHash = this.db.prepare('SELECT * FROM image_hashes WHERE sha256_hash = ?')
return selectHash.get(sha256Hash) as ImageHash | null
}
associatePhotoWithHash(photoId: string, hashId: string): boolean {
const insertPhotoHash = this.db.prepare(`
INSERT OR IGNORE INTO photo_hashes (photo_id, hash_id) VALUES (?, ?)
`)
const result = insertPhotoHash.run(photoId, hashId)
return result.changes > 0
}
getPhotosByHash(sha256Hash: string): Photo[] {
const selectPhotos = this.db.prepare(`
SELECT p.* FROM photos p
JOIN photo_hashes ph ON p.id = ph.photo_id
JOIN image_hashes ih ON ph.hash_id = ih.id
WHERE ih.sha256_hash = ?
ORDER BY p.created_at DESC
`)
return selectPhotos.all(sha256Hash) as Photo[]
}
getDuplicateHashes(): ImageHash[] {
const selectDuplicates = this.db.prepare(`
SELECT * FROM image_hashes WHERE file_count > 1 ORDER BY file_count DESC
`)
return selectDuplicates.all() as ImageHash[]
}
getPhotosWithDuplicates(): Photo[] {
const selectPhotos = this.db.prepare(`
SELECT DISTINCT p.* FROM photos p
JOIN photo_hashes ph ON p.id = ph.photo_id
JOIN image_hashes ih ON ph.hash_id = ih.id
WHERE ih.file_count > 1
ORDER BY p.created_at DESC
`)
return selectPhotos.all() as Photo[]
}
// Thumbnail caching operations
getCachedThumbnail(photoId: string, size: number): Buffer | null {
const selectThumbnail = this.db.prepare(`
SELECT thumbnail_blob FROM photos
WHERE id = ? AND thumbnail_size = ? AND thumbnail_blob IS NOT NULL
`)
const result = selectThumbnail.get(photoId, size) as { thumbnail_blob: Buffer } | null
return result?.thumbnail_blob || null
}
updateThumbnailCache(photoId: string, size: number, thumbnailData: Buffer): boolean {
const updateThumbnail = this.db.prepare(`
UPDATE photos
SET thumbnail_blob = ?, thumbnail_size = ?, thumbnail_generated_at = CURRENT_TIMESTAMP
WHERE id = ?
`)
const result = updateThumbnail.run(thumbnailData, size, photoId)
return result.changes > 0
}
clearThumbnailCache(photoId?: string): boolean {
if (photoId) {
const clearThumbnail = this.db.prepare(`
UPDATE photos
SET thumbnail_blob = NULL, thumbnail_size = NULL, thumbnail_generated_at = NULL
WHERE id = ?
`)
const result = clearThumbnail.run(photoId)
return result.changes > 0
} else {
// Clear all thumbnail caches
const clearAllThumbnails = this.db.prepare(`
UPDATE photos
SET thumbnail_blob = NULL, thumbnail_size = NULL, thumbnail_generated_at = NULL
`)
const result = clearAllThumbnails.run()
return result.changes > 0
}
}
// Conflict detection and handling methods
checkForPhotoConflict(filepath: string, sha256Hash: string): { hasConflict: boolean, existingPhoto?: Photo, isDuplicate: boolean } {
const existingPhoto = this.getPhotoByPath(filepath)
if (!existingPhoto) {
return { hasConflict: false, isDuplicate: false }
}
// Get the hash of the existing photo
const getPhotoHash = this.db.prepare(`
SELECT ih.sha256_hash
FROM photo_hashes ph
JOIN image_hashes ih ON ph.hash_id = ih.id
WHERE ph.photo_id = ?
`)
const existingHashRow = getPhotoHash.get(existingPhoto.id) as { sha256_hash: string } | null
if (!existingHashRow) {
// Existing photo has no hash, assume it's different content
return { hasConflict: true, existingPhoto, isDuplicate: false }
}
const existingHash = existingHashRow.sha256_hash
if (existingHash === sha256Hash) {
// Same file, same content - it's a duplicate
return { hasConflict: false, existingPhoto, isDuplicate: true }
} else {
// Same path, different content - it's a conflict
return { hasConflict: true, existingPhoto, isDuplicate: false }
}
}
createPhotoConflict(
photoData: Omit<Photo, 'id' | 'indexed_at' | 'thumbnail_blob' | 'thumbnail_size' | 'thumbnail_generated_at'>,
originalPhotoId: string,
conflictReason: string
): string {
const id = randomUUID()
const insertConflict = this.db.prepare(`
INSERT INTO photo_conflicts (
id, filename, filepath, directory, filesize, created_at, modified_at,
width, height, format, favorite, rating, description, metadata,
original_photo_id, conflict_reason
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
try {
const params = [
id, // string
photoData.filename, // string
photoData.filepath, // string
photoData.directory, // string
photoData.filesize, // number
photoData.created_at, // string
photoData.modified_at, // string
photoData.width || null, // number or null
photoData.height || null, // number or null
photoData.format || null, // string or null
photoData.favorite || false, // boolean
photoData.rating || null, // number or null
photoData.description || null, // string or null
photoData.metadata || null, // string (JSON) or null
originalPhotoId, // string
conflictReason // string
]
insertConflict.run(...params)
return id
} catch (error) {
console.error('Error creating photo conflict:', error)
throw new Error('Failed to create photo conflict record')
}
}
getPhotoConflicts(): Array<Photo & { original_photo_id: string, conflict_reason: string, conflict_detected_at: string }> {
const selectConflicts = this.db.prepare('SELECT * FROM photo_conflicts ORDER BY conflict_detected_at DESC')
return selectConflicts.all() as Array<Photo & { original_photo_id: string, conflict_reason: string, conflict_detected_at: string }>
}
// Auto-tagging methods
createOrGetTag(tagName: string, color?: string): Tag {
// Try to get existing tag
const selectTag = this.db.prepare('SELECT * FROM tags WHERE name = ?')
let tag = selectTag.get(tagName.toLowerCase()) as Tag | null
if (tag) {
return tag
}
// Create new tag
const id = randomUUID()
const insertTag = this.db.prepare(`
INSERT INTO tags (id, name, color) VALUES (?, ?, ?)
`)
insertTag.run(id, tagName.toLowerCase(), color || '#3B82F6')
return {
id,
name: tagName.toLowerCase(),
color: color || '#3B82F6',
created_at: new Date().toISOString()
}
}
addPhotoTag(photoId: string, tagName: string, confidence?: number): boolean {
const tag = this.createOrGetTag(tagName)
// Check if association already exists
const existingAssociation = this.db.prepare(
'SELECT * FROM photo_tags WHERE photo_id = ? AND tag_id = ?'
).get(photoId, tag.id)
if (existingAssociation) {
return false // Already exists
}
// Create association
const insertPhotoTag = this.db.prepare(`
INSERT INTO photo_tags (photo_id, tag_id, added_at) VALUES (?, ?, ?)
`)
try {
insertPhotoTag.run(photoId, tag.id, new Date().toISOString())
return true
} catch (error) {
console.error('Error adding photo tag:', error)
return false
}
}
addPhotoTags(photoId: string, tags: Array<{ name: string, confidence?: number }>): number {
let addedCount = 0
for (const tagInfo of tags) {
if (this.addPhotoTag(photoId, tagInfo.name, tagInfo.confidence)) {
addedCount++
}
}
return addedCount
}
getPhotoTags(photoId: string): Tag[] {
const selectTags = this.db.prepare(`
SELECT t.* FROM tags t
INNER JOIN photo_tags pt ON t.id = pt.tag_id
WHERE pt.photo_id = ?
ORDER BY t.name
`)
return selectTags.all(photoId) as Tag[]
}
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
}
clearPhotoTags(photoId: string): number {
const deleteAllPhotoTags = this.db.prepare('DELETE FROM photo_tags WHERE photo_id = ?')
const result = deleteAllPhotoTags.run(photoId)
return result.changes
}
searchPhotosByTags(tagNames: string[]): Photo[] {
if (tagNames.length === 0) return []
const placeholders = tagNames.map(() => '?').join(',')
const query = `
SELECT DISTINCT p.* FROM photos p
INNER JOIN photo_tags pt ON p.id = pt.photo_id
INNER JOIN tags t ON pt.tag_id = t.id
WHERE t.name IN (${placeholders})
ORDER BY p.created_at DESC
`
const selectPhotos = this.db.prepare(query)
return selectPhotos.all(...tagNames.map(name => name.toLowerCase())) as Photo[]
}
}
export const photoService = new PhotoService()

143
src/lib/swagger.ts Normal file
View File

@ -0,0 +1,143 @@
import { createSwaggerSpec } from 'next-swagger-doc'
export const getApiDocs = async () => {
const spec = createSwaggerSpec({
apiFolder: 'src/app/api',
definition: {
openapi: '3.0.0',
info: {
title: 'Photo Gallery AI API',
version: '1.0.0',
description: `
AI-powered photo organization and classification API.
## Features
- **Dual-Model Classification**: ViT (objects) + CLIP (style/artistic concepts)
- **Image Captioning**: BLIP model for detailed descriptions
- **Batch Processing**: Process entire photo libraries
- **Tag Management**: Create, organize, and clear tags
- **Local AI**: All processing happens locally, no cloud dependencies
## Authentication
All endpoints are currently open access for local usage.
## Rate Limits
No rate limits - designed for local usage.
`,
contact: {
name: 'Photo Gallery API',
url: 'https://github.com/yourproject'
}
},
servers: [
{ url: 'http://localhost:3000', description: 'Development server' },
{ url: 'https://yourdomain.com', description: 'Production server' }
],
tags: [
{
name: 'Photos',
description: 'Photo management operations'
},
{
name: 'Classification',
description: 'AI-powered image classification and tagging'
},
{
name: 'Captioning',
description: 'AI-powered image captioning and descriptions'
},
{
name: 'Tags',
description: 'Tag management and organization'
},
{
name: 'Configuration',
description: 'AI model configuration and settings'
}
],
components: {
schemas: {
Photo: {
type: 'object',
properties: {
id: { type: 'string', description: 'Unique photo identifier' },
filename: { type: 'string', description: 'Original filename' },
filepath: { type: 'string', description: 'Full file path' },
directory: { type: 'string', description: 'Parent directory' },
filesize: { type: 'integer', description: 'File size in bytes' },
width: { type: 'integer', description: 'Image width in pixels' },
height: { type: 'integer', description: 'Image height in pixels' },
format: { type: 'string', description: 'Image format (JPEG, PNG, etc.)' },
favorite: { type: 'boolean', description: 'Is photo marked as favorite' },
rating: { type: 'integer', minimum: 0, maximum: 5, description: 'Photo rating (0-5 stars)' },
description: { type: 'string', description: 'AI-generated or user description' },
created_at: { type: 'string', format: 'date-time', description: 'Photo creation date' },
modified_at: { type: 'string', format: 'date-time', description: 'Last modification date' },
indexed_at: { type: 'string', format: 'date-time', description: 'When photo was indexed' }
}
},
Tag: {
type: 'object',
properties: {
id: { type: 'string', description: 'Unique tag identifier' },
name: { type: 'string', description: 'Tag name' },
color: { type: 'string', description: 'Tag color in hex format' },
created_at: { type: 'string', format: 'date-time', description: 'Tag creation date' }
}
},
ClassificationResult: {
type: 'object',
properties: {
label: { type: 'string', description: 'Classification label' },
score: { type: 'number', minimum: 0, maximum: 1, description: 'Confidence score (0-1)' }
}
},
CaptionResult: {
type: 'object',
properties: {
caption: { type: 'string', description: 'Generated caption text' },
confidence: { type: 'number', minimum: 0, maximum: 1, description: 'Caption confidence score' }
}
},
ClassifierConfig: {
type: 'object',
properties: {
minConfidence: {
type: 'number',
minimum: 0,
maximum: 1,
description: 'Minimum confidence threshold for classifications'
},
maxResults: {
type: 'integer',
minimum: 1,
maximum: 50,
description: 'Maximum number of results to return'
}
}
},
BatchClassifyRequest: {
type: 'object',
properties: {
directory: { type: 'string', description: 'Directory to process (optional)' },
limit: { type: 'integer', minimum: 1, maximum: 100, description: 'Number of photos to process per batch' },
offset: { type: 'integer', minimum: 0, description: 'Offset for pagination' },
minConfidence: { type: 'number', minimum: 0, maximum: 1, description: 'Minimum confidence threshold' },
maxResults: { type: 'integer', minimum: 1, maximum: 50, description: 'Maximum results per photo' },
onlyUntagged: { type: 'boolean', description: 'Process only photos without existing tags' },
comprehensive: { type: 'boolean', description: 'Use both ViT + CLIP models for more diverse tags' },
dryRun: { type: 'boolean', description: 'Preview results without saving to database' }
}
},
Error: {
type: 'object',
properties: {
error: { type: 'string', description: 'Error message' }
}
}
}
}
}
})
return spec
}

70
src/types/photo.ts Normal file
View File

@ -0,0 +1,70 @@
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
thumbnail_blob?: Buffer // Cached thumbnail image data
thumbnail_size?: number // Size parameter used for cached thumbnail
thumbnail_generated_at?: string // When thumbnail was generated
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
}
export interface ImageHash {
id: string
sha256_hash: string
first_seen_at: string
file_count: number
}
export interface PhotoHash {
photo_id: string
hash_id: string
created_at: string
}

40
tsconfig.json Normal file
View File

@ -0,0 +1,40 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"es6"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
},
"target": "ES2017"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}