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
|
# Application
|
||||||
ADMIN_PASSWORD=your_admin_password_here
|
ADMIN_PASSWORD=your_admin_password_here
|
||||||
OPENAI_API_KEY=sk-your-openai-api-key
|
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_KEY=your_ghost_admin_api_key
|
||||||
GHOST_ADMIN_API_URL=https://your-ghost-instance/admin
|
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 { 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 = {
|
type MediaItem = {
|
||||||
key: string;
|
key: string;
|
||||||
@ -7,6 +7,27 @@ type MediaItem = {
|
|||||||
lastModified: string | null;
|
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({
|
export default function MediaLibrary({
|
||||||
onInsert,
|
onInsert,
|
||||||
onSetFeature,
|
onSetFeature,
|
||||||
@ -32,6 +53,12 @@ export default function MediaLibrary({
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize] = useState(50);
|
const [pageSize] = useState(50);
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
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 () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
@ -138,6 +165,59 @@ export default function MediaLibrary({
|
|||||||
} catch {}
|
} 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 fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const handleFileUpload = async (files: FileList | null) => {
|
const handleFileUpload = async (files: FileList | null) => {
|
||||||
@ -176,6 +256,84 @@ export default function MediaLibrary({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ p: 2 }}>
|
<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' }} 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' }}>
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} alignItems={{ xs: 'stretch', sm: 'center' }} sx={{ flexWrap: 'wrap' }}>
|
||||||
<TextField
|
<TextField
|
||||||
@ -279,6 +437,8 @@ export default function MediaLibrary({
|
|||||||
<Typography variant="body2">No images found.</Typography>
|
<Typography variant="body2">No images found.</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import postsRouter from './posts';
|
|||||||
import ghostRouter from './ghost';
|
import ghostRouter from './ghost';
|
||||||
import aiGenerateRouter from './ai-generate';
|
import aiGenerateRouter from './ai-generate';
|
||||||
import aiRoutesNew from './routes/ai.routes';
|
import aiRoutesNew from './routes/ai.routes';
|
||||||
|
import stockPhotosRouter from './routes/stock-photos.routes';
|
||||||
import settingsRouter from './settings';
|
import settingsRouter from './settings';
|
||||||
import { runMigrations } from './db/migrate';
|
import { runMigrations } from './db/migrate';
|
||||||
|
|
||||||
@ -37,6 +38,7 @@ app.use('/api/ghost', ghostRouter);
|
|||||||
app.use('/api/ai', aiRoutesNew);
|
app.use('/api/ai', aiRoutesNew);
|
||||||
// Keep old routes temporarily for backward compatibility (can remove after testing)
|
// Keep old routes temporarily for backward compatibility (can remove after testing)
|
||||||
// app.use('/api/ai', aiGenerateRouter);
|
// app.use('/api/ai', aiGenerateRouter);
|
||||||
|
app.use('/api/stock-photos', stockPhotosRouter);
|
||||||
app.use('/api/settings', settingsRouter);
|
app.use('/api/settings', settingsRouter);
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
res.json({ ok: true });
|
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