udpate
This commit is contained in:
18
.env.example
18
.env.example
@@ -1,4 +1,18 @@
|
|||||||
|
# App Configuration
|
||||||
VITE_ADMIN_PIN=1234
|
VITE_ADMIN_PIN=1234
|
||||||
VITE_APP_TITLE=Dongho Kim
|
|
||||||
VITE_APP_DESCRIPTION=My Photo Journey
|
|
||||||
VITE_APP_TITLE=Dongho Kim Gallery
|
VITE_APP_TITLE=Dongho Kim Gallery
|
||||||
|
VITE_APP_DESCRIPTION=My Photo Journey
|
||||||
|
|
||||||
|
# Navidrome Music Player Configuration
|
||||||
|
# Your Navidrome server URL (without trailing slash)
|
||||||
|
VITE_NAVIDROME_URL=https://navidrome.example.com
|
||||||
|
|
||||||
|
# Your Navidrome username
|
||||||
|
VITE_NAVIDROME_USERNAME=d-username
|
||||||
|
|
||||||
|
# Your Navidrome password (or use token authentication - see docs)
|
||||||
|
VITE_NAVIDROME_PASSWORD=your-password
|
||||||
|
|
||||||
|
# Playlist ID (find it in the URL when viewing a playlist in Navidrome)
|
||||||
|
# Example: https://navidrome.example.com/app/playlist/abc123 -> use "abc123"
|
||||||
|
VITE_NAVIDROME_PLAYLIST_ID=your-playlist-id
|
||||||
@@ -24,7 +24,7 @@ services:
|
|||||||
- VITE_APP_TITLE=${VITE_APP_TITLE:-Chronicle}
|
- VITE_APP_TITLE=${VITE_APP_TITLE:-Chronicle}
|
||||||
- VITE_APP_DESCRIPTION=${VITE_APP_DESCRIPTION:-A visual journey through time}
|
- VITE_APP_DESCRIPTION=${VITE_APP_DESCRIPTION:-A visual journey through time}
|
||||||
volumes:
|
volumes:
|
||||||
- ./uploads:/app/uploads
|
- /mnt/big/gallery:/app/uploads
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" href="/api/favicon" />
|
<link rel="icon" href="/api/favicon" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&display=swap" rel="stylesheet">
|
||||||
<title>photo-showcase</title>
|
<title>photo-showcase</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
2
public/playlists/The Gallery.m3u
Normal file
2
public/playlists/The Gallery.m3u
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
#EXTM3U
|
||||||
|
#PLAYLIST:The Gallery
|
||||||
@@ -3,6 +3,7 @@ import { Sun, Moon } from 'lucide-react';
|
|||||||
import Timeline from './components/Timeline';
|
import Timeline from './components/Timeline';
|
||||||
import PhotoDetail from './components/PhotoDetail';
|
import PhotoDetail from './components/PhotoDetail';
|
||||||
import Admin from './components/Admin';
|
import Admin from './components/Admin';
|
||||||
|
import MusicPlayer from './components/MusicPlayer';
|
||||||
import { fetchPhotos } from './data/photos';
|
import { fetchPhotos } from './data/photos';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -115,6 +116,14 @@ function App() {
|
|||||||
onClose={() => setActivePhoto(null)}
|
onClose={() => setActivePhoto(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Music Player */}
|
||||||
|
<MusicPlayer
|
||||||
|
navidromeUrl={import.meta.env.VITE_NAVIDROME_URL}
|
||||||
|
username={import.meta.env.VITE_NAVIDROME_USERNAME}
|
||||||
|
password={import.meta.env.VITE_NAVIDROME_PASSWORD}
|
||||||
|
playlistId={import.meta.env.VITE_NAVIDROME_PLAYLIST_ID}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ const Admin = ({ onBack, onUpdate }) => {
|
|||||||
const [notification, setNotification] = useState(null); // { type: 'success'|'error', message: '' }
|
const [notification, setNotification] = useState(null); // { type: 'success'|'error', message: '' }
|
||||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
const [faviconFile, setFaviconFile] = useState(null);
|
const [faviconFile, setFaviconFile] = useState(null);
|
||||||
|
const [isProcessingImage, setIsProcessingImage] = useState(false);
|
||||||
|
const [processingProgress, setProcessingProgress] = useState(0);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
|
||||||
const showNotification = (type, message) => {
|
const showNotification = (type, message) => {
|
||||||
setNotification({ type, message });
|
setNotification({ type, message });
|
||||||
@@ -59,11 +62,15 @@ const Admin = ({ onBack, onUpdate }) => {
|
|||||||
const handleImageChange = async (e) => {
|
const handleImageChange = async (e) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
|
setIsProcessingImage(true);
|
||||||
|
setProcessingProgress(10); // Started
|
||||||
setImageFile(file);
|
setImageFile(file);
|
||||||
|
|
||||||
// Extract Metadata
|
// Extract Metadata
|
||||||
try {
|
try {
|
||||||
const exifData = await exifr.parse(file);
|
const exifData = await exifr.parse(file);
|
||||||
|
setProcessingProgress(30); // EXIF extracted
|
||||||
|
|
||||||
if (exifData) {
|
if (exifData) {
|
||||||
const make = exifData.Make || '';
|
const make = exifData.Make || '';
|
||||||
const model = exifData.Model || '';
|
const model = exifData.Model || '';
|
||||||
@@ -97,6 +104,8 @@ const Admin = ({ onBack, onUpdate }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setProcessingProgress(50); // Location fetched
|
||||||
|
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
date: date,
|
date: date,
|
||||||
@@ -112,27 +121,37 @@ const Admin = ({ onBack, onUpdate }) => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Failed to extract EXIF data", error);
|
console.warn("Failed to extract EXIF data", error);
|
||||||
|
setProcessingProgress(30);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Image Preview (Supports DNG/Raw via embedded thumbnail)
|
// Handle Image Preview (Supports DNG/Raw via embedded thumbnail)
|
||||||
let imageSource = null;
|
let imageSource = null;
|
||||||
let isBlob = false;
|
let isBlob = false;
|
||||||
|
|
||||||
|
setProcessingProgress(70); // Starting preview generation
|
||||||
|
|
||||||
|
let isDngFile = file.name.toLowerCase().endsWith('.dng');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to extract thumbnail if it looks like a raw file
|
// Try to extract thumbnail if it looks like a raw file
|
||||||
if (file.name.toLowerCase().endsWith('.dng')) {
|
if (isDngFile) {
|
||||||
const thumb = await exifr.thumbnail(file);
|
const thumb = await exifr.thumbnail(file);
|
||||||
if (thumb) {
|
if (thumb) {
|
||||||
imageSource = URL.createObjectURL(new Blob([thumb]));
|
imageSource = URL.createObjectURL(new Blob([thumb], { type: 'image/jpeg' }));
|
||||||
isBlob = true;
|
isBlob = true;
|
||||||
|
console.log('Extracted thumbnail from DNG');
|
||||||
|
} else {
|
||||||
|
console.warn('No thumbnail found in DNG file');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Could not extract thumbnail from DNG", e);
|
console.warn("Could not extract thumbnail from DNG", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to standard FileReader if no thumbnail found or not a DNG
|
setProcessingProgress(80); // Thumbnail extracted or skipped
|
||||||
if (!imageSource) {
|
|
||||||
|
// For regular images (not DNG) or if we successfully extracted a thumbnail
|
||||||
|
if (!imageSource && !isDngFile) {
|
||||||
imageSource = await new Promise((resolve) => {
|
imageSource = await new Promise((resolve) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => resolve(e.target.result);
|
reader.onload = (e) => resolve(e.target.result);
|
||||||
@@ -140,9 +159,47 @@ const Admin = ({ onBack, onUpdate }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we still don't have an image source (DNG without thumbnail), show a placeholder
|
||||||
|
if (!imageSource) {
|
||||||
|
console.warn('Cannot generate preview for this file');
|
||||||
|
showNotification('error', 'Cannot preview DNG files without embedded thumbnails. The file will still upload.');
|
||||||
|
setPreview(''); // Clear preview
|
||||||
|
setIsProcessingImage(false);
|
||||||
|
setProcessingProgress(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProcessingProgress(90); // Image loaded
|
||||||
|
|
||||||
|
console.log('Image source type:', isBlob ? 'blob' : 'data URL');
|
||||||
|
console.log('Image source length:', imageSource?.length || 'N/A');
|
||||||
|
console.log('Image source preview:', imageSource?.substring(0, 100));
|
||||||
|
|
||||||
|
if (!imageSource) {
|
||||||
|
console.error('No image source available');
|
||||||
|
showNotification('error', 'Failed to read image file');
|
||||||
|
setIsProcessingImage(false);
|
||||||
|
setProcessingProgress(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.src = imageSource;
|
|
||||||
|
// Set up handlers BEFORE setting src to avoid race condition
|
||||||
|
img.onerror = (error) => {
|
||||||
|
console.error("Failed to load image for preview", error);
|
||||||
|
console.error("Image src was:", img.src?.substring(0, 100));
|
||||||
|
showNotification('error', 'Failed to process image preview');
|
||||||
|
setIsProcessingImage(false);
|
||||||
|
setProcessingProgress(0);
|
||||||
|
if (isBlob && imageSource) {
|
||||||
|
URL.revokeObjectURL(imageSource);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
|
console.log('Image loaded successfully, dimensions:', img.width, 'x', img.height);
|
||||||
|
try {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
const maxWidth = 1200; // Limit width
|
const maxWidth = 1200; // Limit width
|
||||||
@@ -157,12 +214,33 @@ const Admin = ({ onBack, onUpdate }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||||
setPreview(canvas.toDataURL('image/jpeg', 0.8)); // Compress quality
|
const previewDataUrl = canvas.toDataURL('image/jpeg', 0.8);
|
||||||
|
console.log('Preview created, size:', previewDataUrl.length);
|
||||||
|
setPreview(previewDataUrl);
|
||||||
|
|
||||||
if (isBlob) {
|
if (isBlob) {
|
||||||
URL.revokeObjectURL(imageSource);
|
URL.revokeObjectURL(imageSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setProcessingProgress(100); // Complete
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsProcessingImage(false);
|
||||||
|
setProcessingProgress(0);
|
||||||
|
}, 300); // Small delay to show 100%
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating preview:", error);
|
||||||
|
showNotification('error', 'Failed to create image preview');
|
||||||
|
setIsProcessingImage(false);
|
||||||
|
setProcessingProgress(0);
|
||||||
|
if (isBlob && imageSource) {
|
||||||
|
URL.revokeObjectURL(imageSource);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Set src AFTER handlers are attached
|
||||||
|
console.log('Setting image src...');
|
||||||
|
img.src = imageSource;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -176,6 +254,7 @@ const Admin = ({ onBack, onUpdate }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
setUploadProgress(0);
|
||||||
|
|
||||||
const metadata = {
|
const metadata = {
|
||||||
title: formData.title,
|
title: formData.title,
|
||||||
@@ -194,7 +273,9 @@ const Admin = ({ onBack, onUpdate }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await uploadPhoto(imageFile, metadata);
|
await uploadPhoto(imageFile, metadata, (progress) => {
|
||||||
|
setUploadProgress(progress);
|
||||||
|
});
|
||||||
showNotification('success', 'Photo added successfully!');
|
showNotification('success', 'Photo added successfully!');
|
||||||
// Reset form
|
// Reset form
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -212,11 +293,13 @@ const Admin = ({ onBack, onUpdate }) => {
|
|||||||
});
|
});
|
||||||
setPreview('');
|
setPreview('');
|
||||||
setImageFile(null);
|
setImageFile(null);
|
||||||
|
setUploadProgress(0);
|
||||||
refreshList();
|
refreshList();
|
||||||
if (onUpdate) onUpdate();
|
if (onUpdate) onUpdate();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showNotification('error', 'Failed to upload.');
|
showNotification('error', 'Failed to upload.');
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
setUploadProgress(0);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -304,10 +387,10 @@ const Admin = ({ onBack, onUpdate }) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2rem', alignItems: 'start' }}>
|
||||||
{/* Upload Form */}
|
{/* Upload Form */}
|
||||||
<div style={{ background: 'var(--bg-secondary)', padding: '1.5rem', borderRadius: '12px' }}>
|
<div style={{ background: 'var(--bg-secondary)', padding: '1.5rem', borderRadius: '12px', height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
<h2 style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<h2 style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '8px', flexShrink: 0 }}>
|
||||||
<Upload size={20} /> Upload Photo
|
<Upload size={20} /> Upload Photo
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
@@ -322,23 +405,66 @@ const Admin = ({ onBack, onUpdate }) => {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '8px',
|
gap: '8px',
|
||||||
fontSize: '0.9rem'
|
fontSize: '0.9rem',
|
||||||
|
flexShrink: 0
|
||||||
}}>
|
}}>
|
||||||
{notification.type === 'success' ? <CheckCircle size={18} /> : <AlertCircle size={18} />}
|
{notification.type === 'success' ? <CheckCircle size={18} /> : <AlertCircle size={18} />}
|
||||||
{notification.message}
|
{notification.message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem', flex: 1, overflowY: 'auto', paddingRight: '0.5rem' }}>
|
||||||
|
|
||||||
<div style={{ border: '2px dashed var(--border)', borderRadius: '8px', padding: '1rem', textAlign: 'center', cursor: 'pointer', position: 'relative', overflow: 'hidden' }}>
|
<div style={{ border: '2px dashed var(--border)', borderRadius: '8px', padding: '1rem', textAlign: 'center', cursor: 'pointer', position: 'relative', overflow: 'hidden' }}>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*,.dng,.DNG"
|
accept="image/*,.dng,.DNG"
|
||||||
onChange={handleImageChange}
|
onChange={handleImageChange}
|
||||||
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', opacity: 0, cursor: 'pointer' }}
|
disabled={isProcessingImage}
|
||||||
|
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', opacity: 0, cursor: isProcessingImage ? 'not-allowed' : 'pointer' }}
|
||||||
/>
|
/>
|
||||||
{preview ? (
|
{isProcessingImage ? (
|
||||||
|
<div style={{ padding: '2rem', color: 'var(--text-secondary)', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
||||||
|
<div style={{ position: 'relative', width: '80px', height: '80px' }}>
|
||||||
|
{/* Background circle */}
|
||||||
|
<svg style={{ transform: 'rotate(-90deg)', width: '100%', height: '100%' }}>
|
||||||
|
<circle
|
||||||
|
cx="40"
|
||||||
|
cy="40"
|
||||||
|
r="35"
|
||||||
|
stroke="var(--border)"
|
||||||
|
strokeWidth="6"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
{/* Progress circle */}
|
||||||
|
<circle
|
||||||
|
cx="40"
|
||||||
|
cy="40"
|
||||||
|
r="35"
|
||||||
|
stroke="var(--accent)"
|
||||||
|
strokeWidth="6"
|
||||||
|
fill="none"
|
||||||
|
strokeDasharray={`${2 * Math.PI * 35}`}
|
||||||
|
strokeDashoffset={`${2 * Math.PI * 35 * (1 - processingProgress / 100)}`}
|
||||||
|
style={{ transition: 'stroke-dashoffset 0.3s ease' }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/* Percentage text */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
fontSize: '1.2rem',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: 'var(--accent)'
|
||||||
|
}}>
|
||||||
|
{processingProgress}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>Processing image...</div>
|
||||||
|
</div>
|
||||||
|
) : preview ? (
|
||||||
<img src={preview} alt="Preview" style={{ width: '100%', maxHeight: '200px', objectFit: 'contain' }} />
|
<img src={preview} alt="Preview" style={{ width: '100%', maxHeight: '200px', objectFit: 'contain' }} />
|
||||||
) : (
|
) : (
|
||||||
<div style={{ padding: '2rem', color: 'var(--text-secondary)' }}>
|
<div style={{ padding: '2rem', color: 'var(--text-secondary)' }}>
|
||||||
@@ -371,19 +497,65 @@ const Admin = ({ onBack, onUpdate }) => {
|
|||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button type="submit" disabled={isSubmitting} style={{ background: isSubmitting ? 'var(--text-secondary)' : 'var(--accent)', color: 'white', padding: '1rem', borderRadius: '8px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px', fontWeight: 600, cursor: isSubmitting ? 'not-allowed' : 'pointer' }}>
|
|
||||||
{isSubmitting ? 'Saving...' : <><Save size={18} /> Save Photo</>}
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || isProcessingImage}
|
||||||
|
style={{
|
||||||
|
background: (isSubmitting || isProcessingImage) ? 'var(--text-secondary)' : 'var(--accent)',
|
||||||
|
color: 'white',
|
||||||
|
padding: '1rem',
|
||||||
|
borderRadius: '8px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: (isSubmitting || isProcessingImage) ? 'not-allowed' : 'pointer',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginTop: 'auto',
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{uploadProgress > 0 && uploadProgress < 100 && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
height: '100%',
|
||||||
|
width: `${uploadProgress}%`,
|
||||||
|
background: 'rgba(255, 255, 255, 0.2)',
|
||||||
|
transition: 'width 0.3s ease'
|
||||||
|
}}></div>
|
||||||
|
)}
|
||||||
|
<span style={{ position: 'relative', zIndex: 1, display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
{uploadProgress > 0 && uploadProgress < 100 ? (
|
||||||
|
`Uploading... ${uploadProgress}%`
|
||||||
|
) : isSubmitting ? (
|
||||||
|
'Saving...'
|
||||||
|
) : (
|
||||||
|
<><Save size={18} /> Save Photo</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* List of uploaded photos */}
|
{/* List of uploaded photos */}
|
||||||
<div style={{ background: 'var(--bg-secondary)', padding: '1.5rem', borderRadius: '12px' }}>
|
<div style={{ background: 'var(--bg-secondary)', padding: '1.5rem', borderRadius: '12px', display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
<h2 style={{ marginBottom: '1.5rem' }}>Your Uploads</h2>
|
<h2 style={{ marginBottom: '1.5rem', flexShrink: 0 }}>Your Uploads</h2>
|
||||||
{localPhotos.length === 0 ? (
|
{localPhotos.length === 0 ? (
|
||||||
<p style={{ color: 'var(--text-secondary)' }}>No local uploads yet.</p>
|
<p style={{ color: 'var(--text-secondary)' }}>No local uploads yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1rem',
|
||||||
|
overflowY: 'auto',
|
||||||
|
paddingRight: '0.5rem',
|
||||||
|
flex: 1
|
||||||
|
}}>
|
||||||
{localPhotos.map(photo => (
|
{localPhotos.map(photo => (
|
||||||
<div key={photo.id} style={{ display: 'flex', gap: '1rem', padding: '1rem', background: 'var(--bg-primary)', borderRadius: '8px' }}>
|
<div key={photo.id} style={{ display: 'flex', gap: '1rem', padding: '1rem', background: 'var(--bg-primary)', borderRadius: '8px' }}>
|
||||||
<img src={photo.url} alt={photo.title} style={{ width: '60px', height: '60px', objectFit: 'cover', borderRadius: '4px' }} />
|
<img src={photo.url} alt={photo.title} style={{ width: '60px', height: '60px', objectFit: 'cover', borderRadius: '4px' }} />
|
||||||
@@ -515,6 +687,10 @@ const Admin = ({ onBack, onUpdate }) => {
|
|||||||
.input-field:focus {
|
.input-field:focus {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
}
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
376
src/components/MusicPlayer.jsx
Normal file
376
src/components/MusicPlayer.jsx
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Play, Pause, SkipForward, Volume2, VolumeX } from 'lucide-react';
|
||||||
|
|
||||||
|
const MusicPlayer = ({ navidromeUrl, username, password, playlistId }) => {
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
const [currentTrackIndex, setCurrentTrackIndex] = useState(0);
|
||||||
|
const [tracks, setTracks] = useState([]);
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const [currentTrack, setCurrentTrack] = useState(null);
|
||||||
|
const [hasInteracted, setHasInteracted] = useState(false);
|
||||||
|
const [readyToPlay, setReadyToPlay] = useState(false);
|
||||||
|
const audioRef = useRef(null);
|
||||||
|
|
||||||
|
// Set volume to 30% on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.volume = 0.3;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch playlist from Navidrome API
|
||||||
|
useEffect(() => {
|
||||||
|
if (!navidromeUrl || !username || !password || !playlistId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchPlaylist = async () => {
|
||||||
|
try {
|
||||||
|
// URL encode credentials to handle special characters
|
||||||
|
const encodedUsername = encodeURIComponent(username);
|
||||||
|
const encodedPassword = encodeURIComponent(password);
|
||||||
|
const encodedPlaylistId = encodeURIComponent(playlistId);
|
||||||
|
|
||||||
|
// Construct Navidrome API URL - ensure f=json for JSON response
|
||||||
|
const apiUrl = `${navidromeUrl}/rest/getPlaylist.view?id=${encodedPlaylistId}&u=${encodedUsername}&p=${encodedPassword}&v=1.16.1&c=PhotoShowcase&f=json`;
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl);
|
||||||
|
const text = await response.text();
|
||||||
|
|
||||||
|
// Try to parse as JSON
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch (parseError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data['subsonic-response']?.status === 'ok') {
|
||||||
|
const playlist = data['subsonic-response'].playlist;
|
||||||
|
const entries = playlist.entry || [];
|
||||||
|
|
||||||
|
// Build track list with stream URLs (use encoded credentials)
|
||||||
|
const trackList = entries.map(entry => ({
|
||||||
|
id: entry.id,
|
||||||
|
title: entry.title,
|
||||||
|
artist: entry.artist,
|
||||||
|
streamUrl: `${navidromeUrl}/rest/stream.view?id=${encodeURIComponent(entry.id)}&u=${encodedUsername}&p=${encodedPassword}&v=1.16.1&c=PhotoShowcase`
|
||||||
|
}));
|
||||||
|
|
||||||
|
setTracks(trackList);
|
||||||
|
|
||||||
|
if (trackList.length > 0 && audioRef.current) {
|
||||||
|
setCurrentTrack(trackList[0]);
|
||||||
|
audioRef.current.src = trackList[0].streamUrl;
|
||||||
|
|
||||||
|
// Mark as ready to play
|
||||||
|
setReadyToPlay(true);
|
||||||
|
|
||||||
|
// Try autoplay, but don't worry if it fails
|
||||||
|
audioRef.current.play().catch(() => { });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchPlaylist();
|
||||||
|
}, [navidromeUrl, username, password, playlistId]);
|
||||||
|
|
||||||
|
// Auto-play on first user interaction
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasInteracted || !readyToPlay || !audioRef.current) return;
|
||||||
|
|
||||||
|
const startPlayback = () => {
|
||||||
|
if (!hasInteracted && audioRef.current && readyToPlay) {
|
||||||
|
audioRef.current.play().then(() => {
|
||||||
|
setHasInteracted(true);
|
||||||
|
}).catch(() => { });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for various user interactions
|
||||||
|
const events = ['click', 'scroll', 'touchstart', 'keydown', 'mousemove'];
|
||||||
|
|
||||||
|
events.forEach(event => {
|
||||||
|
document.addEventListener(event, startPlayback, { once: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
events.forEach(event => {
|
||||||
|
document.removeEventListener(event, startPlayback);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [hasInteracted, readyToPlay]);
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (!audioRef.current || tracks.length === 0) return;
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
} else {
|
||||||
|
audioRef.current.play();
|
||||||
|
}
|
||||||
|
setIsPlaying(!isPlaying);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMute = () => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
audioRef.current.muted = !isMuted;
|
||||||
|
setIsMuted(!isMuted);
|
||||||
|
};
|
||||||
|
|
||||||
|
const skipTrack = (autoPlay = false) => {
|
||||||
|
if (tracks.length === 0) return;
|
||||||
|
|
||||||
|
const nextIndex = (currentTrackIndex + 1) % tracks.length;
|
||||||
|
setCurrentTrackIndex(nextIndex);
|
||||||
|
setCurrentTrack(tracks[nextIndex]);
|
||||||
|
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.src = tracks[nextIndex].streamUrl;
|
||||||
|
// Play if currently playing OR if autoPlay is true (from track end)
|
||||||
|
if (isPlaying || autoPlay) {
|
||||||
|
audioRef.current.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTrackEnd = () => {
|
||||||
|
skipTrack(true); // Auto-play next track
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{`
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vinyl-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 30px;
|
||||||
|
right: 30px;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vinyl-button {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(var(--vinyl-bg), 0.15);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 2px solid rgba(var(--vinyl-border), 0.3);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 32px rgba(0, 0, 0, 0.1),
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
animation: ${props => props.isPlaying ? 'spin 3s linear infinite' : 'none'};
|
||||||
|
}
|
||||||
|
|
||||||
|
.vinyl-button:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
background: rgba(var(--vinyl-bg), 0.25);
|
||||||
|
border-color: rgba(59, 130, 246, 0.5);
|
||||||
|
box-shadow:
|
||||||
|
0 12px 40px rgba(59, 130, 246, 0.2),
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vinyl-center {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: rgba(var(--vinyl-bg), 0.4);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px rgba(var(--vinyl-border), 0.2),
|
||||||
|
inset 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vinyl-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
background: linear-gradient(135deg, rgba(59, 130, 246, 0.8) 0%, rgba(37, 99, 235, 0.9) 100%);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px rgba(59, 130, 246, 0.3),
|
||||||
|
inset 0 2px 8px rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vinyl-grooves {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: repeating-radial-gradient(
|
||||||
|
circle at center,
|
||||||
|
transparent 0px,
|
||||||
|
transparent 2px,
|
||||||
|
rgba(var(--vinyl-border), 0.1) 2px,
|
||||||
|
rgba(var(--vinyl-border), 0.1) 3px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-panel {
|
||||||
|
background: rgba(var(--vinyl-bg), 0.15);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(var(--vinyl-border), 0.2);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||||
|
opacity: ${props => props.isExpanded ? '1' : '0'};
|
||||||
|
transform: ${props => props.isExpanded ? 'translateY(0)' : 'translateY(10px)'};
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
pointer-events: ${props => props.isExpanded ? 'auto' : 'none'};
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(var(--vinyl-bg), 0.2);
|
||||||
|
border: 1px solid rgba(var(--vinyl-border), 0.3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
color: var(--text-primary);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.8);
|
||||||
|
border-color: rgba(59, 130, 246, 1);
|
||||||
|
transform: scale(1.1);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSS variables for theme adaptation */
|
||||||
|
:root {
|
||||||
|
--vinyl-bg: 0, 0, 0;
|
||||||
|
--vinyl-border: 255, 255, 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--vinyl-bg: 255, 255, 255;
|
||||||
|
--vinyl-border: 0, 0, 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.vinyl-container {
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vinyl-button {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vinyl-label {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vinyl-center {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<div className="vinyl-container">
|
||||||
|
{/* Controls Panel */}
|
||||||
|
<div
|
||||||
|
className="controls-panel"
|
||||||
|
style={{
|
||||||
|
opacity: isExpanded ? '1' : '0',
|
||||||
|
transform: isExpanded ? 'translateY(0)' : 'translateY(10px)',
|
||||||
|
pointerEvents: isExpanded ? 'auto' : 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="control-button"
|
||||||
|
onClick={skipTrack}
|
||||||
|
title="Next Track"
|
||||||
|
>
|
||||||
|
<SkipForward size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="control-button"
|
||||||
|
onClick={toggleMute}
|
||||||
|
title={isMuted ? 'Unmute' : 'Mute'}
|
||||||
|
>
|
||||||
|
{isMuted ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vinyl Button */}
|
||||||
|
<div
|
||||||
|
className="vinyl-button"
|
||||||
|
onClick={togglePlay}
|
||||||
|
onMouseEnter={() => setIsExpanded(true)}
|
||||||
|
onMouseLeave={() => setIsExpanded(false)}
|
||||||
|
style={{
|
||||||
|
animation: isPlaying ? 'spin 3s linear infinite' : 'none'
|
||||||
|
}}
|
||||||
|
title={isPlaying ? 'Pause' : 'Play'}
|
||||||
|
>
|
||||||
|
<div className="vinyl-grooves"></div>
|
||||||
|
<div className="vinyl-label">
|
||||||
|
{isPlaying ? (
|
||||||
|
<Pause size={20} color="white" fill="white" />
|
||||||
|
) : (
|
||||||
|
<Play size={20} color="white" fill="white" style={{ marginLeft: '2px' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="vinyl-center"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hidden Audio Element */}
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
onEnded={handleTrackEnd}
|
||||||
|
onPlay={() => setIsPlaying(true)}
|
||||||
|
onPause={() => setIsPlaying(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MusicPlayer;
|
||||||
@@ -46,7 +46,7 @@ const PhotoDetail = ({ photo, onClose }) => {
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
background: showTitle ? 'var(--bg-primary)' : 'linear-gradient(to bottom, rgba(0,0,0,0.6), transparent)',
|
background: showTitle ? 'var(--bg-primary)' : 'var(--header-bg)',
|
||||||
transition: 'background 0.3s ease',
|
transition: 'background 0.3s ease',
|
||||||
borderBottom: showTitle ? '1px solid var(--border)' : 'none'
|
borderBottom: showTitle ? '1px solid var(--border)' : 'none'
|
||||||
}}>
|
}}>
|
||||||
@@ -56,10 +56,11 @@ const PhotoDetail = ({ photo, onClose }) => {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '8px',
|
gap: '8px',
|
||||||
color: 'white',
|
color: 'var(--text-primary)',
|
||||||
background: 'rgba(0,0,0,0.3)',
|
background: 'var(--bg-secondary)',
|
||||||
padding: '8px 16px',
|
padding: '8px 16px',
|
||||||
borderRadius: '20px',
|
borderRadius: '20px',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
backdropFilter: 'blur(4px)'
|
backdropFilter: 'blur(4px)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -88,7 +89,7 @@ const PhotoDetail = ({ photo, onClose }) => {
|
|||||||
<div className="photo-container" style={{
|
<div className="photo-container" style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
background: '#000',
|
background: 'var(--bg-secondary)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center'
|
justifyContent: 'center'
|
||||||
|
|||||||
@@ -14,10 +14,23 @@ const Timeline = ({ photos, onSelectPhoto }) => {
|
|||||||
backdropFilter: 'blur(10px)',
|
backdropFilter: 'blur(10px)',
|
||||||
borderBottom: '1px solid var(--border)'
|
borderBottom: '1px solid var(--border)'
|
||||||
}}>
|
}}>
|
||||||
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, letterSpacing: '-0.02em' }}>
|
<h1 style={{
|
||||||
|
fontSize: '2.5rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
fontFamily: "'Playfair Display', serif",
|
||||||
|
marginBottom: '0.5rem'
|
||||||
|
}}>
|
||||||
{import.meta.env.VITE_APP_TITLE || 'Chronicle'}
|
{import.meta.env.VITE_APP_TITLE || 'Chronicle'}
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.875rem', marginTop: '0.5rem' }}>
|
<p style={{
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontSize: '1rem',
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
fontFamily: "'Playfair Display', serif",
|
||||||
|
fontStyle: 'italic',
|
||||||
|
fontWeight: 400
|
||||||
|
}}>
|
||||||
{import.meta.env.VITE_APP_DESCRIPTION || 'A visual journey through time'}
|
{import.meta.env.VITE_APP_DESCRIPTION || 'A visual journey through time'}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -14,18 +14,41 @@ export const fetchPhotos = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const uploadPhoto = async (file, metadata) => {
|
export const uploadPhoto = async (file, metadata, onProgress) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('image', file);
|
formData.append('image', file);
|
||||||
formData.append('metadata', JSON.stringify(metadata));
|
formData.append('metadata', JSON.stringify(metadata));
|
||||||
|
|
||||||
const res = await fetch(API_BASE, {
|
return new Promise((resolve, reject) => {
|
||||||
method: 'POST',
|
const xhr = new XMLHttpRequest();
|
||||||
body: formData
|
|
||||||
|
// Track upload progress
|
||||||
|
xhr.upload.addEventListener('progress', (e) => {
|
||||||
|
if (e.lengthComputable && onProgress) {
|
||||||
|
const percentComplete = Math.round((e.loaded / e.total) * 100);
|
||||||
|
onProgress(percentComplete);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) throw new Error('Upload failed');
|
xhr.addEventListener('load', () => {
|
||||||
return await res.json();
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(xhr.responseText));
|
||||||
|
} catch (e) {
|
||||||
|
resolve(xhr.responseText);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reject(new Error('Upload failed'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('error', () => {
|
||||||
|
reject(new Error('Upload failed'));
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.open('POST', API_BASE);
|
||||||
|
xhr.send(formData);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deletePhotoById = async (id) => {
|
export const deletePhotoById = async (id) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user