This commit is contained in:
2025-12-13 20:46:48 +01:00
parent d874cec644
commit 9dc66b02fa
7 changed files with 279 additions and 26 deletions

View File

@@ -1,3 +1,4 @@
VITE_ADMIN_PIN=1234 VITE_ADMIN_PIN=1234
VITE_APP_TITLE=Dongho Kim VITE_APP_TITLE=Dongho Kim
VITE_APP_DESCRIPTION=My Photo Journey VITE_APP_DESCRIPTION=My Photo Journey
VITE_APP_TITLE=Dongho Kim Gallery

2
.gitignore vendored
View File

@@ -6,7 +6,7 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
docker-compose.yml docker-compose.yml*
node_modules node_modules
dist dist
dist-ssr dist-ssr

View File

@@ -1,13 +1,16 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <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" />
<title>photo-showcase</title> <title>photo-showcase</title>
</head> </head>
<body>
<body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</body> </body>
</html> </html>

View File

@@ -201,6 +201,34 @@ app.delete('/api/photos/:id', async (req, res) => {
// Serve Uploads // Serve Uploads
app.use('/uploads', express.static(UPLOAD_DIR)); app.use('/uploads', express.static(UPLOAD_DIR));
// Favicon Storage
const faviconStorage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, UPLOAD_DIR);
},
filename: (req, file, cb) => {
cb(null, 'favicon.png');
}
});
const uploadFavicon = multer({ storage: faviconStorage });
// Upload Favicon
app.post('/api/favicon', uploadFavicon.single('favicon'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
res.json({ success: true, url: '/uploads/favicon.png' });
});
// Serve Favicon (Dynamic)
app.get('/api/favicon', (req, res) => {
const faviconPath = path.join(UPLOAD_DIR, 'favicon.png');
if (fs.existsSync(faviconPath)) {
res.sendFile(faviconPath);
} else {
// Fallback to default
res.status(404).send('Not found');
}
});
// Serve Frontend (Production) // Serve Frontend (Production)
const DIST_DIR = path.join(__dirname, 'dist'); const DIST_DIR = path.join(__dirname, 'dist');
if (fs.existsSync(DIST_DIR)) { if (fs.existsSync(DIST_DIR)) {

View File

@@ -13,6 +13,10 @@ function App() {
const [activePhoto, setActivePhoto] = useState(null); const [activePhoto, setActivePhoto] = useState(null);
const [photos, setPhotos] = useState([]); const [photos, setPhotos] = useState([]);
useEffect(() => {
document.title = import.meta.env.VITE_APP_TITLE || 'Chronicle';
}, []);
// Theme State // Theme State
const [theme, setTheme] = useState(() => { const [theme, setTheme] = useState(() => {
return localStorage.getItem('theme_preference') || 'dark'; return localStorage.getItem('theme_preference') || 'dark';
@@ -58,7 +62,7 @@ function App() {
} }
return ( return (
<div className="app-container"> <div className="app-container" style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
{/* Theme Toggle */} {/* Theme Toggle */}
<div style={{ position: 'fixed', top: '20px', right: '20px', zIndex: 50 }}> <div style={{ position: 'fixed', top: '20px', right: '20px', zIndex: 50 }}>
<button <button
@@ -84,7 +88,21 @@ function App() {
</div> </div>
{/* Main Timeline View */} {/* Main Timeline View */}
<div style={{ flex: 1 }}>
<Timeline photos={photos} onSelectPhoto={setActivePhoto} /> <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 */} {/* Detail Overlay */}
{activePhoto && ( {activePhoto && (

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; 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 { uploadPhoto, fetchPhotos, deletePhotoById } from '../data/photos';
import exifr from 'exifr'; import exifr from 'exifr';
@@ -13,6 +13,7 @@ const Admin = ({ onBack, onUpdate }) => {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
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 showNotification = (type, message) => { const showNotification = (type, message) => {
setNotification({ 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) { if (!isAuthenticated) {
return ( return (
<div style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg-primary)' }}> <div style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg-primary)' }}>
@@ -368,6 +402,52 @@ const Admin = ({ onBack, onUpdate }) => {
</div> </div>
</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 */} {/* Delete Confirmation Modal */}
{deleteTarget && ( {deleteTarget && (
<div style={{ <div style={{

View File

@@ -3,6 +3,16 @@ import { X, Camera, Aperture, Clock, Gauge, ArrowLeft, MapPin } from 'lucide-rea
const PhotoDetail = ({ photo, onClose }) => { const PhotoDetail = ({ photo, onClose }) => {
const [loaded, setLoaded] = useState(false); 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(() => { useEffect(() => {
setLoaded(true); setLoaded(true);
@@ -29,19 +39,23 @@ const PhotoDetail = ({ photo, onClose }) => {
opacity: loaded ? 1 : 0, opacity: loaded ? 1 : 0,
transition: 'opacity 0.3s ease', transition: 'opacity 0.3s ease',
overflowY: 'auto' overflowY: 'auto'
}}> }}
onScroll={handleScroll}
>
{/* Navbar/Header for the modal */} {/* Navbar/Header for the modal */}
<div style={{ <div style={{
padding: '1.5rem', padding: '1rem 1.5rem',
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
position: 'absolute', position: 'sticky',
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
zIndex: 10, 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 <button
onClick={onClose} onClick={onClose}
@@ -58,23 +72,45 @@ const PhotoDetail = ({ photo, onClose }) => {
> >
<ArrowLeft size={20} /> <span style={{ fontSize: '0.9rem' }}>Back</span> <ArrowLeft size={20} /> <span style={{ fontSize: '0.9rem' }}>Back</span>
</button> </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>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}> <div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{/* Main Image Area */} {/* Main Image Area */}
<div style={{ <div className="photo-container" style={{
height: '60vh',
width: '100%', width: '100%',
position: 'relative', position: 'relative',
background: '#000' background: '#000',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}> }}>
<img <img
src={photo.url} src={photo.url}
alt={photo.title} alt={photo.title}
onClick={() => setIsFullScreen(true)}
style={{ style={{
width: '100%', maxWidth: '100%',
height: '100%', maxHeight: '100%',
objectFit: 'contain' objectFit: 'contain',
cursor: 'zoom-in',
transition: 'transform 0.2s ease'
}} }}
/> />
</div> </div>
@@ -138,15 +174,102 @@ const PhotoDetail = ({ photo, onClose }) => {
)} )}
</div> </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> </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>{` <style>{`
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp { @keyframes slideUp {
from { opacity: 0; transform: translateY(20px); } from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); } 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> `}</style>
</div> </div >
); );
}; };