Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 120 additions & 32 deletions src/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,44 +4,84 @@ import type { UploadResponse } from '../shared/types/api';
import { History } from './History';

type View = 'home' | 'history';
type MediaType = 'image' | 'video' | 'audio';

export const App = () => {
const [dragActive, setDragActive] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [mediaType, setMediaType] = useState<MediaType | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [serverInfo, setServerInfo] = useState<UploadResponse | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);

const imageInputRef = useRef<HTMLInputElement | null>(null);
const videoInputRef = useRef<HTMLInputElement | null>(null);
const audioInputRef = useRef<HTMLInputElement | null>(null);

const isMobile = useMemo(() => {
if (typeof navigator === 'undefined') return false;
return /Android|iPhone|iPad|iPod|iOS/i.test(navigator.userAgent);
}, []);

const allowedTypes = useMemo(
const imageTypes = useMemo(
() => ['image/png', 'image/jpeg', 'image/gif', 'image/webp'] as const,
[]
);

const videoTypes = useMemo(
() => ['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime'] as const,
[]
);

const audioTypes = useMemo(
() => ['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/webm', 'audio/mp4'] as const,
[]
);


const resetPreview = useCallback(() => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
setSelectedFile(null);
setMediaType(null);
setServerInfo(null);
}, [previewUrl]);

const getMediaType = useCallback(
(mimeType: string): MediaType | null => {
if (imageTypes.includes(mimeType as (typeof imageTypes)[number])) return 'image';
if (videoTypes.includes(mimeType as (typeof videoTypes)[number])) return 'video';
if (audioTypes.includes(mimeType as (typeof audioTypes)[number])) return 'audio';
return null;
},
[imageTypes, videoTypes, audioTypes]
);

