Initial release v1.0.0
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is a local/development URL that shouldn't be optimized by Next.js Image.
|
||||
* Includes localhost, 127.0.0.1, 10.0.2.2 (Android emulator), and private network ranges.
|
||||
*/
|
||||
export function isLocalUrl(url: string): boolean {
|
||||
return (
|
||||
url.startsWith("http://localhost") ||
|
||||
url.startsWith("http://127.0.0.1") ||
|
||||
url.startsWith("http://10.0.2.2") || // Android emulator host
|
||||
url.startsWith("http://10.0.3.2") || // Genymotion emulator host
|
||||
url.startsWith("http://192.168.") || // Private network
|
||||
url.startsWith("http://10.") || // Private network (broader)
|
||||
url.startsWith("http://172.16.") || // Private network
|
||||
url.startsWith("http://172.17.") || // Docker bridge
|
||||
url.startsWith("http://172.18.") || // Docker network
|
||||
url.startsWith("http://host.docker.internal") // Docker for desktop
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Format seconds into a human-readable time string
|
||||
* For durations under 1 hour: m:ss (e.g., 5:32)
|
||||
* For durations 1 hour or more: h:mm:ss (e.g., 1:05:32)
|
||||
*/
|
||||
export function formatTime(seconds: number): string {
|
||||
if (isNaN(seconds) || !isFinite(seconds) || seconds < 0) return "0:00";
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format seconds into a human-readable duration string
|
||||
* Always shows full format for clarity (e.g., "2h 30m" or "45m")
|
||||
*/
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (isNaN(seconds) || !isFinite(seconds) || seconds < 0) return "0m";
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
if (mins > 0) {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
return `${hours}h`;
|
||||
}
|
||||
return `${mins}m`;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Client-side image cache using object URLs with request queuing
|
||||
* Prevents overwhelming the server with simultaneous image requests
|
||||
*/
|
||||
|
||||
interface CachedImage {
|
||||
objectUrl: string;
|
||||
originalUrl: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface QueuedRequest {
|
||||
url: string;
|
||||
resolve: (url: string) => void;
|
||||
reject: (error: Error) => void;
|
||||
retries: number;
|
||||
}
|
||||
|
||||
const imageCache = new Map<string, CachedImage>();
|
||||
const MAX_CACHE_AGE = 1000 * 60 * 60; // 1 hour
|
||||
const pendingFetches = new Map<string, Promise<string>>();
|
||||
|
||||
// Request queue to prevent overwhelming the server
|
||||
const requestQueue: QueuedRequest[] = [];
|
||||
let activeRequests = 0;
|
||||
const MAX_CONCURRENT_REQUESTS = 3; // Only 3 simultaneous image fetches
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
/**
|
||||
* Process the next item in the request queue
|
||||
*/
|
||||
async function processQueue() {
|
||||
if (
|
||||
activeRequests >= MAX_CONCURRENT_REQUESTS ||
|
||||
requestQueue.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = requestQueue.shift();
|
||||
if (!request) return;
|
||||
|
||||
activeRequests++;
|
||||
|
||||
try {
|
||||
const url = await fetchImageWithRetry(request.url, request.retries);
|
||||
request.resolve(url);
|
||||
} catch (error) {
|
||||
request.reject(error as Error);
|
||||
} finally {
|
||||
activeRequests--;
|
||||
// Process next item in queue
|
||||
setTimeout(processQueue, 50); // Small delay between requests
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch image with exponential backoff retry for 429 errors
|
||||
*/
|
||||
async function fetchImageWithRetry(
|
||||
url: string,
|
||||
retriesLeft: number
|
||||
): Promise<string> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
credentials: "include",
|
||||
cache: "force-cache",
|
||||
});
|
||||
|
||||
// Handle rate limiting with exponential backoff
|
||||
if (response.status === 429) {
|
||||
if (retriesLeft > 0) {
|
||||
const retryAfter = response.headers.get("Retry-After");
|
||||
const delayMs = retryAfter
|
||||
? parseInt(retryAfter) * 1000
|
||||
: Math.pow(2, MAX_RETRIES - retriesLeft) * 1000; // Exponential: 1s, 2s, 4s
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
return fetchImageWithRetry(url, retriesLeft - 1);
|
||||
}
|
||||
throw new Error(`Rate limited: ${response.status}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch image: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
imageCache.set(url, {
|
||||
objectUrl,
|
||||
originalUrl: url,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
pendingFetches.delete(url);
|
||||
return objectUrl;
|
||||
} catch (error) {
|
||||
console.error("Failed to cache image:", url, error);
|
||||
pendingFetches.delete(url);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached image URL or fetch and cache it (with queuing)
|
||||
*/
|
||||
export async function getCachedImageUrl(url: string): Promise<string> {
|
||||
if (!url) return url;
|
||||
|
||||
// Check if already cached
|
||||
const cached = imageCache.get(url);
|
||||
if (cached) {
|
||||
// Check if cache is still fresh
|
||||
if (Date.now() - cached.timestamp < MAX_CACHE_AGE) {
|
||||
return cached.objectUrl;
|
||||
} else {
|
||||
// Revoke old object URL to free memory
|
||||
URL.revokeObjectURL(cached.objectUrl);
|
||||
imageCache.delete(url);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if already fetching
|
||||
if (pendingFetches.has(url)) {
|
||||
return pendingFetches.get(url)!;
|
||||
}
|
||||
|
||||
// Add to queue instead of fetching immediately
|
||||
const fetchPromise = new Promise<string>((resolve, reject) => {
|
||||
requestQueue.push({
|
||||
url,
|
||||
resolve,
|
||||
reject,
|
||||
retries: MAX_RETRIES,
|
||||
});
|
||||
|
||||
// Start processing queue
|
||||
processQueue();
|
||||
});
|
||||
|
||||
pendingFetches.set(url, fetchPromise);
|
||||
|
||||
return fetchPromise.catch(() => {
|
||||
// Fallback to original URL on error
|
||||
return url;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload an image into cache
|
||||
*/
|
||||
export async function preloadImage(url: string): Promise<void> {
|
||||
if (!url) return;
|
||||
await getCachedImageUrl(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload multiple images
|
||||
*/
|
||||
export async function preloadImages(urls: string[]): Promise<void> {
|
||||
const promises = urls
|
||||
.filter(Boolean)
|
||||
.map((url) => preloadImage(url).catch(() => {}));
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if image is already cached
|
||||
*/
|
||||
export function isImageCached(url: string): boolean {
|
||||
const cached = imageCache.get(url);
|
||||
if (!cached) return false;
|
||||
return Date.now() - cached.timestamp < MAX_CACHE_AGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached images
|
||||
*/
|
||||
export function clearImageCache(): void {
|
||||
imageCache.forEach((cached) => {
|
||||
URL.revokeObjectURL(cached.objectUrl);
|
||||
});
|
||||
imageCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache stats
|
||||
*/
|
||||
export function getCacheStats() {
|
||||
return {
|
||||
size: imageCache.size,
|
||||
urls: Array.from(imageCache.keys()),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user