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 { 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' }}>
|
||||||
|
|||||||
@ -6,27 +6,150 @@ const router = express.Router();
|
|||||||
|
|
||||||
const PEXELS_API_KEY = process.env.PEXELS_API_KEY;
|
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
|
* 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) => {
|
router.get('/search', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { query, per_page = '30' } = req.query;
|
const { query, per_page = '30', page = '1' } = req.query;
|
||||||
|
|
||||||
if (!query || typeof query !== 'string') {
|
if (!query || typeof query !== 'string') {
|
||||||
return res.status(400).json({ error: 'query parameter is required' });
|
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' });
|
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, {
|
const response = await fetch(pexelsUrl, {
|
||||||
headers: {
|
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}`);
|
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 totalPages = calculateTotalPages(data.total_results, perPageNum);
|
||||||
const transformedData = {
|
const transformedData: SearchResult = {
|
||||||
results: data.photos?.map((photo: any) => ({
|
results: data.photos?.map(transformPexelsPhoto) || [],
|
||||||
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,
|
|
||||||
})) || [],
|
|
||||||
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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user