update
This commit is contained in:
@@ -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
2
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
13
index.html
13
index.html
@@ -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>
|
||||||
28
server.js
28
server.js
@@ -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)) {
|
||||||
|
|||||||
20
src/App.jsx
20
src/App.jsx
@@ -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 && (
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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 >
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user