feat: add Unsplash stock photo integration to Media Library
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:
Ender 2025-10-27 21:28:43 +01:00
parent 321fd63f5a
commit 28b12a4376
6 changed files with 597 additions and 1 deletions

View File

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

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

View File

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

View File

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

View 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;