Compare commits
13 Commits
5835809057
...
3427c1d17b
Author | SHA1 | Date | |
---|---|---|---|
3427c1d17b | |||
b4b248925f | |||
4aacfb8ff1 | |||
a204168c00 | |||
85c1479d94 | |||
96e6f4676a | |||
5c3ad988f5 | |||
f6b651eeda | |||
868ef2eeaa | |||
2d81844c05 | |||
31784d91b2 | |||
de3fa100d1 | |||
c44c820239 |
42
.gitignore
vendored
Normal file
42
.gitignore
vendored
Normal 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
8
.idea/.gitignore
generated
vendored
Normal 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
8
.idea/modules.xml
generated
Normal 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
12
.idea/photos.iml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal 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
197
AIROADMAP.md
Normal 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
198
API_DOCUMENTATION.md
Normal 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
40
CLAUDE.md
Normal 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
|
26
README.md
26
README.md
@ -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
35
next.config.js
Normal 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
6688
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
package.json
Normal file
42
package.json
Normal 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
6
postcss.config.mjs
Normal file
@ -0,0 +1,6 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
export default config;
|
30
src/app/api/albums/route.ts
Normal file
30
src/app/api/albums/route.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { photoService } from '@/lib/photo-service'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const albums = photoService.getAlbums()
|
||||
return NextResponse.json({ albums })
|
||||
} catch (error) {
|
||||
console.error('Error fetching albums:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch albums' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const albumData = await request.json()
|
||||
const album = photoService.createAlbum(albumData)
|
||||
|
||||
return NextResponse.json(album, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error creating album:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create album' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
210
src/app/api/caption/batch/route.ts
Normal file
210
src/app/api/caption/batch/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
179
src/app/api/caption/route.ts
Normal file
179
src/app/api/caption/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
378
src/app/api/classify/batch/route.ts
Normal file
378
src/app/api/classify/batch/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
171
src/app/api/classify/comprehensive/route.ts
Normal file
171
src/app/api/classify/comprehensive/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
109
src/app/api/classify/config/route.ts
Normal file
109
src/app/api/classify/config/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
176
src/app/api/classify/route.ts
Normal file
176
src/app/api/classify/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
38
src/app/api/directories/[id]/route.ts
Normal file
38
src/app/api/directories/[id]/route.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { photoService } from '@/lib/photo-service'
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
// Get the directory first to check if it exists
|
||||
const directory = photoService.getDirectory(id)
|
||||
if (!directory) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Directory not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Delete the directory record
|
||||
const success = photoService.deleteDirectory(id)
|
||||
|
||||
if (!success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete directory' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error deleting directory:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete directory' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
47
src/app/api/directories/route.ts
Normal file
47
src/app/api/directories/route.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { photoService } from '@/lib/photo-service'
|
||||
import path from 'path'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const directories = photoService.getDirectories()
|
||||
return NextResponse.json({ directories })
|
||||
} catch (error) {
|
||||
console.error('Error fetching directories:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch directories' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { path: directoryPath } = await request.json()
|
||||
|
||||
if (!directoryPath || typeof directoryPath !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Directory path is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create directory record
|
||||
const directoryName = path.basename(directoryPath)
|
||||
const directory = photoService.createOrUpdateDirectory({
|
||||
path: directoryPath,
|
||||
name: directoryName,
|
||||
last_scanned: new Date().toISOString(),
|
||||
photo_count: 0,
|
||||
total_size: 0
|
||||
})
|
||||
|
||||
return NextResponse.json(directory, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error saving directory:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save directory' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
15
src/app/api/docs/route.ts
Normal file
15
src/app/api/docs/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
62
src/app/api/duplicates/route.ts
Normal file
62
src/app/api/duplicates/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
91
src/app/api/photos/[id]/full/route.ts
Normal file
91
src/app/api/photos/[id]/full/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
79
src/app/api/photos/[id]/route.ts
Normal file
79
src/app/api/photos/[id]/route.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { photoService } from '@/lib/photo-service'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const photo = photoService.getPhoto(id)
|
||||
|
||||
if (!photo) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Photo not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(photo)
|
||||
} catch (error) {
|
||||
console.error('Error fetching photo:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch photo' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const updates = await request.json()
|
||||
|
||||
const photo = photoService.updatePhoto(id, updates)
|
||||
|
||||
if (!photo) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Photo not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(photo)
|
||||
} catch (error) {
|
||||
console.error('Error updating photo:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update photo' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const success = photoService.deletePhoto(id)
|
||||
|
||||
if (!success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Photo not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error deleting photo:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete photo' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
21
src/app/api/photos/[id]/tags/route.ts
Normal file
21
src/app/api/photos/[id]/tags/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
87
src/app/api/photos/[id]/thumbnail/route.ts
Normal file
87
src/app/api/photos/[id]/thumbnail/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
79
src/app/api/photos/route.ts
Normal file
79
src/app/api/photos/route.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { photoService } from '@/lib/photo-service'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
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
84
src/app/api/scan/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
27
src/app/api/stats/route.ts
Normal file
27
src/app/api/stats/route.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { photoService } from '@/lib/photo-service'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const photoCount = photoService.getPhotoCount()
|
||||
const totalSize = photoService.getTotalFileSize()
|
||||
const directories = photoService.getDirectories()
|
||||
const albums = photoService.getAlbums()
|
||||
const tags = photoService.getTags()
|
||||
|
||||
return NextResponse.json({
|
||||
photoCount,
|
||||
totalSize,
|
||||
directoryCount: directories.length,
|
||||
albumCount: albums.length,
|
||||
tagCount: tags.length,
|
||||
directories: directories.slice(0, 5) // Latest 5 directories
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching stats:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch statistics' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
146
src/app/api/tags/clear/route.ts
Normal file
146
src/app/api/tags/clear/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
46
src/app/api/thumbnails/clear/route.ts
Normal file
46
src/app/api/thumbnails/clear/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
88
src/app/api/validate-directory/route.ts
Normal file
88
src/app/api/validate-directory/route.ts
Normal 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
11
src/app/docs/layout.tsx
Normal 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
237
src/app/docs/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
312
src/app/docs/swagger-ui-tailwind.css
Normal file
312
src/app/docs/swagger-ui-tailwind.css
Normal 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
19
src/app/globals.css
Normal 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
25
src/app/layout.tsx
Normal 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
19
src/app/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
35
src/components/ApiDocsLink.tsx
Normal file
35
src/components/ApiDocsLink.tsx
Normal 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
48
src/components/Button.tsx
Normal 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>
|
||||
)
|
||||
}
|
26
src/components/ConditionalLayout.tsx
Normal file
26
src/components/ConditionalLayout.tsx
Normal 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}</>
|
||||
}
|
252
src/components/DirectoryList.tsx
Normal file
252
src/components/DirectoryList.tsx
Normal 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>
|
||||
)
|
||||
}
|
267
src/components/DirectoryModal.tsx
Normal file
267
src/components/DirectoryModal.tsx
Normal 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
34
src/components/Header.tsx
Normal 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>
|
||||
)
|
||||
}
|
543
src/components/ImageModal.tsx
Normal file
543
src/components/ImageModal.tsx
Normal 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)
|
||||
}
|
45
src/components/MainLayout.tsx
Normal file
45
src/components/MainLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
834
src/components/PhotoGrid.tsx
Normal file
834
src/components/PhotoGrid.tsx
Normal 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>
|
||||
)
|
||||
}
|
270
src/components/PhotoThumbnail.tsx
Normal file
270
src/components/PhotoThumbnail.tsx
Normal 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>
|
||||
)
|
||||
}
|
29
src/components/Sidebar.tsx
Normal file
29
src/components/Sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
78
src/components/SwaggerUIWrapper.tsx
Normal file
78
src/components/SwaggerUIWrapper.tsx
Normal 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
198
src/lib/database.ts
Normal 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
459
src/lib/file-scanner.ts
Normal 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
593
src/lib/image-classifier.ts
Normal 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
613
src/lib/photo-service.ts
Normal 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
143
src/lib/swagger.ts
Normal 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
70
src/types/photo.ts
Normal 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
40
tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
Loading…
Reference in New Issue
Block a user