diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d0c5638..93f8d26 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "react-router-dom": "^7.9.6", + "react-spinners": "^0.17.0", "tailwindcss": "^4.1.12" }, "devDependencies": { @@ -3662,6 +3663,16 @@ "react-dom": ">=18" } }, + "node_modules/react-spinners": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-spinners/-/react-spinners-0.17.0.tgz", + "integrity": "sha512-L/8HTylaBmIWwQzIjMq+0vyaRXuoAevzWoD35wKpNTxxtYXWZp+xtgkfD7Y4WItuX0YvdxMPU79+7VhhmbmuTQ==", + "license": "MIT", + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index b797348..f43105b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "react-router-dom": "^7.9.6", + "react-spinners": "^0.17.0", "tailwindcss": "^4.1.12" }, "devDependencies": { @@ -30,4 +31,4 @@ "globals": "^16.3.0", "vite": "^7.1.2" } -} \ No newline at end of file +} diff --git a/frontend/src/components/AuthImage.jsx b/frontend/src/components/AuthImage.jsx index 211a727..62e43b1 100644 --- a/frontend/src/components/AuthImage.jsx +++ b/frontend/src/components/AuthImage.jsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' -export default function AuthImage({ src, token, alt, className, ...props }) { +export default function AuthImage({ src, token, alt, className, onLoad, ...props }) { const [imageSrc, setImageSrc] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(false) @@ -8,6 +8,7 @@ export default function AuthImage({ src, token, alt, className, ...props }) { useEffect(() => { let objectUrl = null let active = true + const controller = new AbortController() const fetchImage = async () => { setLoading(true) @@ -16,7 +17,8 @@ export default function AuthImage({ src, token, alt, className, ...props }) { const response = await fetch(src, { headers: { 'Authorization': token - } + }, + signal: controller.signal }) if (!response.ok) { @@ -28,6 +30,11 @@ export default function AuthImage({ src, token, alt, className, ...props }) { objectUrl = URL.createObjectURL(blob) setImageSrc(objectUrl) setLoading(false) + if (onLoad) { + setTimeout(() => { + onLoad() + }, 500) + } } } catch (err) { if (active) { @@ -46,6 +53,7 @@ export default function AuthImage({ src, token, alt, className, ...props }) { return () => { active = false + controller.abort() if (objectUrl) { URL.revokeObjectURL(objectUrl) } diff --git a/frontend/src/gallery/components/MediaUploadModal.jsx b/frontend/src/gallery/components/MediaUploadModal.jsx index 3e7e87c..0e57577 100644 --- a/frontend/src/gallery/components/MediaUploadModal.jsx +++ b/frontend/src/gallery/components/MediaUploadModal.jsx @@ -30,40 +30,68 @@ export default function MediaUploadModal({ open, onOpenChange, trigger, albumNam } const handleUpload = async () => { - // Placeholder for server-side logic hook - const performUpload = async (fileWrapper) => { - // TODO: Implement actual server upload here - // Example: - // const formData = new FormData() - // formData.append('file', fileWrapper.file) - // formData.append('albumId', albumId) - // await fetch('/api/upload', { method: 'POST', body: formData, ... }) + const pendingFiles = files.filter(f => f.status === 'pending') + if (pendingFiles.length === 0) return - const formData = new FormData() - formData.append('file', fileWrapper.file) - formData.append('albumId', albumId) - const response = await fetch(`${getServerUrl()}/api/media/uploadMedia`, { - method: 'POST', - body: formData, - headers: { - 'Authorization': getAccessToken(), - }, + setFiles(prev => prev.map(f => f.status === 'pending' ? { ...f, status: 'uploading' } : f)) + + const uploadFile = (fileWrapper) => { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + const formData = new FormData() + formData.append('file', fileWrapper.file) + formData.append('albumId', albumId) + + xhr.upload.addEventListener('progress', (event) => { + if (event.lengthComputable) { + const progress = Math.round((event.loaded / event.total) * 100) + setFiles(prev => prev.map(f => f.id === fileWrapper.id ? { ...f, progress } : f)) + } + }) + + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + const data = JSON.parse(xhr.responseText) + if (data.error) { + setFiles(prev => prev.map(f => f.id === fileWrapper.id ? { ...f, status: 'error' } : f)) + reject(data.error) + } else { + setFiles(prev => prev.map(f => f.id === fileWrapper.id ? { ...f, status: 'completed', progress: 100 } : f)) + resolve(data) + } + } catch (e) { + setFiles(prev => prev.map(f => f.id === fileWrapper.id ? { ...f, status: 'error' } : f)) + reject(e) + } + } else { + setFiles(prev => prev.map(f => f.id === fileWrapper.id ? { ...f, status: 'error' } : f)) + reject(new Error(xhr.statusText)) + } + }) + + xhr.addEventListener('error', () => { + setFiles(prev => prev.map(f => f.id === fileWrapper.id ? { ...f, status: 'error' } : f)) + reject(new Error('Network Error')) + }) + + xhr.open('POST', `${getServerUrl()}/api/media/uploadMedia`) + xhr.setRequestHeader('Authorization', getAccessToken()) + xhr.send(formData) }) - const data = await response.json() - if (data.error) { - showError(data.error) - } else { - setFiles(prev => prev.map(f => f.id === fileWrapper.id ? { ...f, status: 'completed' } : f)) - showSuccess('Media uploaded successfully') - } } - // Start uploads - files.forEach(fileWrapper => { - if (fileWrapper.status === 'pending') { - performUpload(fileWrapper) - } - }) + const results = await Promise.allSettled(pendingFiles.map(f => uploadFile(f))) + + const failedCount = results.filter(r => r.status === 'rejected').length + const successCount = results.filter(r => r.status === 'fulfilled').length + + if (failedCount > 0) { + showError(`${failedCount} file(s) failed to upload`) + } + if (successCount > 0) { + showSuccess(`${successCount} file(s) uploaded successfully`) + } } const formatFileSize = (bytes) => { diff --git a/frontend/src/viewer/components/ImageViewer.jsx b/frontend/src/viewer/components/ImageViewer.jsx index e520f02..b684f3e 100644 --- a/frontend/src/viewer/components/ImageViewer.jsx +++ b/frontend/src/viewer/components/ImageViewer.jsx @@ -1,8 +1,11 @@ import AuthImage from '../../components/AuthImage' import { getServerUrl } from '../../hooks/getConstants' +import { useState, useEffect } from 'react' +import { Loader2 } from 'lucide-react' export default function ImageViewer({ albumId, mediaId, token, title }) { const src = `${getServerUrl()}/api/media/${albumId}/${mediaId}` + const [loaded, setLoaded] = useState(false) return (
@@ -10,8 +13,23 @@ export default function ImageViewer({ albumId, mediaId, token, title }) { src={src} token={token} alt={title} - className="max-w-full max-h-full object-contain" + className={`max-w-full max-h-full object-contain absolute ${loaded ? 'block' : 'hidden'}`} + onLoad={() => { + setLoaded(true) + }} /> + {/* loading image */} + {!loaded && ( +
+ + +
+ )}
) }