493 lines
15 KiB
TypeScript
493 lines
15 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useRef, useCallback, DragEvent, ChangeEvent } from 'react';
|
|
import { useApiKey } from '@/components/shared/ApiKeyWidget/apikey-context';
|
|
import { Section } from '@/components/shared/Section';
|
|
import { CodeExamplesWidget } from '@/components/demo/CodeExamplesWidget';
|
|
import { SelectedFileCodePreview } from '@/components/demo/SelectedFileCodePreview';
|
|
import { ImageMetadataBar } from '@/components/shared/ImageMetadataBar';
|
|
import { ImageCard } from '@/components/shared/ImageCard';
|
|
import { calculateAspectRatio } from '@/utils/imageUtils';
|
|
|
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
|
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;
|
|
width?: number;
|
|
height?: number;
|
|
aspectRatio?: string;
|
|
downloadMs?: number;
|
|
}
|
|
|
|
export default function DemoUploadPage() {
|
|
// API Key from context
|
|
const { apiKey, apiKeyValidated, focus } = useApiKey();
|
|
|
|
// 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);
|
|
const [imageDimensions, setImageDimensions] = useState<{
|
|
width: number;
|
|
height: number;
|
|
aspectRatio: string;
|
|
} | null>(null);
|
|
|
|
// History State
|
|
const [uploadHistory, setUploadHistory] = useState<UploadHistoryItem[]>([]);
|
|
|
|
// Copy Feedback State
|
|
const [codeCopied, setCodeCopied] = useState(false);
|
|
|
|
// Refs
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// 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 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);
|
|
setImageDimensions(null);
|
|
return;
|
|
}
|
|
|
|
setSelectedFile(file);
|
|
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => {
|
|
const dataUrl = reader.result as string;
|
|
setPreviewUrl(dataUrl);
|
|
|
|
// Extract image dimensions
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
const aspectRatio = calculateAspectRatio(img.width, img.height);
|
|
setImageDimensions({
|
|
width: img.width,
|
|
height: img.height,
|
|
aspectRatio,
|
|
});
|
|
};
|
|
img.src = dataUrl;
|
|
};
|
|
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,
|
|
width: imageDimensions?.width,
|
|
height: imageDimensions?.height,
|
|
aspectRatio: imageDimensions?.aspectRatio,
|
|
};
|
|
|
|
setUploadHistory((prev) => [historyItem, ...prev]);
|
|
|
|
setSelectedFile(null);
|
|
setPreviewUrl(null);
|
|
setImageDimensions(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 handleDownloadMeasured = useCallback((itemId: string, downloadMs: number) => {
|
|
setUploadHistory((prev) =>
|
|
prev.map((item) => {
|
|
// Only update if this item doesn't have downloadMs yet (prevent re-measuring)
|
|
if (item.id === itemId && item.downloadMs === undefined) {
|
|
return { ...item, downloadMs };
|
|
}
|
|
return item;
|
|
})
|
|
);
|
|
}, []);
|
|
|
|
const generateUploadCodeExamples = (item: UploadHistoryItem, key: string, baseUrl: string) => {
|
|
const fileName = item.originalName;
|
|
|
|
return {
|
|
curl: `# Navigate to your images folder
|
|
cd /your/images/folder
|
|
|
|
# Upload the file
|
|
curl -X POST "${baseUrl}/api/upload" \\
|
|
-H "X-API-Key: ${key}" \\
|
|
-F "file=@${fileName}"`,
|
|
fetch: `// Set your images folder path
|
|
const imagePath = '/your/images/folder';
|
|
const fileName = '${fileName}';
|
|
|
|
// For Node.js with fs module:
|
|
const fs = require('fs');
|
|
const FormData = require('form-data');
|
|
const formData = new FormData();
|
|
formData.append('file', fs.createReadStream(\`\${imagePath}/\${fileName}\`));
|
|
|
|
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: `# From your images folder: /your/images/folder
|
|
|
|
POST ${baseUrl}/api/upload
|
|
Headers:
|
|
X-API-Key: ${key}
|
|
Content-Type: multipart/form-data
|
|
|
|
Body (form-data):
|
|
file: @${fileName}`,
|
|
};
|
|
};
|
|
|
|
const handleCopyCode = (code: string) => {
|
|
navigator.clipboard.writeText(code);
|
|
setCodeCopied(true);
|
|
setTimeout(() => setCodeCopied(false), 2000);
|
|
};
|
|
|
|
return (
|
|
<Section className="py-12 md:py-16 min-h-screen">
|
|
<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>
|
|
|
|
{/* API Key Required Notice - Only show when not validated */}
|
|
{!apiKeyValidated && (
|
|
<div className="mb-6 p-5 bg-amber-900/10 border border-amber-700/50 rounded-2xl">
|
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-white mb-1">API Key Required</h3>
|
|
<p className="text-sm text-gray-400">Enter your API key to use this workbench</p>
|
|
</div>
|
|
<button
|
|
onClick={focus}
|
|
className="px-5 py-2.5 bg-amber-600 hover:bg-amber-700 text-white font-medium rounded-lg transition-colors"
|
|
>
|
|
Enter API Key
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<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 mb-2">{selectedFile.name}</p>
|
|
{imageDimensions && (
|
|
<ImageMetadataBar
|
|
width={imageDimensions.width}
|
|
height={imageDimensions.height}
|
|
fileSize={selectedFile.size}
|
|
fileType={selectedFile.type}
|
|
showVisualIndicator={true}
|
|
/>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setSelectedFile(null);
|
|
setPreviewUrl(null);
|
|
setImageDimensions(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>
|
|
)}
|
|
|
|
{selectedFile && apiKeyValidated && !validationError && (
|
|
<div className="mt-6">
|
|
<SelectedFileCodePreview
|
|
file={selectedFile}
|
|
apiKey={apiKey}
|
|
apiBaseUrl={API_BASE_URL}
|
|
onCopy={handleCopyCode}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<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">
|
|
<ImageCard
|
|
imageUrl={item.url}
|
|
filename={item.originalName}
|
|
width={item.width}
|
|
height={item.height}
|
|
fileSize={item.size}
|
|
fileType={item.contentType}
|
|
timestamp={item.timestamp}
|
|
measureDownloadTime={true}
|
|
onDownloadMeasured={(downloadMs) => handleDownloadMeasured(item.id, downloadMs)}
|
|
/>
|
|
</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>
|
|
)}
|
|
</Section>
|
|
);
|
|
}
|