This commit is contained in:
2025-12-14 14:56:37 +00:00
parent aeaf2bfaa3
commit 582cd97784
10 changed files with 668 additions and 51 deletions

View File

@@ -1,4 +1,18 @@
# App Configuration
VITE_ADMIN_PIN=1234
VITE_APP_TITLE=Dongho Kim
VITE_APP_DESCRIPTION=My Photo Journey
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

View File

@@ -24,7 +24,7 @@ services:
- VITE_APP_TITLE=${VITE_APP_TITLE:-Chronicle}
- VITE_APP_DESCRIPTION=${VITE_APP_DESCRIPTION:-A visual journey through time}
volumes:
- ./uploads:/app/uploads
- /mnt/big/gallery:/app/uploads
- ./data:/app/data
restart: always

View File

@@ -5,6 +5,9 @@
<meta charset="UTF-8" />
<link rel="icon" href="/api/favicon" />
<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>
</head>

View File

@@ -0,0 +1,2 @@
#EXTM3U
#PLAYLIST:The Gallery

View File

@@ -3,6 +3,7 @@ import { Sun, Moon } from 'lucide-react';
import Timeline from './components/Timeline';
import PhotoDetail from './components/PhotoDetail';
import Admin from './components/Admin';
import MusicPlayer from './components/MusicPlayer';
import { fetchPhotos } from './data/photos';
function App() {
@@ -115,6 +116,14 @@ function App() {
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>
);
}

View File

