update
This commit is contained in:
22
src/App.jsx
22
src/App.jsx
@@ -13,6 +13,10 @@ function App() {
|
||||
const [activePhoto, setActivePhoto] = useState(null);
|
||||
const [photos, setPhotos] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = import.meta.env.VITE_APP_TITLE || 'Chronicle';
|
||||
}, []);
|
||||
|
||||
// Theme State
|
||||
const [theme, setTheme] = useState(() => {
|
||||
return localStorage.getItem('theme_preference') || 'dark';
|
||||
@@ -58,7 +62,7 @@ function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<div className="app-container" style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Theme Toggle */}
|
||||
<div style={{ position: 'fixed', top: '20px', right: '20px', zIndex: 50 }}>
|
||||
<button
|
||||
@@ -84,7 +88,21 @@ function App() {
|
||||
</div>
|
||||
|
||||
{/* Main Timeline View */}
|
||||
<Timeline photos={photos} onSelectPhoto={setActivePhoto} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Timeline photos={photos} onSelectPhoto={setActivePhoto} />
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer style={{
|
||||
padding: '2rem',
|
||||
textAlign: 'center',
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: '0.9rem',
|
||||
borderTop: '1px solid var(--border)',
|
||||
marginTop: 'auto'
|
||||
}}>
|
||||
<p>© {new Date().getFullYear()} {import.meta.env.VITE_APP_TITLE || 'Chronicle'}. All rights reserved.</p>
|
||||
</footer>
|
||||
|
||||
{/* Detail Overlay */}
|
||||
{activePhoto && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Upload, X, Save, Trash2, ArrowLeft, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { Upload, X, Save, Trash2, ArrowLeft, CheckCircle, AlertCircle, Settings } from 'lucide-react';
|
||||
import { uploadPhoto, fetchPhotos, deletePhotoById } from '../data/photos';
|
||||
import exifr from 'exifr';
|
||||
|
||||
@@ -13,6 +13,7 @@ const Admin = ({ onBack, onUpdate }) => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [notification, setNotification] = useState(null); // { type: 'success'|'error', message: '' }
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
const [faviconFile, setFaviconFile] = useState(null);
|
||||
|
||||
const showNotification = (type, message) => {
|
||||
setNotification({ type, message });
|
||||
@@ -241,6 +242,39 @@ const Admin = ({ onBack, onUpdate }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFaviconUpload = async () => {
|
||||
if (!faviconFile) {
|
||||
showNotification('error', 'Please select a file');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('favicon', faviconFile);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/favicon', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
showNotification('success', 'Favicon updated! Refresh to see.');
|
||||
setFaviconFile(null);
|
||||
// Force reload favicon
|
||||
const link = document.querySelector("link[rel*='icon']") || document.createElement('link');
|
||||
link.type = 'image/png';
|
||||
link.rel = 'icon';
|
||||
link.href = `/api/favicon?t=${Date.now()}`;
|
||||
document.getElementsByTagName('head')[0].appendChild(link);
|
||||
} else {
|
||||
showNotification('error', 'Upload failed');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showNotification('error', 'Upload failed');
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg-primary)' }}>
|
||||
@@ -368,6 +402,52 @@ const Admin = ({ onBack, onUpdate }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Site Settings */}
|
||||
<div style={{ marginTop: '2rem', maxWidth: '800px', margin: '2rem auto 0', background: 'var(--bg-secondary)', padding: '1.5rem', borderRadius: '12px' }}>
|
||||
<h2 style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Settings size={20} /> Site Settings
|
||||
</h2>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 500 }}>Update Favicon</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".ico,.png,.jpg,.svg"
|
||||
onChange={(e) => setFaviconFile(e.target.files[0])}
|
||||
style={{
|
||||
padding: '0.5rem',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '6px',
|
||||
width: '100%',
|
||||
background: 'var(--bg-primary)',
|
||||
color: 'var(--text-primary)'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleFaviconUpload}
|
||||
disabled={!faviconFile}
|
||||
style={{
|
||||
marginTop: 'auto',
|
||||
padding: '0.6rem 1.2rem',
|
||||
background: faviconFile ? 'var(--accent)' : 'var(--border)',
|
||||
color: 'white',
|
||||
borderRadius: '6px',
|
||||
fontWeight: 600,
|
||||
cursor: faviconFile ? 'pointer' : 'not-allowed',
|
||||
height: 'fit-content',
|
||||
alignSelf: 'flex-end'
|
||||
}}
|
||||
>
|
||||
Update Favicon
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ marginTop: '0.5rem', fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
|
||||
Upload a .png or .ico file to change the browser tab icon.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{deleteTarget && (
|
||||
<div style={{
|
||||
|
||||
@@ -3,6 +3,16 @@ import { X, Camera, Aperture, Clock, Gauge, ArrowLeft, MapPin } from 'lucide-rea
|
||||
|
||||
const PhotoDetail = ({ photo, onClose }) => {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [isFullScreen, setIsFullScreen] = useState(false);
|
||||
const [showTitle, setShowTitle] = useState(false);
|
||||
|
||||
const handleScroll = (e) => {
|
||||
if (e.target.scrollTop > 300) {
|
||||
setShowTitle(true);
|
||||
} else {
|
||||
setShowTitle(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLoaded(true);
|
||||
@@ -29,19 +39,23 @@ const PhotoDetail = ({ photo, onClose }) => {
|
||||
opacity: loaded ? 1 : 0,
|
||||
transition: 'opacity 0.3s ease',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{/* Navbar/Header for the modal */}
|
||||
<div style={{
|
||||
padding: '1.5rem',
|
||||
padding: '1rem 1.5rem',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
position: 'absolute',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 10,
|
||||
background: 'linear-gradient(to bottom, rgba(0,0,0,0.6), transparent)'
|
||||
background: showTitle ? 'var(--bg-primary)' : 'linear-gradient(to bottom, rgba(0,0,0,0.6), transparent)',
|
||||
transition: 'background 0.3s ease',
|
||||
borderBottom: showTitle ? '1px solid var(--border)' : 'none'
|
||||
}}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -58,23 +72,45 @@ const PhotoDetail = ({ photo, onClose }) => {
|
||||
>
|
||||
<ArrowLeft size={20} /> <span style={{ fontSize: '0.9rem' }}>Back</span>
|
||||
</button>
|
||||
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
opacity: showTitle ? 1 : 0,
|
||||
transition: 'opacity 0.3s ease',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '1.1rem',
|
||||
pointerEvents: 'none',
|
||||
color: 'var(--text-primary)'
|
||||
}}>
|
||||
{photo.title}
|
||||
</div>
|
||||
|
||||
<div style={{ width: '80px' }}></div> {/* Spacer for balance */}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Main Image Area */}
|
||||
<div style={{
|
||||
height: '60vh',
|
||||
<div className="photo-container" style={{
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
background: '#000'
|
||||
background: '#000',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
|
||||
<img
|
||||
src={photo.url}
|
||||
alt={photo.title}
|
||||
onClick={() => setIsFullScreen(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain'
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
cursor: 'zoom-in',
|
||||
transition: 'transform 0.2s ease'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -138,15 +174,102 @@ const PhotoDetail = ({ photo, onClose }) => {
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer style={{
|
||||
padding: '2rem',
|
||||
textAlign: 'center',
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: '0.9rem',
|
||||
borderTop: '1px solid var(--border)',
|
||||
marginTop: 'auto',
|
||||
background: 'var(--bg-primary)'
|
||||
}}>
|
||||
<p>© {new Date().getFullYear()} {import.meta.env.VITE_APP_TITLE || 'Chronicle'}. All rights reserved.</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Full Screen Overlay */}
|
||||
{
|
||||
isFullScreen && (
|
||||
<div
|
||||
onClick={() => setIsFullScreen(false)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'black',
|
||||
zIndex: 200,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'zoom-out',
|
||||
animation: 'fadeIn 0.2s ease-out'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={photo.url}
|
||||
alt={photo.title}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
width: 'auto',
|
||||
height: 'auto'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '20px',
|
||||
right: '20px',
|
||||
background: 'rgba(255,255,255,0.2)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '50%',
|
||||
padding: '10px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<style>{`
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.photo-container {
|
||||
height: 60vh;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.photo-container {
|
||||
height: 85vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.photo-container {
|
||||
height: 92vh;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user