feat: add Unsplash stock photo integration to Media Library
All checks were successful
Deploy to Production / deploy (push) Successful in 1m49s
All checks were successful
Deploy to Production / deploy (push) Successful in 1m49s
- Added new Stock Photos tab in Media Library for searching and importing Unsplash photos - Implemented search functionality with preview thumbnails and attribution info - Added one-click import feature to save photos directly to user's library - Created detailed setup documentation (STOCK_PHOTOS_QUICK_START.md and STOCK_PHOTOS_SETUP.md) - Added UNSPLASH_ACCESS_KEY configuration to .env.example - Enhanced MediaLibrary component with tabs,
This commit is contained in:
parent
321fd63f5a
commit
28b12a4376
@ -5,6 +5,7 @@ MYSQL_PASSWORD=your_mysql_password_here
|
||||
# Application
|
||||
ADMIN_PASSWORD=your_admin_password_here
|
||||
OPENAI_API_KEY=sk-your-openai-api-key
|
||||
UNSPLASH_ACCESS_KEY=your_unsplash_access_key
|
||||
GHOST_ADMIN_API_KEY=your_ghost_admin_api_key
|
||||
GHOST_ADMIN_API_URL=https://your-ghost-instance/admin
|
||||
|
||||
|
||||
91
STOCK_PHOTOS_QUICK_START.md
Normal file
91
STOCK_PHOTOS_QUICK_START.md
Normal file
@ -0,0 +1,91 @@
|
||||
# Stock Photos - Quick Start
|
||||
|
||||
## ✅ What's New
|
||||
|
||||
Your Media Library now has a **Stock Photos** tab that lets you search and import free photos from Unsplash!
|
||||
|
||||
## 🚀 Setup (5 minutes)
|
||||
|
||||
### 1. Get Unsplash API Key
|
||||
|
||||
1. Go to https://unsplash.com/developers
|
||||
2. Sign up/login → "New Application"
|
||||
3. Accept terms → Fill in details
|
||||
4. Copy your **Access Key**
|
||||
|
||||
### 2. Add to .env
|
||||
|
||||
```bash
|
||||
# Add this line to your .env file
|
||||
UNSPLASH_ACCESS_KEY=your_access_key_here
|
||||
```
|
||||
|
||||
### 3. Restart (already done!)
|
||||
|
||||
```bash
|
||||
docker-compose restart api
|
||||
```
|
||||
|
||||
## 📸 How to Use
|
||||
|
||||
### In the Media Library:
|
||||
|
||||
1. **Click "Stock Photos" tab**
|
||||
2. **Search** (e.g., "mountain sunset", "office desk")
|
||||
3. **Click "Import to Library"** on any photo
|
||||
4. **Done!** Photo appears in "My Library" tab
|
||||
|
||||
### Then use it like any uploaded photo:
|
||||
- Insert into content
|
||||
- Set as feature image
|
||||
- Copy URL
|
||||
- Delete if needed
|
||||
|
||||
## 💡 Search Tips
|
||||
|
||||
**Good searches:**
|
||||
- `coffee cup workspace`
|
||||
- `mountain landscape sunset`
|
||||
- `modern office interior`
|
||||
- `person working laptop`
|
||||
- `food photography minimal`
|
||||
|
||||
**Be specific:**
|
||||
- ✅ "laptop on wooden desk"
|
||||
- ❌ "laptop"
|
||||
|
||||
## ⚠️ Important
|
||||
|
||||
### Attribution
|
||||
Photos show photographer name with link. Consider adding credit in image captions when publishing.
|
||||
|
||||
### Rate Limits
|
||||
- **Free tier**: 50 requests/hour
|
||||
- **Production**: 5,000 requests/hour (requires approval)
|
||||
|
||||
## 🎯 Benefits
|
||||
|
||||
- ✅ **No manual download** - One-click import
|
||||
- ✅ **High quality** - Professional photos
|
||||
- ✅ **Free to use** - Unsplash license
|
||||
- ✅ **Millions of photos** - Huge library
|
||||
- ✅ **Proper attribution** - Automatic credits
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
**"Unsplash API key not configured"**
|
||||
→ Add `UNSPLASH_ACCESS_KEY` to `.env` and restart
|
||||
|
||||
**"Failed to search"**
|
||||
→ Check API key, verify it's valid
|
||||
|
||||
**"Rate limit exceeded"**
|
||||
→ Wait an hour or request production access
|
||||
|
||||
## 📚 More Info
|
||||
|
||||
See `STOCK_PHOTOS_SETUP.md` for detailed documentation.
|
||||
|
||||
---
|
||||
|
||||
**Ready to use!** Go to Assets → Media Library → Stock Photos tab 🎉
|
||||
217
STOCK_PHOTOS_SETUP.md
Normal file
217
STOCK_PHOTOS_SETUP.md
Normal file
@ -0,0 +1,217 @@
|
||||
# Stock Photos Feature Setup
|
||||
|
||||
## Overview
|
||||
|
||||
The Media Library now includes a **Stock Photos** tab that lets you search and import free high-quality photos from Unsplash directly into your library.
|
||||
|
||||
## Features
|
||||
|
||||
✅ **Search stock photos** - Search Unsplash's library of millions of free photos
|
||||
✅ **Import to library** - One-click import to your media library
|
||||
✅ **Proper attribution** - Photographer credits included
|
||||
✅ **No manual download** - Photos are fetched and uploaded automatically
|
||||
✅ **Seamless integration** - Imported photos appear in your library instantly
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Get Unsplash API Key
|
||||
|
||||
1. Go to [Unsplash Developers](https://unsplash.com/developers)
|
||||
2. Sign up or log in
|
||||
3. Click "New Application"
|
||||
4. Accept the API terms
|
||||
5. Fill in application details:
|
||||
- **Application name**: VoxBlog (or your blog name)
|
||||
- **Description**: Blog content management system
|
||||
6. Copy your **Access Key**
|
||||
|
||||
### 2. Add API Key to Environment
|
||||
|
||||
Add the Unsplash API key to your `.env` file:
|
||||
|
||||
```bash
|
||||
UNSPLASH_ACCESS_KEY=your_unsplash_access_key_here
|
||||
```
|
||||
|
||||
### 3. Rebuild and Restart
|
||||
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Search Stock Photos
|
||||
|
||||
1. Go to **Assets** step in post editor
|
||||
2. Click **Stock Photos** tab in Media Library
|
||||
3. Enter search term (e.g., "mountain sunset", "office desk", "coffee")
|
||||
4. Click **Search**
|
||||
|
||||
### Import Photos
|
||||
|
||||
1. Browse search results
|
||||
2. Click **Import to Library** on any photo
|
||||
3. Photo is downloaded and uploaded to your S3 storage
|
||||
4. Automatically switches to **My Library** tab
|
||||
5. Imported photo appears in your library
|
||||
|
||||
### Use Imported Photos
|
||||
|
||||
Once imported, stock photos work exactly like your uploaded photos:
|
||||
- Insert into content
|
||||
- Set as feature image
|
||||
- Copy URL
|
||||
- Delete if needed
|
||||
|
||||
## API Limits
|
||||
|
||||
### Unsplash Free Tier
|
||||
|
||||
- **50 requests per hour** (demo/development apps)
|
||||
- **5,000 requests per hour** (production apps - requires approval)
|
||||
|
||||
### Rate Limit Guidelines
|
||||
|
||||
- Search requests count toward your limit
|
||||
- Import requests count toward your limit
|
||||
- Unsplash tracks downloads for photographer attribution
|
||||
|
||||
### Production Approval
|
||||
|
||||
For production use with higher limits:
|
||||
1. Go to your Unsplash app dashboard
|
||||
2. Click "Request Production Access"
|
||||
3. Provide app details and screenshots
|
||||
4. Usually approved within 24-48 hours
|
||||
|
||||
## Attribution
|
||||
|
||||
Unsplash requires attribution for photos. The feature automatically:
|
||||
- ✅ Displays photographer name with link
|
||||
- ✅ Tracks download for Unsplash analytics
|
||||
- ✅ Includes UTM parameters for proper attribution
|
||||
|
||||
When using imported photos in your blog, consider adding photographer credit in the caption.
|
||||
|
||||
## Technical Details
|
||||
|
||||
### API Endpoints
|
||||
|
||||
**Search**:
|
||||
```
|
||||
GET /api/stock-photos/search?query=mountain&per_page=30
|
||||
```
|
||||
|
||||
**Import**:
|
||||
```
|
||||
POST /api/stock-photos/import
|
||||
Body: {
|
||||
photoId: string,
|
||||
downloadUrl: string,
|
||||
downloadLocation: string,
|
||||
photographer: string,
|
||||
photographerUrl: string
|
||||
}
|
||||
```
|
||||
|
||||
### Storage
|
||||
|
||||
Imported photos are stored in S3 with the naming pattern:
|
||||
```
|
||||
images/YYYY-MM-DD/unsplash-{photoId}-{randomId}.{ext}
|
||||
```
|
||||
|
||||
### File Formats
|
||||
|
||||
Supported formats:
|
||||
- JPEG (most common)
|
||||
- PNG
|
||||
- WebP
|
||||
- GIF
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Unsplash API key not configured"
|
||||
|
||||
**Solution**: Add `UNSPLASH_ACCESS_KEY` to `.env` and rebuild containers
|
||||
|
||||
### "Failed to search stock photos"
|
||||
|
||||
**Possible causes**:
|
||||
- Invalid API key
|
||||
- Rate limit exceeded
|
||||
- Network connectivity issue
|
||||
|
||||
**Solution**: Check API key, wait if rate limited, verify network
|
||||
|
||||
### "Failed to import photo"
|
||||
|
||||
**Possible causes**:
|
||||
- S3 storage not configured
|
||||
- Network timeout
|
||||
- Invalid photo URL
|
||||
|
||||
**Solution**: Verify S3 credentials, check logs for details
|
||||
|
||||
### Rate Limit Exceeded
|
||||
|
||||
**Solution**:
|
||||
- Wait for rate limit to reset (hourly)
|
||||
- Request production access for higher limits
|
||||
- Cache search results on frontend
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Search Tips
|
||||
|
||||
- **Be specific**: "laptop on wooden desk" vs "laptop"
|
||||
- **Use keywords**: "minimal", "modern", "vintage", etc.
|
||||
- **Try variations**: If no results, try synonyms
|
||||
|
||||
### Import Strategy
|
||||
|
||||
- **Import only what you need**: Don't bulk import
|
||||
- **Review before importing**: Check photo quality and relevance
|
||||
- **Organize after import**: Use descriptive filenames
|
||||
|
||||
### Attribution
|
||||
|
||||
- **Add captions**: Include photographer name in image captions
|
||||
- **Link to photographer**: Use the provided photographer URL
|
||||
- **Follow Unsplash guidelines**: https://unsplash.com/license
|
||||
|
||||
## Example Searches
|
||||
|
||||
Good search terms:
|
||||
- `coffee cup workspace`
|
||||
- `mountain landscape sunset`
|
||||
- `modern office interior`
|
||||
- `person working laptop`
|
||||
- `food photography minimal`
|
||||
- `abstract geometric pattern`
|
||||
- `nature forest trees`
|
||||
- `city skyline night`
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements:
|
||||
- [ ] Pagination for search results
|
||||
- [ ] Filter by orientation (landscape/portrait)
|
||||
- [ ] Filter by color
|
||||
- [ ] Save favorite photographers
|
||||
- [ ] Bulk import
|
||||
- [ ] Search history
|
||||
- [ ] Integration with other stock photo services (Pexels, Pixabay)
|
||||
|
||||
## Resources
|
||||
|
||||
- [Unsplash API Documentation](https://unsplash.com/documentation)
|
||||
- [Unsplash License](https://unsplash.com/license)
|
||||
- [Unsplash Guidelines](https://help.unsplash.com/en/articles/2511245-unsplash-api-guidelines)
|
||||
|
||||
---
|
||||
|
||||
**Status**: ✅ Ready to use
|
||||
**Last Updated**: 2025-10-27
|
||||
**Version**: 1.0
|
||||
@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { Box, Button, Stack, Typography, Paper, TextField, MenuItem } from '@mui/material';
|
||||
import { Box, Button, Stack, Typography, Paper, TextField, MenuItem, Tabs, Tab, CircularProgress } from '@mui/material';
|
||||
|
||||
type MediaItem = {
|
||||
key: string;
|
||||
@ -7,6 +7,27 @@ type MediaItem = {
|
||||
lastModified: string | null;
|
||||
};
|
||||
|
||||
type StockPhoto = {
|
||||
id: string;
|
||||
urls: {
|
||||
small: string;
|
||||
regular: string;
|
||||
full: string;
|
||||
};
|
||||
alt_description: string | null;
|
||||
user: {
|
||||
name: string;
|
||||
links: {
|
||||
html: string;
|
||||
};
|
||||
};
|
||||
links: {
|
||||
download_location: string;
|
||||
};
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export default function MediaLibrary({
|
||||
onInsert,
|
||||
onSetFeature,
|
||||
@ -32,6 +53,12 @@ export default function MediaLibrary({
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(50);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [tab, setTab] = useState<'library' | 'stock'>('library');
|
||||
const [stockQuery, setStockQuery] = useState('');
|
||||
const [stockPhotos, setStockPhotos] = useState<StockPhoto[]>([]);
|
||||
const [stockLoading, setStockLoading] = useState(false);
|
||||
const [stockError, setStockError] = useState('');
|
||||
const [importing, setImporting] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
@ -138,6 +165,59 @@ export default function MediaLibrary({
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const searchStockPhotos = async (searchQuery: string) => {
|
||||
if (!searchQuery.trim()) {
|
||||
setStockPhotos([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setStockLoading(true);
|
||||
setStockError('');
|
||||
const res = await fetch(`/api/stock-photos/search?query=${encodeURIComponent(searchQuery)}&per_page=30`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
setStockPhotos(data.results || []);
|
||||
} catch (e: any) {
|
||||
setStockError(e?.message || 'Failed to search stock photos');
|
||||
setStockPhotos([]);
|
||||
} finally {
|
||||
setStockLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const importStockPhoto = async (photo: StockPhoto) => {
|
||||
try {
|
||||
setImporting(photo.id);
|
||||
setError('');
|
||||
|
||||
// Download and import the photo
|
||||
const res = await fetch('/api/stock-photos/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
photoId: photo.id,
|
||||
downloadUrl: photo.urls.regular,
|
||||
downloadLocation: photo.links.download_location,
|
||||
photographer: photo.user.name,
|
||||
photographerUrl: photo.user.links.html,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
|
||||
// Reload library to show the new image
|
||||
await load();
|
||||
|
||||
// Switch to library tab to see the imported image
|
||||
setTab('library');
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to import photo');
|
||||
} finally {
|
||||
setImporting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileUpload = async (files: FileList | null) => {
|
||||
@ -176,6 +256,84 @@ export default function MediaLibrary({
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tab label="My Library" value="library" />
|
||||
<Tab label="Stock Photos" value="stock" />
|
||||
</Tabs>
|
||||
|
||||
{tab === 'stock' && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Search stock photos (e.g., 'mountain sunset', 'office desk')..."
|
||||
value={stockQuery}
|
||||
onChange={e => setStockQuery(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && searchStockPhotos(stockQuery)}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => searchStockPhotos(stockQuery)}
|
||||
disabled={stockLoading || !stockQuery.trim()}
|
||||
>
|
||||
{stockLoading ? 'Searching...' : 'Search'}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{stockError && <Typography color="error" sx={{ mb: 2 }}>{stockError}</Typography>}
|
||||
{error && <Typography color="error" sx={{ mb: 2 }}>{error}</Typography>}
|
||||
|
||||
{stockLoading && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!stockLoading && stockPhotos.length === 0 && stockQuery && (
|
||||
<Typography variant="body2" sx={{ textAlign: 'center', py: 4, color: 'text.secondary' }}>
|
||||
No results found. Try a different search term.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{!stockLoading && stockPhotos.length === 0 && !stockQuery && (
|
||||
<Typography variant="body2" sx={{ textAlign: 'center', py: 4, color: 'text.secondary' }}>
|
||||
Enter a search term to find free stock photos from Unsplash.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(auto-fill, minmax(150px, 1fr))', sm: 'repeat(auto-fill, minmax(200px, 1fr))' }, gap: 1.5 }}>
|
||||
{stockPhotos.map((photo) => (
|
||||
<Paper key={photo.id} sx={{ p: 1.25, display: 'flex', flexDirection: 'column', minHeight: 320, overflow: 'hidden' }}>
|
||||
<Box sx={{ width: '100%', flex: '0 0 150px', display: 'flex', alignItems: 'center', justifyContent: 'center', mb: 1, overflow: 'hidden', background: '#fafafa', borderRadius: 1 }}>
|
||||
<img src={photo.urls.small} alt={photo.alt_description || 'Stock photo'} style={{ maxWidth: '100%', maxHeight: '150px', objectFit: 'contain', display: 'block' }} />
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.5 }}>
|
||||
Photo by <a href={photo.user.links.html + '?utm_source=voxblog&utm_medium=referral'} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit' }}>{photo.user.name}</a>
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary', mb: 1 }}>
|
||||
{photo.width} × {photo.height}
|
||||
</Typography>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 0.5, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
fullWidth
|
||||
disabled={importing === photo.id}
|
||||
onClick={() => importStockPhoto(photo)}
|
||||
>
|
||||
{importing === photo.id ? 'Importing...' : 'Import to Library'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 'library' && (
|
||||
<>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} justifyContent="flex-end" alignItems={{ xs: 'stretch', sm: 'center' }} sx={{ mb: 2, gap: 1 }}>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} alignItems={{ xs: 'stretch', sm: 'center' }} sx={{ flexWrap: 'wrap' }}>
|
||||
<TextField
|
||||
@ -279,6 +437,8 @@ export default function MediaLibrary({
|
||||
<Typography variant="body2">No images found.</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ import postsRouter from './posts';
|
||||
import ghostRouter from './ghost';
|
||||
import aiGenerateRouter from './ai-generate';
|
||||
import aiRoutesNew from './routes/ai.routes';
|
||||
import stockPhotosRouter from './routes/stock-photos.routes';
|
||||
import settingsRouter from './settings';
|
||||
import { runMigrations } from './db/migrate';
|
||||
|
||||
@ -37,6 +38,7 @@ app.use('/api/ghost', ghostRouter);
|
||||
app.use('/api/ai', aiRoutesNew);
|
||||
// Keep old routes temporarily for backward compatibility (can remove after testing)
|
||||
// app.use('/api/ai', aiGenerateRouter);
|
||||
app.use('/api/stock-photos', stockPhotosRouter);
|
||||
app.use('/api/settings', settingsRouter);
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
|
||||
125
apps/api/src/routes/stock-photos.routes.ts
Normal file
125
apps/api/src/routes/stock-photos.routes.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import express from 'express';
|
||||
import { uploadBuffer } from '../storage/s3';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const UNSPLASH_ACCESS_KEY = process.env.UNSPLASH_ACCESS_KEY;
|
||||
|
||||
/**
|
||||
* GET /api/stock-photos/search
|
||||
* Search Unsplash for stock photos
|
||||
*/
|
||||
router.get('/search', async (req, res) => {
|
||||
try {
|
||||
const { query, per_page = '30' } = req.query;
|
||||
|
||||
if (!query || typeof query !== 'string') {
|
||||
return res.status(400).json({ error: 'query parameter is required' });
|
||||
}
|
||||
|
||||
if (!UNSPLASH_ACCESS_KEY) {
|
||||
return res.status(500).json({ error: 'Unsplash API key not configured' });
|
||||
}
|
||||
|
||||
const unsplashUrl = `https://api.unsplash.com/search/photos?query=${encodeURIComponent(query)}&per_page=${per_page}&client_id=${UNSPLASH_ACCESS_KEY}`;
|
||||
|
||||
const response = await fetch(unsplashUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unsplash API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
res.json(data);
|
||||
} catch (err: any) {
|
||||
console.error('[Stock Photos] Search error:', err);
|
||||
res.status(500).json({
|
||||
error: 'Failed to search stock photos',
|
||||
details: err?.message || 'Unknown error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/stock-photos/import
|
||||
* Download and import a stock photo to the media library
|
||||
*/
|
||||
router.post('/import', async (req, res) => {
|
||||
try {
|
||||
const { photoId, downloadUrl, downloadLocation, photographer, photographerUrl } = req.body;
|
||||
|
||||
if (!photoId || !downloadUrl) {
|
||||
return res.status(400).json({ error: 'photoId and downloadUrl are required' });
|
||||
}
|
||||
|
||||
if (!UNSPLASH_ACCESS_KEY) {
|
||||
return res.status(500).json({ error: 'Unsplash API key not configured' });
|
||||
}
|
||||
|
||||
console.log('[Stock Photos] Importing photo:', photoId);
|
||||
|
||||
// Trigger download tracking (required by Unsplash API guidelines)
|
||||
if (downloadLocation) {
|
||||
try {
|
||||
await fetch(downloadLocation, {
|
||||
headers: {
|
||||
'Authorization': `Client-ID ${UNSPLASH_ACCESS_KEY}`,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[Stock Photos] Failed to track download:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Download the image
|
||||
const imageResponse = await fetch(downloadUrl);
|
||||
|
||||
if (!imageResponse.ok) {
|
||||
throw new Error(`Failed to download image: ${imageResponse.status}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await imageResponse.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
const contentType = imageResponse.headers.get('content-type') || 'image/jpeg';
|
||||
|
||||
// Determine file extension
|
||||
let ext = 'jpg';
|
||||
if (contentType.includes('png')) ext = 'png';
|
||||
else if (contentType.includes('webp')) ext = 'webp';
|
||||
else if (contentType.includes('gif')) ext = 'gif';
|
||||
|
||||
// Generate unique filename
|
||||
const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
const randomId = crypto.randomBytes(8).toString('hex');
|
||||
const filename = `unsplash-${photoId}-${randomId}.${ext}`;
|
||||
const key = `images/${timestamp}/${filename}`;
|
||||
|
||||
// Upload to S3
|
||||
const bucket = process.env.S3_BUCKET || '';
|
||||
await uploadBuffer({
|
||||
bucket,
|
||||
key,
|
||||
body: buffer,
|
||||
contentType,
|
||||
});
|
||||
|
||||
console.log('[Stock Photos] Imported successfully:', key);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
key,
|
||||
bucket,
|
||||
url: `/api/media/obj?bucket=${encodeURIComponent(bucket)}&key=${encodeURIComponent(key)}`,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('[Stock Photos] Import error:', err);
|
||||
res.status(500).json({
|
||||
error: 'Failed to import photo',
|
||||
details: err?.message || 'Unknown error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Loading…
Reference in New Issue
Block a user