const validateFile = useCallback(
(file: File): string | null => {
if (!allowedTypes.includes(file.type as (typeof allowedTypes)[number])) {
return 'Only PNG, JPEG, GIF, or WEBP images are allowed.';
(file: File, expectedType?: MediaType): string | null => {
const detectedType = getMediaType(file.type);

if (!detectedType) {
return 'Unsupported file type. Please use images (PNG, JPEG, GIF, WEBP), videos (MP4, WebM, OGG), or audio (MP3, WAV, OGG).';
}
if (file.size > 4 * 1024 * 1024) {
return 'Image must be under 4MB.';

if (expectedType && detectedType !== expectedType) {
return `Expected ${expectedType} file but got ${detectedType}.`;
}

const maxSize = detectedType === 'video' ? 10 * 1024 * 1024 :
detectedType === 'audio' ? 10 * 1024 * 1024 :
4 * 1024 * 1024;
const sizeLabel = detectedType === 'video' ? '10MB' :
detectedType === 'audio' ? '10MB' : '4MB';

if (file.size > maxSize) {
return `${detectedType.charAt(0).toUpperCase() + detectedType.slice(1)} must be under ${sizeLabel}.`;
}
return null;
},
[allowedTypes]
[getMediaType]
);

// Upload a single file
Expand Down Expand Up @@ -83,31 +123,33 @@ export const App = () => {
// History view will fetch on demand
} catch (err) {
console.error(err);
setMessage('Upload failed. Please try another image.');
setMessage('Upload failed. Please try another file.');
setServerInfo(null);
} finally {
setUploading(false);
}
}, []);

const handleFiles = useCallback(
(files: FileList | null) => {
(files: FileList | null, expectedType?: MediaType) => {
if (!files || files.length === 0) return;
const file = files[0]!;
const err = validateFile(file);
const err = validateFile(file, expectedType);
if (err) {
setMessage(err);
resetPreview();
return;
}
setMessage(null);
setSelectedFile(file);
const detectedType = getMediaType(file.type);
setMediaType(detectedType);
setServerInfo(null);
const url = URL.createObjectURL(file);
setPreviewUrl(url);
void uploadFile(file);
},
[validateFile, resetPreview, uploadFile]
[validateFile, resetPreview, uploadFile, getMediaType]
);

const onDrop = useCallback(
Expand Down Expand Up @@ -136,26 +178,35 @@ export const App = () => {
onDragLeave={!isMobile ? onDragLeave : undefined}
>
<div className="flex flex-col items-center justify-center p-6 gap-3 h-full">
{previewUrl ? (
{previewUrl && mediaType === 'image' && (
<img
src={previewUrl}
alt={selectedFile?.name || 'preview'}
className="max-h-full w-full object-contain rounded"
/>
) : (
)}
{previewUrl && mediaType === 'video' && (
<video
src={previewUrl}
controls
className="max-h-full w-full object-contain rounded"
/>
)}
{previewUrl && mediaType === 'audio' && (
<div className="flex flex-col items-center gap-4 w-full">
<div className="text-6xl">🎵</div>
<p className="text-sm text-gray-600 truncate max-w-full">{selectedFile?.name}</p>
<audio src={previewUrl} controls className="w-full max-w-md" />
</div>
)}
{!previewUrl && (
<div className="flex flex-col items-center gap-2 text-center">
<div className="text-5xl">🖼️</div>
<div className="text-5xl">📁</div>
{isMobile ? (
<p className="text-sm text-gray-700">Tap Choose Image to pick a photo.</p>
<p className="text-sm text-gray-700">Tap a button below to select a file.</p>
) : (
<p className="text-sm text-gray-700">
Drag & drop an image here, or
<button
className="ml-1 text-[#d93900] underline underline-offset-2"
onClick={() => inputRef.current?.click()}
>
browse
</button>
Drag & drop a file here, or choose below
</p>
)}
</div>
Expand All @@ -165,28 +216,65 @@ export const App = () => {
className={`flex flex-wrap gap-3 w-full justify-center ${previewUrl ? 'mt-auto' : ''}`}
>
<button
className="px-4 py-2 rounded bg-gray-100 text-gray-800 text-sm disabled:opacity-50"
onClick={() => inputRef.current?.click()}
className="px-4 py-2 rounded bg-blue-100 text-blue-800 text-sm disabled:opacity-50 hover:bg-blue-200 transition-colors"
onClick={() => imageInputRef.current?.click()}
disabled={uploading}
>
{uploading && mediaType === 'image' ? 'Uploading…' : '🖼️ Choose Image'}
</button>
<button
className="px-4 py-2 rounded bg-purple-100 text-purple-800 text-sm disabled:opacity-50 hover:bg-purple-200 transition-colors"
onClick={() => videoInputRef.current?.click()}
disabled={uploading}
>
{uploading && mediaType === 'video' ? 'Uploading…' : '🎬 Choose Video'}
</button>
<button
className="px-4 py-2 rounded bg-green-100 text-green-800 text-sm disabled:opacity-50 hover:bg-green-200 transition-colors"
onClick={() => audioInputRef.current?.click()}
disabled={uploading}
>
{uploading ? 'Uploading…' : 'Choose Image'}
{uploading && mediaType === 'audio' ? 'Uploading…' : '🎵 Choose Audio'}
</button>
{selectedFile ? (
{selectedFile && (
<button
className="px-4 py-2 rounded bg-gray-100 text-gray-800 text-sm disabled:opacity-50"
className="px-4 py-2 rounded bg-gray-100 text-gray-800 text-sm disabled:opacity-50 hover:bg-gray-200 transition-colors"
onClick={resetPreview}
disabled={uploading}
>
Clear
</button>
) : null}
)}
</div>
<input
ref={inputRef}
ref={imageInputRef}
type="file"
accept={imageTypes.join(',')}
className="hidden"
onChange={(e) => {
handleFiles(e.target.files, 'image');
e.target.value = '';
}}
/>
<input
ref={videoInputRef}
type="file"
accept={videoTypes.join(',')}
className="hidden"
onChange={(e) => {
handleFiles(e.target.files, 'video');
e.target.value = '';
}}
/>
<input
ref={audioInputRef}
type="file"
accept={allowedTypes.join(',')}
accept={audioTypes.join(',')}
className="hidden"
onChange={(e) => handleFiles(e.target.files)}
onChange={(e) => {
handleFiles(e.target.files, 'audio');
e.target.value = '';
}}
/>
</div>
</div>
Expand Down
97 changes: 94 additions & 3 deletions src/client/History.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import type { ListUploadsResponse, UploadedAsset } from '../shared/types/api';
import type { ListUploadsResponse, UploadedAsset, DeleteResponse } from '../shared/types/api';
import type React from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';

export const History = () => {
const [assets, setAssets] = useState<UploadedAsset[] | null>(null);
const [loading, setLoading] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const urlRefs = useRef<Record<string, HTMLDivElement | null>>({});
const [copiedId, setCopiedId] = useState<string | null>(null);
const copiedTimer = useRef<number | null>(null);
Expand Down Expand Up @@ -67,6 +71,48 @@ export const History = () => {
copiedTimer.current = window.setTimeout(() => setCopiedId(null), 1500);
}
};

const handleDeleteClick = (e: React.MouseEvent, asset: UploadedAsset) => {
e.stopPropagation(); // Prevent row click from firing
setConfirmingDeleteId(asset.mediaId);
setDeleteError(null);
};

const handleDeleteCancel = (e: React.MouseEvent) => {
e.stopPropagation();
setConfirmingDeleteId(null);
};

const handleDeleteConfirm = async (e: React.MouseEvent, asset: UploadedAsset) => {
e.stopPropagation();
setDeletingId(asset.mediaId);
setConfirmingDeleteId(null);
setDeleteError(null);

try {
const res = await fetch(`/api/delete-media?mediaUrl=${encodeURIComponent(asset.mediaUrl)}`, {
method: 'DELETE',
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || `HTTP ${res.status}`);
}
const data: DeleteResponse = await res.json();
if (data.type === 'delete') {
// Remove from local state
setAssets((prev) => prev?.filter((a) => a.mediaUrl !== asset.mediaUrl) ?? null);
if (selectedId === asset.mediaId) {
setSelectedId(null);
}
}
} catch (err) {
console.error('Failed to delete', err);
setDeleteError(asset.mediaId);
} finally {
setDeletingId(null);
}
};

return (
<section className="w-full max-w-screen-sm mx-auto mt-6">
<div className="flex items-center justify-between">
Expand Down Expand Up @@ -98,6 +144,7 @@ export const History = () => {
</th>
<th className="px-3 py-2 border-b border-gray-200">URL</th>
<th className="px-3 py-2 border-b border-gray-200">Date</th>
<th className="px-3 py-2 border-b border-gray-200 w-16"></th>
</tr>
</thead>
<tbody>
Expand All @@ -110,8 +157,16 @@ export const History = () => {
<td
className={`sticky left-0 ${selectedId === a.mediaId ? 'bg-yellow-50' : 'bg-white/95'} backdrop-blur px-3 py-2 border-b border-gray-100 max-w-3`}
>
<div className="h-12 w-12 overflow-hidden rounded bg-gray-100">
<img src={a.mediaUrl} alt={a.mediaId} className="h-12 w-12 object-cover" />
<div className="h-12 w-12 overflow-hidden rounded bg-gray-100 flex items-center justify-center">
{(a.mediaType === 'image' || a.mediaType === 'gif') && (
<img src={a.mediaUrl} alt={a.mediaId} className="h-12 w-12 object-cover" />
)}
{a.mediaType === 'video' && (
<video src={a.mediaUrl} className="h-12 w-12 object-cover" muted />
)}
{a.mediaType === 'audio' && (
<span className="text-2xl">🎵</span>
)}
</div>
</td>
<td className="px-3 py-2 border-b border-gray-100">
Expand All @@ -131,6 +186,42 @@ export const History = () => {
<td className="px-3 py-2 border-b border-gray-100 whitespace-nowrap text-gray-600">
{formatDate(a.date)}
</td>
<td className="px-3 py-2 border-b border-gray-100">
{confirmingDeleteId === a.mediaId ? (
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
<button
className="px-2 py-1 text-xs rounded bg-red-500 text-white hover:bg-red-600 transition-colors"
onClick={(e) => void handleDeleteConfirm(e, a)}
>
Yes
</button>
<button
className="px-2 py-1 text-xs rounded bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors"
onClick={handleDeleteCancel}
>
No
</button>
</div>
) : deleteError === a.mediaId ? (
<div className="flex gap-1 items-center">
<span className="text-xs text-red-600">Failed</span>
<button
className="px-2 py-1 text-xs rounded bg-red-100 text-red-700 hover:bg-red-200 transition-colors"
onClick={(e) => handleDeleteClick(e, a)}
>
Retry
</button>
</div>
) : (
<button
className="px-2 py-1 text-xs rounded bg-red-100 text-red-700 hover:bg-red-200 transition-colors disabled:opacity-50"
onClick={(e) => handleDeleteClick(e, a)}
disabled={deletingId === a.mediaId}
>
{deletingId === a.mediaId ? '…' : '🗑️'}
</button>
)}
</td>
</tr>
))}
</tbody>
Expand Down
Loading