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:
parent
0c2813bea6
commit
669fff9843
@ -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<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 () => {
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => searchStockPhotos(stockQuery)}
|
||||
onClick={() => {
|
||||
setStockPage(1);
|
||||
searchStockPhotos(stockQuery, 1);
|
||||
}}
|
||||
disabled={stockLoading || !stockQuery.trim()}
|
||||
>
|
||||
{stockLoading ? 'Searching...' : 'Search'}
|
||||
</Button>
|
||||
</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>}
|
||||
{error && <Typography color="error" sx={{ mb: 2 }}>{error}</Typography>}
|
||||
|
||||
@ -298,6 +357,38 @@ export default function MediaLibrary({
|
||||
</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 }}>
|
||||
{stockPhotos.map((photo) => (
|
||||
<Paper key={photo.id} sx={{ p: 1.25, display: 'flex', flexDirection: 'column', minHeight: 320, overflow: 'hidden' }}>
|
||||
|
||||
@ -6,39 +6,93 @@ const router = express.Router();
|
||||
|
||||
const PEXELS_API_KEY = process.env.PEXELS_API_KEY;
|
||||
|
||||
/**
|
||||
* GET /api/stock-photos/search
|
||||
* Search Pexels for stock photos
|
||||
*/
|
||||
router.get('/search', async (req, res) => {
|
||||
try {
|
||||
const { query, per_page = '30' } = req.query;
|
||||
// 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;
|
||||
}
|
||||
|
||||
if (!query || typeof query !== 'string') {
|
||||
return res.status(400).json({ error: 'query parameter is required' });
|
||||
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;
|
||||
}
|
||||
|
||||
if (!PEXELS_API_KEY) {
|
||||
return res.status(500).json({ error: 'Pexels API key not configured' });
|
||||
}
|
||||
return {
|
||||
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}`;
|
||||
|
||||
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) => ({
|
||||
function transformPexelsPhoto(photo: PexelsPhoto): TransformedPhoto {
|
||||
return {
|
||||
id: String(photo.id),
|
||||
urls: {
|
||||
small: photo.src.small,
|
||||
@ -54,8 +108,68 @@ router.get('/search', async (req, res) => {
|
||||
},
|
||||
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 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,
|
||||
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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user