@@ -14,6 +14,9 @@ const Admin = ({ onBack, onUpdate }) => {
const [notification, setNotification] = useState(null); // { type: 'success'|'error', message: '' }
const [deleteTarget, setDeleteTarget] = 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) => {
setNotification({ type, message });
@@ -59,11 +62,15 @@ const Admin = ({ onBack, onUpdate }) => {
const handleImageChange = async (e) => {
const file = e.target.files[0];
if (file) {
setIsProcessingImage(true);
setProcessingProgress(10); // Started
setImageFile(file);
// Extract Metadata
try {
const exifData = await exifr.parse(file);
setProcessingProgress(30); // EXIF extracted
if (exifData) {
const make = exifData.Make || '';
const model = exifData.Model || '';
@@ -97,6 +104,8 @@ const Admin = ({ onBack, onUpdate }) => {
}
}
setProcessingProgress(50); // Location fetched
setFormData(prev => ({
...prev,
date: date,
@@ -112,27 +121,37 @@ const Admin = ({ onBack, onUpdate }) => {
}
} catch (error) {
console.warn("Failed to extract EXIF data", error);
setProcessingProgress(30);
}
// Handle Image Preview (Supports DNG/Raw via embedded thumbnail)
let imageSource = null;
let isBlob = false;
setProcessingProgress(70); // Starting preview generation
let isDngFile = file.name.toLowerCase().endsWith('.dng');
try {
// 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);
if (thumb) {
imageSource = URL.createObjectURL(new Blob([thumb]));
imageSource = URL.createObjectURL(new Blob([thumb], { type: 'image/jpeg' }));
isBlob = true;
console.log('Extracted thumbnail from DNG');
} else {
console.warn('No thumbnail found in DNG file');
}
}
} catch (e) {
console.warn("Could not extract thumbnail from DNG", e);
}
// Fallback to standard FileReader if no thumbnail found or not a DNG
if (!imageSource) {
setProcessingProgress(80); // Thumbnail extracted or skipped
// For regular images (not DNG) or if we successfully extracted a thumbnail
if (!imageSource && !isDngFile) {
imageSource = await new Promise((resolve) => {
const reader = new FileReader();
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();
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 = () => {
console.log('Image loaded successfully, dimensions:', img.width, 'x', img.height);
try {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const maxWidth = 1200; // Limit width
@@ -157,12 +214,33 @@ const Admin = ({ onBack, onUpdate }) => {
}
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) {
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);
setUploadProgress(0);
const metadata = {
title: formData.title,
@@ -194,7 +273,9 @@ const Admin = ({ onBack, onUpdate }) => {
};
try {
await uploadPhoto(imageFile, metadata);
await uploadPhoto(imageFile, metadata, (progress) => {
setUploadProgress(progress);
});
showNotification('success', 'Photo added successfully!');
// Reset form
setFormData({
@@ -212,11 +293,13 @@ const Admin = ({ onBack, onUpdate }) => {
});
setPreview('');
setImageFile(null);
setUploadProgress(0);
refreshList();
if (onUpdate) onUpdate();
} catch (err) {
showNotification('error', 'Failed to upload.');
console.error(err);
setUploadProgress(0);
} finally {
setIsSubmitting(false);
}
@@ -304,10 +387,10 @@ const Admin = ({ onBack, onUpdate }) => {
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2rem', alignItems: 'start' }}>
{/* Upload Form */}
<div style={{ background: 'var(--bg-secondary)', padding: '1.5rem', borderRadius: '12px' }}>
<h2 style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '8px' }}>
<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', flexShrink: 0 }}>
<Upload size={20} /> Upload Photo
</h2>
@@ -322,23 +405,66 @@ const Admin = ({ onBack, onUpdate }) => {
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '0.9rem'
fontSize: '0.9rem',
flexShrink: 0
}}>
{notification.type === 'success' ? <CheckCircle size={18} /> : <AlertCircle size={18} />}
{notification.message}
</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' }}>
<input
type="file"
accept="image/*,.dng,.DNG"
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' }} />
) : (
<div style={{ padding: '2rem', color: 'var(--text-secondary)' }}>
@@ -371,19 +497,65 @@ const Admin = ({ onBack, onUpdate }) => {
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>
</form>
</div>
{/* List of uploaded photos */}
<div style={{ background: 'var(--bg-secondary)', padding: '1.5rem', borderRadius: '12px' }}>
<h2 style={{ marginBottom: '1.5rem' }}>Your Uploads</h2>
<div style={{ background: 'var(--bg-secondary)', padding: '1.5rem', borderRadius: '12px', display: 'flex', flexDirection: 'column', height: '100%' }}>
<h2 style={{ marginBottom: '1.5rem', flexShrink: 0 }}>Your Uploads</h2>
{localPhotos.length === 0 ? (
<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 => (
<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' }} />
@@ -515,6 +687,10 @@ const Admin = ({ onBack, onUpdate }) => {
.input-field:focus {
outline: 2px solid var(--accent);
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
</div>
);

View 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;

View File

@@ -46,7 +46,7 @@ const PhotoDetail = ({ photo, onClose }) => {
left: 0,
right: 0,
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',
borderBottom: showTitle ? '1px solid var(--border)' : 'none'
}}>
@@ -56,10 +56,11 @@ const PhotoDetail = ({ photo, onClose }) => {
display: 'flex',
alignItems: 'center',
gap: '8px',
color: 'white',
background: 'rgba(0,0,0,0.3)',
color: 'var(--text-primary)',
background: 'var(--bg-secondary)',
padding: '8px 16px',
borderRadius: '20px',
border: '1px solid var(--border)',
backdropFilter: 'blur(4px)'
}}
>
@@ -88,7 +89,7 @@ const PhotoDetail = ({ photo, onClose }) => {
<div className="photo-container" style={{
width: '100%',
position: 'relative',
background: '#000',
background: 'var(--bg-secondary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'

View File

@@ -14,10 +14,23 @@ const Timeline = ({ photos, onSelectPhoto }) => {
backdropFilter: 'blur(10px)',
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'}
</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'}
</p>
</header>

View File

@@ -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();
formData.append('image', file);
formData.append('metadata', JSON.stringify(metadata));
const res = await fetch(API_BASE, {
method: 'POST',
body: formData
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
// 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');
return await res.json();
xhr.addEventListener('load', () => {
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) => {