Initial release v1.0.0

This commit is contained in:
Kevin O'Neill
2025-12-25 18:58:06 -06:00
commit 021aec7a63
439 changed files with 116588 additions and 0 deletions
+25
View File
@@ -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
);
}
+55
View File
@@ -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`;
}
+196
View File
@@ -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()),
};
}