diff --git a/apps/admin/src/components/MediaLibrary.tsx b/apps/admin/src/components/MediaLibrary.tsx index a4aee21..e859d87 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, Tabs, Tab, CircularProgress } from '@mui/material'; +import { Box, Button, Stack, Typography, Paper, TextField, MenuItem, Tabs, Tab, CircularProgress, Alert, Chip } from '@mui/material'; type MediaItem = { key: string; @@ -25,6 +25,23 @@ type StockPhoto = { height: number; }; +type RateLimitInfo = { + limit: number; + remaining: number; + reset: number; +}; + +type StockSearchResult = { + results: StockPhoto[]; + total: number; + page: number; + per_page: number; + total_pages: number; + has_next_page: boolean; + has_prev_page: boolean; + rate_limit?: RateLimitInfo; +}; + export default function MediaLibrary({ onInsert, onSetFeature, @@ -56,6 +73,10 @@ export default function MediaLibrary({ const [stockLoading, setStockLoading] = useState(false); const [stockError, setStockError] = useState(''); const [importing, setImporting] = useState(null); + const [stockPage, setStockPage] = useState(1); + const [stockTotalPages, setStockTotalPages] = useState(0); + const [stockTotal, setStockTotal] = useState(0); + const [rateLimit, setRateLimit] = useState(null); const load = async () => { try { @@ -162,22 +183,34 @@ export default function MediaLibrary({ } catch {} }; - const searchStockPhotos = async (searchQuery: string) => { + const searchStockPhotos = async (searchQuery: string, page: number = 1) => { if (!searchQuery.trim()) { setStockPhotos([]); + setStockPage(1); + setStockTotalPages(0); + setStockTotal(0); return; } try { setStockLoading(true); setStockError(''); - const res = await fetch(`/api/stock-photos/search?query=${encodeURIComponent(searchQuery)}&per_page=30`); + const res = await fetch(`/api/stock-photos/search?query=${encodeURIComponent(searchQuery)}&per_page=30&page=${page}`); if (!res.ok) throw new Error(await res.text()); - const data = await res.json(); + const data: StockSearchResult = await res.json(); setStockPhotos(data.results || []); + setStockPage(data.page); + setStockTotalPages(data.total_pages); + setStockTotal(data.total); + if (data.rate_limit) { + setRateLimit(data.rate_limit); + } } catch (e: any) { setStockError(e?.message || 'Failed to search stock photos'); setStockPhotos([]); + setStockPage(1); + setStockTotalPages(0); + setStockTotal(0); } finally { setStockLoading(false); } @@ -266,17 +299,43 @@ export default function MediaLibrary({ 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)} + onKeyDown={e => { + if (e.key === 'Enter') { + setStockPage(1); + searchStockPhotos(stockQuery, 1); + } + }} /> + {rateLimit && ( + + + + API Usage: + + + + Resets: {new Date(rateLimit.reset * 1000).toLocaleString()} + + + + )} + {stockError && {stockError}} {error && {error}} @@ -298,6 +357,38 @@ export default function MediaLibrary({ )} + {stockPhotos.length > 0 && ( + + + {stockTotal.toLocaleString()} results ยท Page {stockPage} of {stockTotalPages} + + + + + + + )} + {stockPhotos.map((photo) => ( diff --git a/apps/api/src/routes/stock-photos.routes.ts b/apps/api/src/routes/stock-photos.routes.ts index 3479a3a..4855482 100644 --- a/apps/api/src/routes/stock-photos.routes.ts +++ b/apps/api/src/routes/stock-photos.routes.ts @@ -6,27 +6,150 @@ const router = express.Router(); const PEXELS_API_KEY = process.env.PEXELS_API_KEY; +// Types +interface PexelsPhoto { + id: number; + width: number; + height: number; + url: string; + photographer: string; + photographer_url: string; + photographer_id: number; + avg_color: string; + src: { + original: string; + large2x: string; + large: string; + medium: string; + small: string; + portrait: string; + landscape: string; + tiny: string; + }; + liked: boolean; + alt: string; +} + +interface PexelsSearchResponse { + page: number; + per_page: number; + photos: PexelsPhoto[]; + total_results: number; + next_page?: string; + prev_page?: string; +} + +interface RateLimitInfo { + limit: number; + remaining: number; + reset: number; +} + +interface TransformedPhoto { + id: string; + urls: { + small: string; + regular: string; + full: string; + }; + alt_description: string | null; + user: { + name: string; + links: { + html: string; + }; + }; + width: number; + height: number; +} + +interface SearchResult { + results: TransformedPhoto[]; + total: number; + page: number; + per_page: number; + total_pages: number; + has_next_page: boolean; + has_prev_page: boolean; + rate_limit?: RateLimitInfo; +} + +// Helper Functions +function extractRateLimitInfo(headers: Headers): RateLimitInfo | undefined { + const limit = headers.get('X-Ratelimit-Limit'); + const remaining = headers.get('X-Ratelimit-Remaining'); + const reset = headers.get('X-Ratelimit-Reset'); + + if (!limit || !remaining || !reset) { + return undefined; + } + + return { + limit: parseInt(limit, 10), + remaining: parseInt(remaining, 10), + reset: parseInt(reset, 10), + }; +} + +function transformPexelsPhoto(photo: PexelsPhoto): TransformedPhoto { + return { + id: String(photo.id), + urls: { + small: photo.src.small, + regular: photo.src.large, + full: photo.src.original, + }, + alt_description: photo.alt || null, + user: { + name: photo.photographer, + links: { + html: photo.photographer_url, + }, + }, + width: photo.width, + height: photo.height, + }; +} + +function calculateTotalPages(totalResults: number, perPage: number): number { + return Math.ceil(totalResults / perPage); +} + +function validateApiKey(): boolean { + return !!PEXELS_API_KEY; +} + +function getFileExtensionFromContentType(contentType: string): string { + if (contentType.includes('png')) return 'png'; + if (contentType.includes('webp')) return 'webp'; + if (contentType.includes('gif')) return 'gif'; + return 'jpg'; +} + /** * GET /api/stock-photos/search - * Search Pexels for stock photos + * Search Pexels for stock photos with pagination and rate limit tracking */ router.get('/search', async (req, res) => { try { - const { query, per_page = '30' } = req.query; + const { query, per_page = '30', page = '1' } = req.query; if (!query || typeof query !== 'string') { return res.status(400).json({ error: 'query parameter is required' }); } - if (!PEXELS_API_KEY) { + if (!validateApiKey()) { return res.status(500).json({ error: 'Pexels API key not configured' }); } - const pexelsUrl = `https://api.pexels.com/v1/search?query=${encodeURIComponent(query)}&per_page=${per_page}`; + const perPageNum = Math.min(parseInt(per_page as string, 10) || 30, 80); + const pageNum = Math.max(parseInt(page as string, 10) || 1, 1); + + const pexelsUrl = `https://api.pexels.com/v1/search?query=${encodeURIComponent(query)}&per_page=${perPageNum}&page=${pageNum}`; const response = await fetch(pexelsUrl, { headers: { - 'Authorization': PEXELS_API_KEY, + 'Authorization': PEXELS_API_KEY!, }, }); @@ -34,28 +157,19 @@ router.get('/search', async (req, res) => { throw new Error(`Pexels API error: ${response.status}`); } - const data = await response.json(); + const data: PexelsSearchResponse = await response.json(); + const rateLimit = extractRateLimitInfo(response.headers); - // Transform Pexels response to match expected format - const transformedData = { - results: data.photos?.map((photo: any) => ({ - id: String(photo.id), - urls: { - small: photo.src.small, - regular: photo.src.large, - full: photo.src.original, - }, - alt_description: photo.alt || null, - user: { - name: photo.photographer, - links: { - html: photo.photographer_url, - }, - }, - width: photo.width, - height: photo.height, - })) || [], + const totalPages = calculateTotalPages(data.total_results, perPageNum); + const transformedData: SearchResult = { + results: data.photos?.map(transformPexelsPhoto) || [], total: data.total_results || 0, + page: data.page, + per_page: data.per_page, + total_pages: totalPages, + has_next_page: data.page < totalPages, + has_prev_page: data.page > 1, + rate_limit: rateLimit, }; res.json(transformedData); @@ -97,11 +211,7 @@ router.post('/import', async (req, res) => { 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'; + const ext = getFileExtensionFromContentType(contentType); // Generate unique filename const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD