feat: add pagination and rate limit tracking to stock photo search

- Added pagination controls with Previous/Next buttons for stock photo results
- Implemented rate limit tracking and display showing API usage and reset time
- Enhanced stock photo search to support page navigation and results count
- Added new types for API responses and rate limit information
- Refactored stock photos API route with helper functions for better code organization
- Added validation and transformation functions for
This commit is contained in:
Ender 2025-10-28 12:59:57 +01:00
parent 0c2813bea6
commit 669fff9843
2 changed files with 237 additions and 36 deletions

View File

@ -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, Tabs, Tab, CircularProgress } from '@mui/material'; import { Box, Button, Stack, Typography, Paper, TextField, MenuItem, Tabs, Tab, CircularProgress, Alert, Chip } from '@mui/material';
type MediaItem = { type MediaItem = {
key: string; key: string;
@ -25,6 +25,23 @@ type StockPhoto = {
height: number; 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({ export default function MediaLibrary({
onInsert, onInsert,
onSetFeature, onSetFeature,
@ -56,6 +73,10 @@ export default function MediaLibrary({
const [stockLoading, setStockLoading] = useState(false); const [stockLoading, setStockLoading] = useState(false);
const [stockError, setStockError] = useState(''); const [stockError, setStockError] = useState('');
const [importing, setImporting] = useState<string | null>(null); const [importing, setImporting] = useState<string | null>(null);
const [stockPage, setStockPage] = useState(1);
const [stockTotalPages, setStockTotalPages] = useState(0);
const [stockTotal, setStockTotal] = useState(0);
const [rateLimit, setRateLimit] = useState<RateLimitInfo | null>(null);
const load = async () => { const load = async () => {
try { try {
@ -162,22 +183,34 @@ export default function MediaLibrary({
} catch {} } catch {}
}; };
const searchStockPhotos = async (searchQuery: string) => { const searchStockPhotos = async (searchQuery: string, page: number = 1) => {
if (!searchQuery.trim()) { if (!searchQuery.trim()) {
setStockPhotos([]); setStockPhotos([]);
setStockPage(1);
setStockTotalPages(0);
setStockTotal(0);
return; return;
} }
try { try {
setStockLoading(true); setStockLoading(true);
setStockError(''); 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()); if (!res.ok) throw new Error(await res.text());
const data = await res.json(); const data: StockSearchResult = await res.json();
setStockPhotos(data.results || []); setStockPhotos(data.results || []);
setStockPage(data.page);
setStockTotalPages(data.total_pages);
setStockTotal(data.total);
if (data.rate_limit) {
setRateLimit(data.rate_limit);
}
} catch (e: any) { } catch (e: any) {
setStockError(e?.message || 'Failed to search stock photos'); setStockError(e?.message || 'Failed to search stock photos');
setStockPhotos([]); setStockPhotos([]);
setStockPage(1);
setStockTotalPages(0);
setStockTotal(0);
} finally { } finally {
setStockLoading(false); setStockLoading(false);
} }
@ -266,17 +299,43 @@ export default function MediaLibrary({
placeholder="Search stock photos (e.g., 'mountain sunset', 'office desk')..." placeholder="Search stock photos (e.g., 'mountain sunset', 'office desk')..."
value={stockQuery} value={stockQuery}
onChange={e => setStockQuery(e.target.value)} onChange={e => setStockQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && searchStockPhotos(stockQuery)} onKeyDown={e => {
if (e.key === 'Enter') {
setStockPage(1);
searchStockPhotos(stockQuery, 1);
}
}}
/> />
<Button <Button
variant="contained" variant="contained"
onClick={() => searchStockPhotos(stockQuery)} onClick={() => {
setStockPage(1);
searchStockPhotos(stockQuery, 1);
}}
disabled={stockLoading || !stockQuery.trim()} disabled={stockLoading || !stockQuery.trim()}
> >
{stockLoading ? 'Searching...' : 'Search'} {stockLoading ? 'Searching...' : 'Search'}
</Button> </Button>
</Stack> </Stack>
{rateLimit && (
<Alert severity="info" sx={{ mb: 2 }}>
<Stack direction="row" spacing={2} alignItems="center" flexWrap="wrap">
<Typography variant="body2">
API Usage:
</Typography>
<Chip
label={`${rateLimit.remaining.toLocaleString()} / ${rateLimit.limit.toLocaleString()} remaining`}
size="small"
color={rateLimit.remaining < 1000 ? 'warning' : 'default'}
/>
<Typography variant="caption" color="text.secondary">
Resets: {new Date(rateLimit.reset * 1000).toLocaleString()}
</Typography>
</Stack>
</Alert>
)}
{stockError && <Typography color="error" sx={{ mb: 2 }}>{stockError}</Typography>} {stockError && <Typography color="error" sx={{ mb: 2 }}>{stockError}</Typography>}
{error && <Typography color="error" sx={{ mb: 2 }}>{error}</Typography>} {error && <Typography color="error" sx={{ mb: 2 }}>{error}</Typography>}
@ -298,6 +357,38 @@ export default function MediaLibrary({
</Typography> </Typography>
)} )}
{stockPhotos.length > 0 && (
<Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{stockTotal.toLocaleString()} results · Page {stockPage} of {stockTotalPages}
</Typography>
<Stack direction="row" spacing={1}>
<Button
size="small"
disabled={stockPage === 1 || stockLoading}
onClick={() => {
const newPage = stockPage - 1;
setStockPage(newPage);
searchStockPhotos(stockQuery, newPage);
}}
>
Previous
</Button>
<Button
size="small"
disabled={stockPage >= stockTotalPages || stockLoading}
onClick={() => {
const newPage = stockPage + 1;
setStockPage(newPage);
searchStockPhotos(stockQuery, newPage);
}}
>
Next
</Button>
</Stack>
</Stack>
)}
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(auto-fill, minmax(150px, 1fr))', sm: 'repeat(auto-fill, minmax(200px, 1fr))' }, gap: 1.5 }}> <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) => ( {stockPhotos.map((photo) => (
<Paper key={photo.id} sx={{ p: 1.25, display: 'flex', flexDirection: 'column', minHeight: 320, overflow: 'hidden' }}> <Paper key={photo.id} sx={{ p: 1.25, display: 'flex', flexDirection: 'column', minHeight: 320, overflow: 'hidden' }}>

View File

@ -6,39 +6,93 @@ const router = express.Router();
const PEXELS_API_KEY = process.env.PEXELS_API_KEY; const PEXELS_API_KEY = process.env.PEXELS_API_KEY;
/** // Types
* GET /api/stock-photos/search interface PexelsPhoto {
* Search Pexels for stock photos id: number;
*/ width: number;
router.get('/search', async (req, res) => { height: number;
try { url: string;
const { query, per_page = '30' } = req.query; 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;
}
if (!query || typeof query !== 'string') { interface PexelsSearchResponse {
return res.status(400).json({ error: 'query parameter is required' }); 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;
} }
if (!PEXELS_API_KEY) { return {
return res.status(500).json({ error: 'Pexels API key not configured' }); limit: parseInt(limit, 10),
} remaining: parseInt(remaining, 10),
reset: parseInt(reset, 10),
};
}
const pexelsUrl = `https://api.pexels.com/v1/search?query=${encodeURIComponent(query)}&per_page=${per_page}`; function transformPexelsPhoto(photo: PexelsPhoto): TransformedPhoto {
return {
const response = await fetch(pexelsUrl, {
headers: {
'Authorization': PEXELS_API_KEY,
},
});
if (!response.ok) {
throw new Error(`Pexels API error: ${response.status}`);
}
const data = await response.json();
// Transform Pexels response to match expected format
const transformedData = {
results: data.photos?.map((photo: any) => ({
id: String(photo.id), id: String(photo.id),
urls: { urls: {
small: photo.src.small, small: photo.src.small,
@ -54,8 +108,68 @@ router.get('/search', async (req, res) => {
}, },
width: photo.width, width: photo.width,
height: photo.height, 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 with pagination and rate limit tracking
*/
router.get('/search', async (req, res) => {
try {
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 (!validateApiKey()) {
return res.status(500).json({ error: 'Pexels API key not configured' });
}
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!,
},
});
if (!response.ok) {
throw new Error(`Pexels API error: ${response.status}`);
}
const data: PexelsSearchResponse = await response.json();
const rateLimit = extractRateLimitInfo(response.headers);
const totalPages = calculateTotalPages(data.total_results, perPageNum);
const transformedData: SearchResult = {
results: data.photos?.map(transformPexelsPhoto) || [],
total: data.total_results || 0, 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); res.json(transformedData);
@ -97,11 +211,7 @@ router.post('/import', async (req, res) => {
const buffer = Buffer.from(arrayBuffer); const buffer = Buffer.from(arrayBuffer);
const contentType = imageResponse.headers.get('content-type') || 'image/jpeg'; const contentType = imageResponse.headers.get('content-type') || 'image/jpeg';
// Determine file extension const ext = getFileExtensionFromContentType(contentType);
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 // Generate unique filename
const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD