diff --git a/.env.example b/.env.example index 2f64607..66069ea 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/STOCK_PHOTOS_QUICK_START.md b/STOCK_PHOTOS_QUICK_START.md new file mode 100644 index 0000000..7d73897 --- /dev/null +++ b/STOCK_PHOTOS_QUICK_START.md @@ -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 🎉 diff --git a/STOCK_PHOTOS_SETUP.md b/STOCK_PHOTOS_SETUP.md new file mode 100644 index 0000000..8f43054 --- /dev/null +++ b/STOCK_PHOTOS_SETUP.md @@ -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 diff --git a/apps/admin/src/components/MediaLibrary.tsx b/apps/admin/src/components/MediaLibrary.tsx index 620cc3c..a499817 100644 --- a/apps/admin/src/components/MediaLibrary.tsx +++ b/apps/admin/src/components/MediaLibrary.tsx @@ -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([]); + const [stockLoading, setStockLoading] = useState(false); + const [stockError, setStockError] = useState(''); + const [importing, setImporting] = useState(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(null); const handleFileUpload = async (files: FileList | null) => { @@ -176,6 +256,84 @@ export default function MediaLibrary({ return ( + setTab(v)} sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }}> + + + + + {tab === 'stock' && ( + + + setStockQuery(e.target.value)} + onKeyDown={e => e.key === 'Enter' && searchStockPhotos(stockQuery)} + /> + + + + {stockError && {stockError}} + {error && {error}} + + {stockLoading && ( + + + + )} + + {!stockLoading && stockPhotos.length === 0 && stockQuery && ( + + No results found. Try a different search term. + + )} + + {!stockLoading && stockPhotos.length === 0 && !stockQuery && ( + + Enter a search term to find free stock photos from Unsplash. + + )} + + + {stockPhotos.map((photo) => ( + + + {photo.alt_description + + + Photo by {photo.user.name} + + + {photo.width} × {photo.height} + + + + + + + ))} + + + )} + + {tab === 'library' && ( + <> No images found. )} + + )} ); } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 86123a3..9976130 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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 }); diff --git a/apps/api/src/routes/stock-photos.routes.ts b/apps/api/src/routes/stock-photos.routes.ts new file mode 100644 index 0000000..0f1519d --- /dev/null +++ b/apps/api/src/routes/stock-photos.routes.ts @@ -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;