660 lines
22 KiB
TypeScript
660 lines
22 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useRef, DragEvent, ChangeEvent } from 'react';
|
|
import { MinimizedApiKey } from '@/components/demo/MinimizedApiKey';
|
|
import { CodeExamplesWidget } from '@/components/demo/CodeExamplesWidget';
|
|
import { ImageZoomModal } from '@/components/demo/ImageZoomModal';
|
|
|
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
|
const API_KEY_STORAGE_KEY = 'banatie_demo_api_key';
|
|
const UPLOAD_HISTORY_KEY = 'banatie_upload_history';
|
|
|
|
const ALLOWED_FILE_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'];
|
|
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
|
|
|
interface UploadResponse {
|
|
success: boolean;
|
|
message: string;
|
|
data?: {
|
|
filename: string;
|
|
originalName: string;
|
|
path: string;
|
|
url: string;
|
|
size: number;
|
|
contentType: string;
|
|
uploadedAt: string;
|
|
};
|
|
error?: string;
|
|
}
|
|
|
|
interface UploadHistoryItem {
|
|
id: string;
|
|
timestamp: Date;
|
|
filename: string;
|
|
originalName: string;
|
|
url: string;
|
|
size: number;
|
|
contentType: string;
|
|
durationMs: number;
|
|
}
|
|
|
|
interface ApiKeyInfo {
|
|
organizationSlug?: string;
|
|
projectSlug?: string;
|
|
}
|
|
|
|
export default function DemoUploadPage() {
|
|
// API Key State
|
|
const [apiKey, setApiKey] = useState('');
|
|
const [apiKeyVisible, setApiKeyVisible] = useState(false);
|
|
const [apiKeyValidated, setApiKeyValidated] = useState(false);
|
|
const [apiKeyInfo, setApiKeyInfo] = useState<ApiKeyInfo | null>(null);
|
|
const [apiKeyError, setApiKeyError] = useState('');
|
|
const [validatingKey, setValidatingKey] = useState(false);
|
|
|
|
// Upload State
|
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [uploadError, setUploadError] = useState('');
|
|
const [validationError, setValidationError] = useState('');
|
|
const [dragActive, setDragActive] = useState(false);
|
|
|
|
// History State
|
|
const [uploadHistory, setUploadHistory] = useState<UploadHistoryItem[]>([]);
|
|
|
|
// Zoom Modal State
|
|
const [zoomedImageUrl, setZoomedImageUrl] = useState<string | null>(null);
|
|
|
|
// Copy Feedback State
|
|
const [codeCopied, setCodeCopied] = useState(false);
|
|
|
|
// Refs
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Load API key from localStorage on mount
|
|
useEffect(() => {
|
|
const storedApiKey = localStorage.getItem(API_KEY_STORAGE_KEY);
|
|
if (storedApiKey) {
|
|
setApiKey(storedApiKey);
|
|
validateStoredApiKey(storedApiKey);
|
|
}
|
|
}, []);
|
|
|
|
// Load upload history from sessionStorage
|
|
useEffect(() => {
|
|
const storedHistory = sessionStorage.getItem(UPLOAD_HISTORY_KEY);
|
|
if (storedHistory) {
|
|
try {
|
|
const parsed = JSON.parse(storedHistory);
|
|
setUploadHistory(
|
|
parsed.map((item: UploadHistoryItem) => ({
|
|
...item,
|
|
timestamp: new Date(item.timestamp),
|
|
})),
|
|
);
|
|
} catch (error) {
|
|
console.error('Failed to parse upload history:', error);
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
// Save upload history to sessionStorage
|
|
useEffect(() => {
|
|
if (uploadHistory.length > 0) {
|
|
sessionStorage.setItem(UPLOAD_HISTORY_KEY, JSON.stringify(uploadHistory));
|
|
}
|
|
}, [uploadHistory]);
|
|
|
|
const validateStoredApiKey = async (keyToValidate: string) => {
|
|
setValidatingKey(true);
|
|
setApiKeyError('');
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE_URL}/api/info`, {
|
|
headers: {
|
|
'X-API-Key': keyToValidate,
|
|
},
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setApiKeyValidated(true);
|
|
if (data.keyInfo) {
|
|
setApiKeyInfo({
|
|
organizationSlug: data.keyInfo.organizationSlug || data.keyInfo.organizationId,
|
|
projectSlug: data.keyInfo.projectSlug || data.keyInfo.projectId,
|
|
});
|
|
} else {
|
|
setApiKeyInfo({
|
|
organizationSlug: 'Unknown',
|
|
projectSlug: 'Unknown',
|
|
});
|
|
}
|
|
} else {
|
|
localStorage.removeItem(API_KEY_STORAGE_KEY);
|
|
setApiKeyError('Stored API key is invalid or expired');
|
|
setApiKeyValidated(false);
|
|
}
|
|
} catch (error) {
|
|
setApiKeyError('Failed to validate stored API key');
|
|
setApiKeyValidated(false);
|
|
} finally {
|
|
setValidatingKey(false);
|
|
}
|
|
};
|
|
|
|
const validateApiKey = async () => {
|
|
if (!apiKey.trim()) {
|
|
setApiKeyError('Please enter an API key');
|
|
return;
|
|
}
|
|
|
|
setValidatingKey(true);
|
|
setApiKeyError('');
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE_URL}/api/info`, {
|
|
headers: {
|
|
'X-API-Key': apiKey,
|
|
},
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setApiKeyValidated(true);
|
|
localStorage.setItem(API_KEY_STORAGE_KEY, apiKey);
|
|
|
|
if (data.keyInfo) {
|
|
setApiKeyInfo({
|
|
organizationSlug: data.keyInfo.organizationSlug || data.keyInfo.organizationId,
|
|
projectSlug: data.keyInfo.projectSlug || data.keyInfo.projectId,
|
|
});
|
|
} else {
|
|
setApiKeyInfo({
|
|
organizationSlug: 'Unknown',
|
|
projectSlug: 'Unknown',
|
|
});
|
|
}
|
|
} else {
|
|
const error = await response.json();
|
|
setApiKeyError(error.message || 'Invalid API key');
|
|
setApiKeyValidated(false);
|
|
}
|
|
} catch (error) {
|
|
setApiKeyError('Failed to validate API key. Please check your connection.');
|
|
setApiKeyValidated(false);
|
|
} finally {
|
|
setValidatingKey(false);
|
|
}
|
|
};
|
|
|
|
const revokeApiKey = () => {
|
|
localStorage.removeItem(API_KEY_STORAGE_KEY);
|
|
setApiKey('');
|
|
setApiKeyValidated(false);
|
|
setApiKeyInfo(null);
|
|
setApiKeyError('');
|
|
};
|
|
|
|
const validateFile = (file: File): string | null => {
|
|
if (!ALLOWED_FILE_TYPES.includes(file.type)) {
|
|
return `Invalid file type. Allowed: PNG, JPEG, JPG, WebP`;
|
|
}
|
|
|
|
if (file.size > MAX_FILE_SIZE) {
|
|
return `File too large. Maximum size: ${MAX_FILE_SIZE / (1024 * 1024)}MB`;
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const handleFileSelect = (file: File) => {
|
|
setValidationError('');
|
|
setUploadError('');
|
|
|
|
const error = validateFile(file);
|
|
if (error) {
|
|
setValidationError(error);
|
|
setSelectedFile(null);
|
|
setPreviewUrl(null);
|
|
return;
|
|
}
|
|
|
|
setSelectedFile(file);
|
|
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => {
|
|
setPreviewUrl(reader.result as string);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
};
|
|
|
|
const handleFileInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
handleFileSelect(file);
|
|
}
|
|
};
|
|
|
|
const handleDragEnter = (e: DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setDragActive(true);
|
|
};
|
|
|
|
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setDragActive(false);
|
|
};
|
|
|
|
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
};
|
|
|
|
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setDragActive(false);
|
|
|
|
const file = e.dataTransfer.files?.[0];
|
|
if (file) {
|
|
handleFileSelect(file);
|
|
}
|
|
};
|
|
|
|
const handleUpload = async () => {
|
|
if (!selectedFile) return;
|
|
|
|
setUploading(true);
|
|
setUploadError('');
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', selectedFile);
|
|
|
|
const response = await fetch(`${API_BASE_URL}/api/upload`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'X-API-Key': apiKey,
|
|
},
|
|
body: formData,
|
|
});
|
|
|
|
const result: UploadResponse = await response.json();
|
|
|
|
if (result.success && result.data) {
|
|
const endTime = Date.now();
|
|
const durationMs = endTime - startTime;
|
|
|
|
const historyItem: UploadHistoryItem = {
|
|
id: Date.now().toString(),
|
|
timestamp: new Date(),
|
|
filename: result.data.filename,
|
|
originalName: result.data.originalName,
|
|
url: result.data.url,
|
|
size: result.data.size,
|
|
contentType: result.data.contentType,
|
|
durationMs,
|
|
};
|
|
|
|
setUploadHistory((prev) => [historyItem, ...prev]);
|
|
|
|
setSelectedFile(null);
|
|
setPreviewUrl(null);
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
} else {
|
|
setUploadError(result.error || 'Upload failed');
|
|
}
|
|
} catch (error) {
|
|
setUploadError(error instanceof Error ? error.message : 'Failed to upload file');
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
};
|
|
|
|
const formatFileSize = (bytes: number): string => {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
};
|
|
|
|
const formatDuration = (ms: number): string => {
|
|
if (ms < 1000) return `${ms}ms`;
|
|
return `${(ms / 1000).toFixed(2)}s`;
|
|
};
|
|
|
|
const generateUploadCodeExamples = (item: UploadHistoryItem, key: string, baseUrl: string) => {
|
|
const localPath = `./${item.originalName}`;
|
|
|
|
return {
|
|
curl: `curl -X POST "${baseUrl}/api/upload" \\
|
|
-H "X-API-Key: ${key}" \\
|
|
-F "file=@${localPath}"`,
|
|
fetch: `const formData = new FormData();
|
|
formData.append('file', fileInput.files[0]);
|
|
|
|
fetch('${baseUrl}/api/upload', {
|
|
method: 'POST',
|
|
headers: {
|
|
'X-API-Key': '${key}'
|
|
},
|
|
body: formData
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => console.log(data))
|
|
.catch(error => console.error('Error:', error));`,
|
|
rest: `POST ${baseUrl}/api/upload
|
|
Headers:
|
|
X-API-Key: ${key}
|
|
Content-Type: multipart/form-data
|
|
|
|
Body (form-data):
|
|
file: @${localPath}`,
|
|
};
|
|
};
|
|
|
|
const handleCopyCode = (code: string) => {
|
|
navigator.clipboard.writeText(code);
|
|
setCodeCopied(true);
|
|
setTimeout(() => setCodeCopied(false), 2000);
|
|
};
|
|
|
|
return (
|
|
<div className="relative z-10 max-w-7xl mx-auto px-6 py-12 md:py-16 min-h-screen">
|
|
{apiKeyValidated && apiKeyInfo && (
|
|
<MinimizedApiKey
|
|
organizationSlug={apiKeyInfo.organizationSlug || 'Unknown'}
|
|
projectSlug={apiKeyInfo.projectSlug || 'Unknown'}
|
|
apiKey={apiKey}
|
|
onRevoke={revokeApiKey}
|
|
/>
|
|
)}
|
|
|
|
<header className="mb-8 md:mb-12">
|
|
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-3">
|
|
File Upload Workbench
|
|
</h1>
|
|
<p className="text-gray-400 text-base md:text-lg">
|
|
Developer tool for testing file upload API
|
|
</p>
|
|
</header>
|
|
|
|
{!apiKeyValidated && (
|
|
<section
|
|
className="mb-6 p-5 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl"
|
|
aria-label="API Key Validation"
|
|
>
|
|
<h2 className="text-lg font-semibold text-white mb-3">API Key</h2>
|
|
<div className="flex gap-3">
|
|
<div className="flex-1 relative">
|
|
<input
|
|
type={apiKeyVisible ? 'text' : 'password'}
|
|
value={apiKey}
|
|
onChange={(e) => setApiKey(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
validateApiKey();
|
|
}
|
|
}}
|
|
placeholder="Enter your API key"
|
|
className="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent pr-12"
|
|
aria-label="API key input"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setApiKeyVisible(!apiKeyVisible)}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white transition-colors"
|
|
aria-label={apiKeyVisible ? 'Hide API key' : 'Show API key'}
|
|
>
|
|
{apiKeyVisible ? (
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
|
/>
|
|
</svg>
|
|
) : (
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
|
/>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
|
/>
|
|
</svg>
|
|
)}
|
|
</button>
|
|
</div>
|
|
<button
|
|
onClick={validateApiKey}
|
|
disabled={validatingKey}
|
|
className="px-6 py-3 rounded-lg bg-amber-600 text-white font-semibold hover:bg-amber-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed focus:ring-2 focus:ring-amber-500"
|
|
>
|
|
{validatingKey ? 'Validating...' : 'Validate'}
|
|
</button>
|
|
</div>
|
|
|
|
{apiKeyError && (
|
|
<p className="mt-3 text-sm text-red-400" role="alert">
|
|
{apiKeyError}
|
|
</p>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
<section
|
|
className="mb-8 p-6 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl"
|
|
aria-label="File Upload"
|
|
>
|
|
<h2 className="text-lg font-semibold text-white mb-4">Upload File</h2>
|
|
|
|
<div
|
|
className={`relative border-2 border-dashed rounded-xl p-8 transition-all ${
|
|
dragActive
|
|
? 'border-amber-500 bg-amber-500/10'
|
|
: 'border-slate-700 hover:border-slate-600'
|
|
} ${!apiKeyValidated || uploading ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
|
onDragEnter={handleDragEnter}
|
|
onDragLeave={handleDragLeave}
|
|
onDragOver={handleDragOver}
|
|
onDrop={handleDrop}
|
|
onClick={() => {
|
|
if (apiKeyValidated && !uploading) {
|
|
fileInputRef.current?.click();
|
|
}
|
|
}}
|
|
>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/png,image/jpeg,image/jpg,image/webp"
|
|
onChange={handleFileInputChange}
|
|
disabled={!apiKeyValidated || uploading}
|
|
className="hidden"
|
|
aria-label="File input"
|
|
/>
|
|
|
|
{!selectedFile ? (
|
|
<div className="text-center">
|
|
<svg
|
|
className="w-12 h-12 mx-auto mb-4 text-gray-400"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
|
/>
|
|
</svg>
|
|
<p className="text-gray-300 mb-2">
|
|
Drag and drop your image here, or click to browse
|
|
</p>
|
|
<p className="text-sm text-gray-500">PNG, JPEG, JPG, WebP up to 5MB</p>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col items-center gap-4">
|
|
{previewUrl && (
|
|
<img
|
|
src={previewUrl}
|
|
alt="Preview"
|
|
className="max-h-64 max-w-full object-contain rounded-lg border border-slate-700"
|
|
/>
|
|
)}
|
|
<div className="text-center">
|
|
<p className="text-white font-medium">{selectedFile.name}</p>
|
|
<p className="text-sm text-gray-400">{formatFileSize(selectedFile.size)}</p>
|
|
</div>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setSelectedFile(null);
|
|
setPreviewUrl(null);
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
}}
|
|
className="text-sm text-red-400 hover:text-red-300 transition-colors"
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{validationError && (
|
|
<p className="mt-3 text-sm text-red-400" role="alert">
|
|
{validationError}
|
|
</p>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between gap-4 flex-wrap pt-4 mt-4 border-t border-slate-700/50">
|
|
<div className="text-sm text-gray-500">
|
|
{uploading ? 'Uploading...' : selectedFile ? 'Ready to upload' : 'No file selected'}
|
|
</div>
|
|
<button
|
|
onClick={handleUpload}
|
|
disabled={!apiKeyValidated || uploading || !selectedFile}
|
|
className="px-6 py-2.5 rounded-lg bg-gradient-to-r from-amber-600 to-orange-600 text-white font-semibold hover:from-amber-500 hover:to-orange-500 transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-amber-900/30 focus:ring-2 focus:ring-amber-500"
|
|
>
|
|
{uploading ? 'Uploading...' : 'Upload File'}
|
|
</button>
|
|
</div>
|
|
|
|
{uploadError && (
|
|
<p className="mt-3 text-sm text-red-400" role="alert">
|
|
{uploadError}
|
|
</p>
|
|
)}
|
|
</section>
|
|
|
|
{uploadHistory.length > 0 && (
|
|
<section className="space-y-6" aria-label="Upload History">
|
|
<h2 className="text-xl md:text-2xl font-bold text-white">Upload History</h2>
|
|
|
|
<div className="space-y-6">
|
|
{uploadHistory.map((item) => (
|
|
<div key={item.id} className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
{/* Column 1: Image Card */}
|
|
<div className="lg:col-span-1">
|
|
<div className="p-4 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-xl hover:border-slate-600 transition-all h-full">
|
|
<div
|
|
className="aspect-video bg-slate-800 rounded-lg mb-3 overflow-hidden cursor-pointer group relative"
|
|
onClick={() => setZoomedImageUrl(item.url)}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label="View full size image"
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
setZoomedImageUrl(item.url);
|
|
}
|
|
}}
|
|
>
|
|
<img
|
|
src={item.url}
|
|
alt={item.originalName}
|
|
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
|
/>
|
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
|
<svg
|
|
className="w-12 h-12 text-white"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<p className="text-white text-sm font-medium truncate" title={item.originalName}>
|
|
{item.originalName}
|
|
</p>
|
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
|
<span className="px-2 py-1 bg-slate-700/50 text-gray-300 rounded">
|
|
{formatFileSize(item.size)}
|
|
</span>
|
|
<span className="px-2 py-1 bg-green-600/20 text-green-400 rounded border border-green-600/30">
|
|
{formatDuration(item.durationMs)}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-gray-500">
|
|
{item.timestamp.toLocaleString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})}
|
|
</p>
|
|
<button
|
|
onClick={() => setZoomedImageUrl(item.url)}
|
|
className="mt-2 w-full text-center px-3 py-1.5 text-xs text-amber-400 hover:text-amber-300 border border-amber-600/30 hover:border-amber-500/50 rounded-lg transition-colors"
|
|
>
|
|
View Full Size
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Columns 2-3: API Code Examples Widget */}
|
|
<div className="lg:col-span-2">
|
|
<CodeExamplesWidget
|
|
codeExamples={generateUploadCodeExamples(item, apiKey, API_BASE_URL)}
|
|
onCopy={handleCopyCode}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* Image Zoom Modal */}
|
|
<ImageZoomModal imageUrl={zoomedImageUrl} onClose={() => setZoomedImageUrl(null)} />
|
|
</div>
|
|
);
|
|
}
|