Compare commits
35 Commits
188a714372
...
27dca3df69
| Author | SHA1 | Date | |
|---|---|---|---|
| 27dca3df69 | |||
| 439844cd14 | |||
| 085481e104 | |||
| f9495f0c30 | |||
| 4655e9ab3b | |||
| 5efb9525d5 | |||
| 201601dc95 | |||
| 1072a5eb21 | |||
| c9df3e0e88 | |||
| 6ef687378d | |||
| ca9929faac | |||
| 0198fec43c | |||
| 901cfd7e1b | |||
| c7cad9da61 | |||
| 28a80b5bd7 | |||
| 00f8e00436 | |||
| 326d54c73f | |||
| 5c10952e39 | |||
| 2b826ed700 | |||
| d5ef77ad17 | |||
| 033b89f87d | |||
| e7427ff1d5 | |||
| fd13f65faa | |||
| 2c2011659a | |||
| 0922261c74 | |||
| d53108f1c9 | |||
| 80921563be | |||
| 6875a28e92 | |||
| 2db0c1b3eb | |||
| bce5ae9fac | |||
| a053cf2d71 | |||
| 08efaa722a | |||
| 2cdecd918e | |||
| 1e60524cfc | |||
| 75d1558508 |
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/main_logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>가후습 개인기록</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"recharts": "^3.7.0",
|
||||
"three": "^0.182.0"
|
||||
},
|
||||
@@ -3088,6 +3089,15 @@
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-swipeable": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-swipeable/-/react-swipeable-7.0.2.tgz",
|
||||
"integrity": "sha512-v1Qx1l+aC2fdxKa9aKJiaU/ZxmJ5o98RMoFwUqAAzVWUcxgfHFXDDruCKXhw6zIYXm6V64JiHgP9f6mlME5l8w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.3 || ^17 || ^18 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"recharts": "^3.7.0",
|
||||
"three": "^0.182.0"
|
||||
},
|
||||
|
||||
13
src/App.css
13
src/App.css
@@ -62,6 +62,7 @@
|
||||
@media (max-width: 768px) {
|
||||
.site-main {
|
||||
padding: 16px;
|
||||
padding-bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px) + 16px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,3 +492,15 @@
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Accessibility: Reduced Motion ──────────────────────────────────── */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Navbar from './components/Navbar';
|
||||
import BottomNav from './components/BottomNav';
|
||||
import PageHeader from './components/PageHeader';
|
||||
import Loading from './components/Loading';
|
||||
import { useIsMobile } from './hooks/useIsMobile';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Navbar />
|
||||
@@ -17,6 +21,7 @@ function App() {
|
||||
</React.Suspense>
|
||||
</main>
|
||||
</div>
|
||||
{isMobile && <BottomNav />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
167
src/components/BottomNav.css
Normal file
167
src/components/BottomNav.css
Normal file
@@ -0,0 +1,167 @@
|
||||
/* BottomNav — mobile bottom navigation */
|
||||
|
||||
.bottom-nav {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--bottom-nav-h);
|
||||
padding-bottom: var(--safe-area-bottom);
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--line);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
z-index: 300;
|
||||
align-items: stretch;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.bottom-nav {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
/* Primary nav items */
|
||||
.bottom-nav__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
gap: 3px;
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
font-family: var(--font-body);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
transition: color 0.18s var(--ease-out);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
outline: none;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.bottom-nav__item:hover,
|
||||
.bottom-nav__item.is-active,
|
||||
.bottom-nav__item--active {
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
.bottom-nav__item:hover .bottom-nav__icon,
|
||||
.bottom-nav__item.is-active .bottom-nav__icon,
|
||||
.bottom-nav__item--active .bottom-nav__icon {
|
||||
filter: drop-shadow(0 0 6px var(--neon-cyan-dim));
|
||||
}
|
||||
|
||||
/* Icon wrapper */
|
||||
.bottom-nav__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
transition: filter 0.18s var(--ease-out);
|
||||
}
|
||||
|
||||
.bottom-nav__icon svg,
|
||||
.bottom-nav__icon > * {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
/* Label */
|
||||
.bottom-nav__label {
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- More overlay backdrop ---- */
|
||||
.bottom-nav__more-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 298;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.22s var(--ease-out);
|
||||
}
|
||||
|
||||
.bottom-nav__more-overlay.is-open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* ---- More panel ---- */
|
||||
.bottom-nav__more-panel {
|
||||
position: fixed;
|
||||
bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom));
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 299;
|
||||
padding: 16px 12px 12px;
|
||||
background: var(--surface-raised);
|
||||
border-top: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.25s var(--ease-out);
|
||||
}
|
||||
|
||||
.bottom-nav__more-panel.is-open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* More panel item */
|
||||
.bottom-nav__more-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 12px 4px;
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
transition: color 0.18s var(--ease-out), border-color 0.18s var(--ease-out);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bottom-nav__more-item:hover,
|
||||
.bottom-nav__more-item.is-active {
|
||||
color: var(--neon-cyan);
|
||||
border-color: var(--neon-cyan-dim);
|
||||
}
|
||||
|
||||
.bottom-nav__more-item:hover .bottom-nav__icon,
|
||||
.bottom-nav__more-item.is-active .bottom-nav__icon {
|
||||
filter: drop-shadow(0 0 6px var(--neon-cyan-dim));
|
||||
}
|
||||
|
||||
/* Reduce motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bottom-nav__item,
|
||||
.bottom-nav__icon,
|
||||
.bottom-nav__more-overlay,
|
||||
.bottom-nav__more-panel,
|
||||
.bottom-nav__more-item {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
114
src/components/BottomNav.jsx
Normal file
114
src/components/BottomNav.jsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { navLinks } from '../routes';
|
||||
import './BottomNav.css';
|
||||
|
||||
const PRIMARY_PATHS = ['/', '/lotto', '/stock', '/travel'];
|
||||
|
||||
// Vertical dots (three circles) icon for "more"
|
||||
function MoreDotsIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 22 22"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="11" cy="4.5" r="1.8" />
|
||||
<circle cx="11" cy="11" r="1.8" />
|
||||
<circle cx="11" cy="17.5" r="1.8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const primaryLinks = navLinks.filter((link) =>
|
||||
PRIMARY_PATHS.includes(link.path)
|
||||
);
|
||||
// Preserve the order defined in PRIMARY_PATHS
|
||||
const orderedPrimaryLinks = PRIMARY_PATHS.map((p) =>
|
||||
primaryLinks.find((l) => l.path === p)
|
||||
).filter(Boolean);
|
||||
|
||||
const moreLinks = navLinks.filter(
|
||||
(link) => !PRIMARY_PATHS.includes(link.path)
|
||||
);
|
||||
|
||||
export default function BottomNav() {
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
const openMore = useCallback(() => setMoreOpen(true), []);
|
||||
const closeMore = useCallback(() => setMoreOpen(false), []);
|
||||
const toggleMore = useCallback(() => setMoreOpen((prev) => !prev), []);
|
||||
|
||||
// Highlight the "more" button when the current path belongs to moreLinks
|
||||
const isMoreActive =
|
||||
moreOpen || moreLinks.some((link) => location.pathname === link.path);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`bottom-nav__more-overlay${moreOpen ? ' is-open' : ''}`}
|
||||
onClick={closeMore}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* More panel */}
|
||||
<div
|
||||
className={`bottom-nav__more-panel${moreOpen ? ' is-open' : ''}`}
|
||||
role="menu"
|
||||
aria-label="더보기 메뉴"
|
||||
>
|
||||
{moreLinks.map((link) => (
|
||||
<NavLink
|
||||
key={link.id}
|
||||
to={link.path}
|
||||
className={({ isActive }) =>
|
||||
`bottom-nav__more-item${isActive ? ' is-active' : ''}`
|
||||
}
|
||||
onClick={closeMore}
|
||||
role="menuitem"
|
||||
>
|
||||
<span className="bottom-nav__icon">{link.icon}</span>
|
||||
<span className="bottom-nav__label">{link.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom nav bar */}
|
||||
<nav className="bottom-nav" aria-label="하단 내비게이션">
|
||||
{orderedPrimaryLinks.map((link) => (
|
||||
<NavLink
|
||||
key={link.id}
|
||||
to={link.path}
|
||||
end={link.path === '/'}
|
||||
className={({ isActive }) =>
|
||||
`bottom-nav__item${isActive ? ' is-active' : ''}`
|
||||
}
|
||||
>
|
||||
<span className="bottom-nav__icon">{link.icon}</span>
|
||||
<span className="bottom-nav__label">{link.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
{/* More button */}
|
||||
<button
|
||||
type="button"
|
||||
className={`bottom-nav__item${isMoreActive ? ' is-active' : ''}`}
|
||||
onClick={toggleMore}
|
||||
aria-expanded={moreOpen}
|
||||
aria-haspopup="menu"
|
||||
aria-label="더보기"
|
||||
>
|
||||
<span className="bottom-nav__icon">
|
||||
<MoreDotsIcon />
|
||||
</span>
|
||||
<span className="bottom-nav__label">더보기</span>
|
||||
</button>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
50
src/components/FAB.css
Normal file
50
src/components/FAB.css
Normal file
@@ -0,0 +1,50 @@
|
||||
/* FAB — Floating Action Button (mobile-only) */
|
||||
|
||||
.fab {
|
||||
display: none;
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom) + 16px);
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--grad-accent);
|
||||
border: none;
|
||||
color: #000;
|
||||
font-size: 24px;
|
||||
z-index: 250;
|
||||
box-shadow: 0 0 0 1px var(--neon-cyan-dim), 0 4px 16px rgba(0, 255, 255, 0.25);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: transform 0.15s var(--ease-out), box-shadow 0.15s var(--ease-out);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.fab {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.fab:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
.fab__icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Variant: positioned above a music mini-player */
|
||||
.fab--above-player {
|
||||
bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom) + 16px + 56px);
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fab {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
37
src/components/FAB.jsx
Normal file
37
src/components/FAB.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import './FAB.css';
|
||||
|
||||
const PlusIcon = () => (
|
||||
<svg
|
||||
className="fab__icon"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M12 5v14M5 12h14"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function FAB({ onClick, icon, label = '액션', className = '' }) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (!isMobile) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`fab ${className}`}
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
>
|
||||
{icon ?? <PlusIcon />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
125
src/components/MobileSheet.css
Normal file
125
src/components/MobileSheet.css
Normal file
@@ -0,0 +1,125 @@
|
||||
/* MobileSheet — bottom sheet modal */
|
||||
|
||||
/* Backdrop */
|
||||
.mobile-sheet__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 400;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s var(--ease-out);
|
||||
}
|
||||
|
||||
.mobile-sheet__backdrop.is-open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Sheet */
|
||||
.mobile-sheet {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 90vh;
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--line);
|
||||
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||||
z-index: 401;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
touch-action: none;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s var(--ease-out);
|
||||
}
|
||||
|
||||
.mobile-sheet.is-open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Snap variants */
|
||||
.mobile-sheet.snap-half {
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
/* Drag handle area */
|
||||
.mobile-sheet__handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 0 8px;
|
||||
cursor: grab;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-sheet__handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.mobile-sheet__handle-bar {
|
||||
display: block;
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
background: var(--text-muted);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.mobile-sheet__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-sheet__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
.mobile-sheet__close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: color 0.18s var(--ease-out);
|
||||
}
|
||||
|
||||
.mobile-sheet__close:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Scrollable body */
|
||||
.mobile-sheet__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
padding-bottom: calc(16px + var(--safe-area-bottom));
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mobile-sheet__backdrop,
|
||||
.mobile-sheet {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.mobile-sheet__close {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
113
src/components/MobileSheet.jsx
Normal file
113
src/components/MobileSheet.jsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import './MobileSheet.css';
|
||||
|
||||
export default function MobileSheet({ open, onClose, title, snap = 'full', children }) {
|
||||
const [dragY, setDragY] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const startYRef = useRef(null);
|
||||
const sheetRef = useRef(null);
|
||||
|
||||
// Lock body scroll when open
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Reset drag state on close
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setDragY(0);
|
||||
setIsDragging(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleHandleTouchStart = (e) => {
|
||||
startYRef.current = e.touches[0].clientY;
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleHandleTouchMove = (e) => {
|
||||
if (startYRef.current === null) return;
|
||||
const delta = e.touches[0].clientY - startYRef.current;
|
||||
if (delta < 0) return; // no drag up
|
||||
setDragY(delta);
|
||||
};
|
||||
|
||||
const handleHandleTouchEnd = () => {
|
||||
setIsDragging(false);
|
||||
if (dragY > 100) {
|
||||
setDragY(0);
|
||||
onClose?.();
|
||||
} else {
|
||||
setDragY(0);
|
||||
}
|
||||
startYRef.current = null;
|
||||
};
|
||||
|
||||
const sheetTransform = open
|
||||
? `translateY(${isDragging ? dragY : 0}px)`
|
||||
: 'translateY(100%)';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`mobile-sheet__backdrop ${open ? 'is-open' : ''}`}
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
ref={sheetRef}
|
||||
className={`mobile-sheet snap-${snap} ${open ? 'is-open' : ''}`}
|
||||
style={{
|
||||
transform: sheetTransform,
|
||||
transition: isDragging ? 'none' : undefined,
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div
|
||||
className="mobile-sheet__handle"
|
||||
onTouchStart={handleHandleTouchStart}
|
||||
onTouchMove={handleHandleTouchMove}
|
||||
onTouchEnd={handleHandleTouchEnd}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="mobile-sheet__handle-bar" />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mobile-sheet__header">
|
||||
<span className="mobile-sheet__title">{title}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-sheet__close"
|
||||
onClick={onClose}
|
||||
aria-label="닫기"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M3 3l12 12M15 3L3 15"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="mobile-sheet__body">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -334,26 +334,6 @@
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.sidebar.is-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 데스크톱: 토글 버튼 숨김 ────────────────────────────────────────── */
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.sidebar-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar__overlay {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,92 +1,58 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { navLinks } from '../routes.jsx';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import mainLogo from '../assets/main_logo.png';
|
||||
import './Navbar.css';
|
||||
|
||||
const Navbar = () => {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const closeMenu = () => setMenuOpen(false);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = menuOpen ? 'hidden' : '';
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [menuOpen]);
|
||||
// 모바일에서는 BottomNav가 대체하므로 사이드바 미렌더링
|
||||
if (isMobile) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 모바일 오버레이 */}
|
||||
<div
|
||||
className={`sidebar__overlay${menuOpen ? ' is-visible' : ''}`}
|
||||
onClick={closeMenu}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* 모바일 토글 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-toggle"
|
||||
onClick={() => setMenuOpen((prev) => !prev)}
|
||||
aria-label="메뉴 열기/닫기"
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
<span className={`sidebar-toggle__icon${menuOpen ? ' is-open' : ''}`}>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* 사이드바 본체 */}
|
||||
<aside className={`sidebar${menuOpen ? ' is-open' : ''}`}>
|
||||
{/* 브랜드 섹션 */}
|
||||
<div className="sidebar__brand">
|
||||
<img src={mainLogo} alt="Logo" className="sidebar__logo" />
|
||||
<div className="sidebar__brand-text">
|
||||
<p className="sidebar__brand-name">Jaeoh</p>
|
||||
<p className="sidebar__brand-sub">MANAGEMENT ROOM</p>
|
||||
</div>
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar__brand">
|
||||
<img src={mainLogo} alt="Logo" className="sidebar__logo" />
|
||||
<div className="sidebar__brand-text">
|
||||
<p className="sidebar__brand-name">Jaeoh</p>
|
||||
<p className="sidebar__brand-sub">MANAGEMENT ROOM</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="sidebar__divider" />
|
||||
|
||||
<nav className="sidebar__nav">
|
||||
<p className="sidebar__section-label">NAVIGATION</p>
|
||||
{navLinks.map((link) => (
|
||||
<NavLink
|
||||
key={link.id}
|
||||
to={link.path}
|
||||
className={({ isActive }) =>
|
||||
`sidebar__item${isActive ? ' is-active' : ''}`
|
||||
}
|
||||
style={{ '--item-accent': link.accent }}
|
||||
end={link.path === '/'}
|
||||
>
|
||||
<span className="sidebar__item-icon">{link.icon}</span>
|
||||
<span className="sidebar__item-label">{link.label}</span>
|
||||
<span className="sidebar__item-dot" />
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar__footer">
|
||||
<div className="sidebar__divider" />
|
||||
|
||||
{/* 네비게이션 */}
|
||||
<nav className="sidebar__nav">
|
||||
<p className="sidebar__section-label">NAVIGATION</p>
|
||||
{navLinks.map((link) => (
|
||||
<NavLink
|
||||
key={link.id}
|
||||
to={link.path}
|
||||
onClick={closeMenu}
|
||||
className={({ isActive }) =>
|
||||
`sidebar__item${isActive ? ' is-active' : ''}`
|
||||
}
|
||||
style={{ '--item-accent': link.accent }}
|
||||
end={link.path === '/'}
|
||||
>
|
||||
<span className="sidebar__item-icon">{link.icon}</span>
|
||||
<span className="sidebar__item-label">{link.label}</span>
|
||||
<span className="sidebar__item-dot" />
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* 사이드바 푸터 */}
|
||||
<div className="sidebar__footer">
|
||||
<div className="sidebar__divider" />
|
||||
<div className="sidebar__footer-content">
|
||||
<div className="sidebar__status">
|
||||
<span className="sidebar__status-dot" />
|
||||
<span className="sidebar__status-text">System Online</span>
|
||||
</div>
|
||||
<p className="sidebar__version">v2.0.0</p>
|
||||
<div className="sidebar__footer-content">
|
||||
<div className="sidebar__status">
|
||||
<span className="sidebar__status-dot" />
|
||||
<span className="sidebar__status-text">System Online</span>
|
||||
</div>
|
||||
<p className="sidebar__version">v2.0.0</p>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
86
src/components/PullToRefresh.css
Normal file
86
src/components/PullToRefresh.css
Normal file
@@ -0,0 +1,86 @@
|
||||
/* PullToRefresh — pull-down-to-refresh wrapper */
|
||||
|
||||
.pull-to-refresh {
|
||||
position: relative;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
/* Indicator circle */
|
||||
.pull-to-refresh__indicator {
|
||||
position: absolute;
|
||||
top: -48px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.18s var(--ease-out);
|
||||
z-index: 10;
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
.pull-to-refresh__indicator.is-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.pull-to-refresh__spinner {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--line);
|
||||
border-top-color: var(--neon-cyan);
|
||||
border-radius: 50%;
|
||||
animation: ptr-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ptr-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Arrow chevron */
|
||||
.pull-to-refresh__arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
transition: transform 0.2s var(--ease-out);
|
||||
}
|
||||
|
||||
.pull-to-refresh__arrow.is-ready {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Content area */
|
||||
.pull-to-refresh__content {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pull-to-refresh__spinner {
|
||||
animation: none;
|
||||
border-top-color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
.pull-to-refresh__arrow {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.pull-to-refresh__indicator {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.pull-to-refresh__content {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
99
src/components/PullToRefresh.jsx
Normal file
99
src/components/PullToRefresh.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useRef, useState, useCallback } from 'react';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import './PullToRefresh.css';
|
||||
|
||||
const THRESHOLD = 60;
|
||||
const MAX_PULL = 120;
|
||||
const RESISTANCE = 0.5;
|
||||
const CONTENT_SHIFT_FACTOR = 0.3;
|
||||
|
||||
export default function PullToRefresh({ onRefresh, children, className = '' }) {
|
||||
const isMobile = useIsMobile();
|
||||
const [pullY, setPullY] = useState(0);
|
||||
const [state, setState] = useState('idle'); // idle | pulling | ready | refreshing
|
||||
const startYRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
|
||||
const handleTouchStart = useCallback((e) => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
if (el.scrollTop > 0) return; // only trigger at top
|
||||
startYRef.current = e.touches[0].clientY;
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback((e) => {
|
||||
if (startYRef.current === null) return;
|
||||
const delta = e.touches[0].clientY - startYRef.current;
|
||||
if (delta <= 0) {
|
||||
setPullY(0);
|
||||
setState('idle');
|
||||
return;
|
||||
}
|
||||
const clamped = Math.min(delta * RESISTANCE, MAX_PULL);
|
||||
setPullY(clamped);
|
||||
setState(clamped >= THRESHOLD ? 'ready' : 'pulling');
|
||||
}, []);
|
||||
|
||||
const handleTouchEnd = useCallback(async () => {
|
||||
if (startYRef.current === null) return;
|
||||
startYRef.current = null;
|
||||
if (state === 'ready') {
|
||||
setState('refreshing');
|
||||
setPullY(THRESHOLD);
|
||||
try {
|
||||
await onRefresh?.();
|
||||
} finally {
|
||||
setState('idle');
|
||||
setPullY(0);
|
||||
}
|
||||
} else {
|
||||
setState('idle');
|
||||
setPullY(0);
|
||||
}
|
||||
}, [state, onRefresh]);
|
||||
|
||||
if (!isMobile) {
|
||||
return <div className={className}>{children}</div>;
|
||||
}
|
||||
|
||||
const indicatorVisible = state !== 'idle';
|
||||
const contentShift = pullY * CONTENT_SHIFT_FACTOR;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`pull-to-refresh ${className}`}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<div
|
||||
className={`pull-to-refresh__indicator ${indicatorVisible ? 'is-visible' : ''}`}
|
||||
style={{ transform: `translateY(${pullY}px)` }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{state === 'refreshing' ? (
|
||||
<span className="pull-to-refresh__spinner" />
|
||||
) : (
|
||||
<span className={`pull-to-refresh__arrow ${state === 'ready' ? 'is-ready' : ''}`}>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M9 3v10M4 8l5 5 5-5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="pull-to-refresh__content"
|
||||
style={{ transform: `translateY(${contentShift}px)`, transition: state === 'idle' ? 'transform 0.3s var(--ease-out)' : 'none' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
src/components/SwipeableView.css
Normal file
79
src/components/SwipeableView.css
Normal file
@@ -0,0 +1,79 @@
|
||||
/* SwipeableView — swipeable tab container */
|
||||
|
||||
.swipeable-view {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Tab bar */
|
||||
.swipeable-view__tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--line);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.swipeable-view__tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Individual tab button */
|
||||
.swipeable-view__tab {
|
||||
flex: 1;
|
||||
min-width: fit-content;
|
||||
padding: 8px 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-radius: calc(var(--radius-md) - 2px);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: color 0.18s var(--ease-out), background 0.18s var(--ease-out);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.swipeable-view__tab.is-active {
|
||||
background: var(--surface-raised);
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
/* Sliding track */
|
||||
.swipeable-view__track {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
transition: transform 0.3s var(--ease-out);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.swipeable-view__track.is-swiping {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Each panel */
|
||||
.swipeable-view__panel {
|
||||
flex: 0 0 100%;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.swipeable-view__track {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.swipeable-view__tab {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
92
src/components/SwipeableView.jsx
Normal file
92
src/components/SwipeableView.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useSwipeable } from 'react-swipeable';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import './SwipeableView.css';
|
||||
|
||||
export default function SwipeableView({
|
||||
tabs = [],
|
||||
activeIndex: controlledIndex,
|
||||
onTabChange,
|
||||
showTabs = true,
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
const [internalIndex, setInternalIndex] = useState(0);
|
||||
const [swipeOffset, setSwipeOffset] = useState(0);
|
||||
const [isSwiping, setIsSwiping] = useState(false);
|
||||
const trackRef = useRef(null);
|
||||
|
||||
const activeIndex = controlledIndex !== undefined ? controlledIndex : internalIndex;
|
||||
|
||||
const setIndex = (idx) => {
|
||||
const clamped = Math.max(0, Math.min(tabs.length - 1, idx));
|
||||
if (controlledIndex === undefined) setInternalIndex(clamped);
|
||||
onTabChange?.(clamped);
|
||||
};
|
||||
|
||||
const handlers = useSwipeable({
|
||||
onSwiping: ({ deltaX }) => {
|
||||
if (!isMobile) return;
|
||||
setIsSwiping(true);
|
||||
setSwipeOffset(deltaX);
|
||||
},
|
||||
onSwipedLeft: ({ deltaX }) => {
|
||||
if (!isMobile) return;
|
||||
setIsSwiping(false);
|
||||
setSwipeOffset(0);
|
||||
if (Math.abs(deltaX) > 30) setIndex(activeIndex + 1);
|
||||
},
|
||||
onSwipedRight: ({ deltaX }) => {
|
||||
if (!isMobile) return;
|
||||
setIsSwiping(false);
|
||||
setSwipeOffset(0);
|
||||
if (Math.abs(deltaX) > 30) setIndex(activeIndex - 1);
|
||||
},
|
||||
onTouchEndOrOnMouseUp: () => {
|
||||
setIsSwiping(false);
|
||||
setSwipeOffset(0);
|
||||
},
|
||||
trackMouse: false,
|
||||
trackTouch: true,
|
||||
delta: 30,
|
||||
preventScrollOnSwipe: false,
|
||||
});
|
||||
|
||||
const trackTranslate = -activeIndex * 100 + (isSwiping ? (swipeOffset / (trackRef.current?.offsetWidth || 1)) * 100 : 0);
|
||||
|
||||
return (
|
||||
<div className="swipeable-view">
|
||||
{showTabs && (
|
||||
<div className="swipeable-view__tabs" role="tablist">
|
||||
{tabs.map((tab, i) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
role="tab"
|
||||
aria-selected={i === activeIndex}
|
||||
className={`swipeable-view__tab ${i === activeIndex ? 'is-active' : ''}`}
|
||||
onClick={() => setIndex(i)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
{...(isMobile ? handlers : {})}
|
||||
ref={trackRef}
|
||||
className={`swipeable-view__track ${isSwiping ? 'is-swiping' : ''}`}
|
||||
style={{ transform: `translateX(${trackTranslate}%)` }}
|
||||
>
|
||||
{tabs.map((tab, i) => (
|
||||
<div
|
||||
key={tab.key}
|
||||
role="tabpanel"
|
||||
aria-hidden={i !== activeIndex}
|
||||
className="swipeable-view__panel"
|
||||
>
|
||||
{tab.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
src/hooks/useIsMobile.js
Normal file
18
src/hooks/useIsMobile.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = useState(
|
||||
() => window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`);
|
||||
const handler = (e) => setIsMobile(e.matches);
|
||||
mql.addEventListener('change', handler);
|
||||
return () => mql.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
@@ -72,6 +72,8 @@
|
||||
/* ── Layout ──────────────────────────────────────────────────────── */
|
||||
--sidebar-w: 240px;
|
||||
--topbar-h: 56px;
|
||||
--bottom-nav-h: 64px;
|
||||
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
||||
|
||||
/* ── Typography ──────────────────────────────────────────────────── */
|
||||
--font-display: 'Space Grotesk', 'Noto Sans KR', system-ui, serif;
|
||||
@@ -113,6 +115,10 @@ html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
@@ -240,5 +246,6 @@ select option {
|
||||
body {
|
||||
overflow: auto;
|
||||
background-attachment: scroll;
|
||||
padding-bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,4 +385,16 @@
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* 명령 입력 하단 고정 */
|
||||
.ao-cmd-form {
|
||||
position: fixed;
|
||||
bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px));
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-secondary, #12122a);
|
||||
border-top: 1px solid #2a2a4a;
|
||||
z-index: 200;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useAgentManager } from './hooks/useAgentManager';
|
||||
import { useOfficeCanvas } from './hooks/useOfficeCanvas';
|
||||
import AgentColumn from './components/AgentColumn';
|
||||
import CommandColumn from './components/CommandColumn';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import MobileSheet from '../../components/MobileSheet';
|
||||
import './AgentOffice.css';
|
||||
|
||||
const AGENT_META = {
|
||||
@@ -16,12 +18,17 @@ const AGENT_IDS = ['stock', 'music', 'blog', 'realestate'];
|
||||
|
||||
export function Component() {
|
||||
const canvasContainerRef = useRef(null);
|
||||
const isMobile = useIsMobile();
|
||||
const [agentDetailSheet, setAgentDetailSheet] = useState(null); // agentId or null
|
||||
|
||||
const { agents, pendingTasks, connected, notifications, sendCommand, sendApproval, clearNotifications } = useAgentManager();
|
||||
|
||||
const handleAgentClick = useCallback((agentId) => {
|
||||
clearNotifications(agentId);
|
||||
}, [clearNotifications]);
|
||||
if (isMobile) {
|
||||
setAgentDetailSheet(agentId);
|
||||
}
|
||||
}, [clearNotifications, isMobile]);
|
||||
|
||||
const handleCeoClick = useCallback(() => {}, []);
|
||||
|
||||
@@ -79,6 +86,25 @@ export function Component() {
|
||||
<div className="ao-office-section">
|
||||
<div className="ao-canvas-container" ref={canvasContainerRef} />
|
||||
</div>
|
||||
|
||||
{/* 모바일: 에이전트 상세 바텀시트 */}
|
||||
<MobileSheet
|
||||
open={!!agentDetailSheet}
|
||||
onClose={() => setAgentDetailSheet(null)}
|
||||
title={agentDetailSheet ? (AGENT_META[agentDetailSheet]?.name ?? agentDetailSheet) : ''}
|
||||
>
|
||||
{agentDetailSheet && (
|
||||
<AgentColumn
|
||||
agentId={agentDetailSheet}
|
||||
meta={AGENT_META[agentDetailSheet]}
|
||||
agentState={agents[agentDetailSheet]}
|
||||
notification={notifications[agentDetailSheet] || 0}
|
||||
onCommand={sendCommand}
|
||||
onApproval={sendApproval}
|
||||
onClearNotification={() => clearNotifications(agentDetailSheet)}
|
||||
/>
|
||||
)}
|
||||
</MobileSheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,14 +125,30 @@
|
||||
.bm-empty { text-align: center; padding: 48px 20px; color: rgba(255,255,255,.25); font-size: 0.85rem; }
|
||||
|
||||
/* ── 모바일 ───────────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
@media (max-width: 768px) {
|
||||
.bm-tabs {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.bm-tabs > * {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.bm { padding: 16px 10px 60px; }
|
||||
.bm-header h1 { font-size: 1.2rem; }
|
||||
.bm-status { display: none; }
|
||||
.bm-tab { padding: 6px 10px; font-size: 0.8rem; }
|
||||
.bm-dash-cards { grid-template-columns: repeat(2, 1fr); }
|
||||
.bm-dash-cards { grid-template-columns: 1fr; }
|
||||
.bm-research-form { flex-direction: column; }
|
||||
.bm-analysis-card__scores { gap: 10px; }
|
||||
.bm-write-actions { flex-direction: column; }
|
||||
.bm-post-card__actions { flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bm-spinner { animation: none; }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import PullToRefresh from '../../components/PullToRefresh';
|
||||
import FAB from '../../components/FAB';
|
||||
import {
|
||||
getBlogMarketingStatus,
|
||||
startResearch,
|
||||
@@ -84,10 +86,14 @@ export default function BlogMarketing() {
|
||||
const [tab, setTab] = useState('dashboard');
|
||||
const [status, setStatus] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
getBlogMarketingStatus().then(setStatus).catch(() => {});
|
||||
const loadStatus = useCallback(() => {
|
||||
return getBlogMarketingStatus().then(setStatus).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadStatus();
|
||||
}, [loadStatus]);
|
||||
|
||||
const tabs = [
|
||||
{ id: 'dashboard', label: 'Dashboard' },
|
||||
{ id: 'research', label: 'Research' },
|
||||
@@ -96,6 +102,7 @@ export default function BlogMarketing() {
|
||||
];
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={loadStatus}>
|
||||
<div className="bm">
|
||||
<header className="bm-header">
|
||||
<h1>Blog Lab</h1>
|
||||
@@ -124,10 +131,13 @@ export default function BlogMarketing() {
|
||||
</nav>
|
||||
|
||||
{tab === 'dashboard' && <DashboardTab />}
|
||||
{tab === 'research' && <ResearchTab onGenerate={(id) => { setTab('write'); }} />}
|
||||
{tab === 'research' && <ResearchTab />}
|
||||
{tab === 'write' && <WriteTab />}
|
||||
{tab === 'posts' && <PostsTab />}
|
||||
|
||||
<FAB onClick={() => setTab('research')} label="키워드 분석" />
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
display: none;
|
||||
position: fixed;
|
||||
/* 사이드바 토글 버튼(top-left) 과 겹치지 않도록 오른쪽 하단 배치 */
|
||||
bottom: 24px;
|
||||
bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px) + 16px);
|
||||
right: 24px;
|
||||
top: auto;
|
||||
left: auto;
|
||||
@@ -451,9 +451,8 @@
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.blog-header,
|
||||
.blog-grid {
|
||||
@media (max-width: 768px) {
|
||||
.blog-header {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -469,10 +468,10 @@
|
||||
|
||||
.blog-list {
|
||||
display: none;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.blog-list.is-visible {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -490,6 +489,13 @@
|
||||
|
||||
.blog-list.is-visible .blog-category-filter {
|
||||
margin-bottom: 8px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.blog-list.is-visible .blog-category-filter > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.blog-list.is-visible .blog-pagination {
|
||||
@@ -498,22 +504,18 @@
|
||||
|
||||
.blog-article {
|
||||
width: 100%;
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.blog-header h1 {
|
||||
font-size: clamp(24px, 6vw, 32px);
|
||||
}
|
||||
|
||||
.blog-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.blog-list {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.blog-list__item-btn {
|
||||
padding: 14px;
|
||||
}
|
||||
@@ -526,10 +528,6 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.blog-article {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.blog-article__body h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
@@ -766,4 +764,19 @@
|
||||
align-self: stretch;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 태그/카테고리 필터 가로 스크롤 */
|
||||
.blog-categories,
|
||||
.blog-category-list {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.blog-categories > *,
|
||||
.blog-category-list > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
updateBlogPost,
|
||||
deleteBlogPost,
|
||||
} from '../../api';
|
||||
import PullToRefresh from '../../components/PullToRefresh';
|
||||
import FAB from '../../components/FAB';
|
||||
import './Blog.css';
|
||||
|
||||
// ── 마크다운 렌더러 ──────────────────────────────────────────────────────────
|
||||
@@ -359,9 +361,8 @@ const Blog = () => {
|
||||
const [editorPost, setEditorPost] = useState(null); // null=닫힘, {}=새글, post=수정
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
|
||||
// API 글 불러오기
|
||||
useEffect(() => {
|
||||
getBlogPostsApi()
|
||||
const fetchPosts = useCallback(() => {
|
||||
return getBlogPostsApi()
|
||||
.then((data) => {
|
||||
const posts = Array.isArray(data) ? data : (data?.posts ?? []);
|
||||
setApiPosts(posts.map((p) => ({ ...p, slug: `api-${p.id}` })));
|
||||
@@ -369,6 +370,11 @@ const Blog = () => {
|
||||
.catch(() => setApiError(true));
|
||||
}, []);
|
||||
|
||||
// API 글 불러오기
|
||||
useEffect(() => {
|
||||
fetchPosts();
|
||||
}, [fetchPosts]);
|
||||
|
||||
// 정적 + API 글 병합 (API 글이 앞에 표시)
|
||||
const allPosts = useMemo(() => {
|
||||
const combined = [...apiPosts, ...staticPosts];
|
||||
@@ -450,6 +456,7 @@ const Blog = () => {
|
||||
const closeEditor = useCallback(() => { setIsEditorOpen(false); setEditorPost(null); }, []);
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={fetchPosts}>
|
||||
<div className="blog">
|
||||
<header className="blog-header">
|
||||
<div>
|
||||
@@ -651,7 +658,10 @@ const Blog = () => {
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FAB onClick={openNewEditor} label="글 쓰기" />
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -80,3 +80,14 @@
|
||||
max-width: 400px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sword-stream {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.sword-stream__overlay {
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,7 +727,7 @@
|
||||
|
||||
/* ── Responsive ──────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 960px) {
|
||||
@media (max-width: 1024px) {
|
||||
.home-hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -803,15 +803,27 @@
|
||||
.home-profile__name {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.home-hero__stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.home-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.home-card {
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.home-posts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.home-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.home-hero__stats {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { navLinks } from '../../routes.jsx';
|
||||
import { getBlogPosts } from '../../data/blog';
|
||||
import { getTodos } from '../../api';
|
||||
import { getCurrentTheme } from '../../data/heroConfig';
|
||||
import myPhoto from '../../assets/myPhoto.jpg';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import SwipeableView from '../../components/SwipeableView';
|
||||
import PullToRefresh from '../../components/PullToRefresh';
|
||||
import './Home.css';
|
||||
|
||||
const TODO_COLUMNS = [
|
||||
@@ -17,22 +20,24 @@ const Home = () => {
|
||||
const posts = getBlogPosts().slice(0, 3);
|
||||
const highlights = navLinks.filter((link) => link.id !== 'home');
|
||||
const theme = getCurrentTheme();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const [todosByStatus, setTodosByStatus] = useState({ todo: [], in_progress: [], done: [] });
|
||||
|
||||
useEffect(() => {
|
||||
getTodos()
|
||||
.then((data) => {
|
||||
if (!Array.isArray(data)) return;
|
||||
setTodosByStatus({
|
||||
todo: data.filter((t) => t.status === 'todo'),
|
||||
in_progress: data.filter((t) => t.status === 'in_progress'),
|
||||
done: data.filter((t) => t.status === 'done'),
|
||||
});
|
||||
})
|
||||
.catch(() => { /* 조용히 실패 */ });
|
||||
const loadTodos = useCallback(async () => {
|
||||
const data = await getTodos();
|
||||
if (!Array.isArray(data)) return;
|
||||
setTodosByStatus({
|
||||
todo: data.filter((t) => t.status === 'todo'),
|
||||
in_progress: data.filter((t) => t.status === 'in_progress'),
|
||||
done: data.filter((t) => t.status === 'done'),
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadTodos().catch(() => { /* 조용히 실패 */ });
|
||||
}, [loadTodos]);
|
||||
|
||||
const totalTasks = todosByStatus.todo.length + todosByStatus.in_progress.length + todosByStatus.done.length;
|
||||
const doneTasks = todosByStatus.done.length;
|
||||
const inProgress = todosByStatus.in_progress.length;
|
||||
@@ -132,7 +137,79 @@ const Home = () => {
|
||||
<h2>TODO</h2>
|
||||
<p>계획 · 진행 중 · 완료 태스크를 한눈에 확인합니다.</p>
|
||||
</div>
|
||||
<TodoBoard todosByStatus={todosByStatus} />
|
||||
<PullToRefresh onRefresh={loadTodos}>
|
||||
{isMobile ? (
|
||||
<SwipeableView
|
||||
tabs={[
|
||||
{
|
||||
key: 'todo',
|
||||
label: 'TODO',
|
||||
content: (
|
||||
<div className="home-todo-col__body">
|
||||
{(todosByStatus.todo || []).length === 0 ? (
|
||||
<p className="home-todo-col__empty">태스크가 없습니다.</p>
|
||||
) : (todosByStatus.todo || []).map((todo) => (
|
||||
<div key={todo.id} className="home-todo-card">
|
||||
<p className="home-todo-card__title">{todo.title}</p>
|
||||
{todo.description && <p className="home-todo-card__desc">{todo.description}</p>}
|
||||
<p className="home-todo-card__date">
|
||||
{todo.updated_at
|
||||
? new Date(todo.updated_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'in_progress',
|
||||
label: '진행중',
|
||||
content: (
|
||||
<div className="home-todo-col__body">
|
||||
{(todosByStatus.in_progress || []).length === 0 ? (
|
||||
<p className="home-todo-col__empty">태스크가 없습니다.</p>
|
||||
) : (todosByStatus.in_progress || []).map((todo) => (
|
||||
<div key={todo.id} className="home-todo-card">
|
||||
<p className="home-todo-card__title">{todo.title}</p>
|
||||
{todo.description && <p className="home-todo-card__desc">{todo.description}</p>}
|
||||
<p className="home-todo-card__date">
|
||||
{todo.updated_at
|
||||
? new Date(todo.updated_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'done',
|
||||
label: '완료',
|
||||
content: (
|
||||
<div className="home-todo-col__body">
|
||||
{(todosByStatus.done || []).length === 0 ? (
|
||||
<p className="home-todo-col__empty">태스크가 없습니다.</p>
|
||||
) : (todosByStatus.done || []).map((todo) => (
|
||||
<div key={todo.id} className="home-todo-card">
|
||||
<p className="home-todo-card__title">{todo.title}</p>
|
||||
{todo.description && <p className="home-todo-card__desc">{todo.description}</p>}
|
||||
<p className="home-todo-card__date">
|
||||
{todo.updated_at
|
||||
? new Date(todo.updated_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<TodoBoard todosByStatus={todosByStatus} />
|
||||
)}
|
||||
</PullToRefresh>
|
||||
</section>
|
||||
|
||||
<section className="home-section">
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import BriefingTab from './tabs/BriefingTab';
|
||||
import AnalysisTab from './tabs/AnalysisTab';
|
||||
import PurchaseTab from './tabs/PurchaseTab';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import SwipeableView from '../../components/SwipeableView';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'briefing', label: '🗓 이번 주 브리핑' },
|
||||
@@ -11,22 +13,44 @@ const TABS = [
|
||||
|
||||
export default function Functions() {
|
||||
const [tab, setTab] = useState('briefing');
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const tabIndex = TABS.findIndex(t => t.id === tab);
|
||||
|
||||
const handleTabChange = useCallback((index) => {
|
||||
setTab(TABS[index].id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="lotto-functions">
|
||||
<nav className="lotto-tabs">
|
||||
{TABS.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={tab === t.id ? 'active' : ''}
|
||||
onClick={() => setTab(t.id)}
|
||||
>{t.label}</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="lotto-tab-body">
|
||||
{tab === 'briefing' && <BriefingTab />}
|
||||
{tab === 'analysis' && <AnalysisTab />}
|
||||
{tab === 'purchase' && <PurchaseTab />}
|
||||
</div>
|
||||
{isMobile ? (
|
||||
<SwipeableView
|
||||
tabs={TABS.map(t => ({
|
||||
key: t.id,
|
||||
label: t.label,
|
||||
content: t.id === 'briefing' ? <BriefingTab /> : t.id === 'analysis' ? <AnalysisTab /> : <PurchaseTab />,
|
||||
}))}
|
||||
activeIndex={tabIndex}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<nav className="lotto-tabs">
|
||||
{TABS.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={tab === t.id ? 'active' : ''}
|
||||
onClick={() => setTab(t.id)}
|
||||
>{t.label}</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="lotto-tab-body">
|
||||
{tab === 'briefing' && <BriefingTab />}
|
||||
{tab === 'analysis' && <AnalysisTab />}
|
||||
{tab === 'purchase' && <PurchaseTab />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1074,41 +1074,7 @@
|
||||
|
||||
/* ── 반응형 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.lotto-header {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.lotto-history__item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.lotto-analysis__row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.lotto-pick {
|
||||
grid-template-columns: 24px minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.lotto-report-top {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.lotto-purchase-list__head,
|
||||
.lotto-purchase-row {
|
||||
grid-template-columns: 56px 90px 90px minmax(0, 1fr) 100px;
|
||||
}
|
||||
|
||||
.lotto-purchase-list__head span:nth-child(4),
|
||||
.lotto-purchase-row span:nth-child(4) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@media (max-width: 480px) {
|
||||
.lotto-purchase-stats {
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -1157,6 +1123,34 @@
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.lotto-header {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.lotto-analysis__row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.lotto-pick {
|
||||
grid-template-columns: 24px minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.lotto-report-top {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.lotto-purchase-list__head,
|
||||
.lotto-purchase-row {
|
||||
grid-template-columns: 56px 90px 90px minmax(0, 1fr) 100px;
|
||||
}
|
||||
|
||||
.lotto-purchase-list__head span:nth-child(4),
|
||||
.lotto-purchase-row span:nth-child(4) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lotto-header h1 {
|
||||
font-size: clamp(24px, 6vw, 32px);
|
||||
}
|
||||
@@ -1181,9 +1175,9 @@
|
||||
}
|
||||
|
||||
.lotto-ball {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 14px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.lotto-meta__title {
|
||||
@@ -1191,6 +1185,7 @@
|
||||
}
|
||||
|
||||
.lotto-history__item {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 14px;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -1459,7 +1454,7 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@media (max-width: 480px) {
|
||||
.lotto-combined__method {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
@@ -1514,8 +1509,20 @@
|
||||
.lotto-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid rgba(255,255,255,0.1); }
|
||||
.lotto-tabs button { padding: 8px 16px; background: transparent; border: none; color: #94a3b8; cursor: pointer; border-bottom: 2px solid transparent; }
|
||||
.lotto-tabs button.active { color: #e2e8f0; border-bottom-color: #818cf8; }
|
||||
.lotto-tab-body { padding-top: 8px; }
|
||||
.lotto-tab-body { padding-top: 8px; display: grid; gap: 24px; }
|
||||
@media (max-width: 768px) {
|
||||
.lotto-tabs { overflow-x: auto; }
|
||||
.lotto-tabs button { white-space: nowrap; }
|
||||
|
||||
/* 구매 이력 테이블 가로 스크롤 */
|
||||
.purchase-list {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.lotto-ball {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,7 +317,7 @@
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
@media (max-width: 1024px) {
|
||||
.ms-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -487,7 +487,7 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@media (max-width: 480px) {
|
||||
.ms-genre-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
@@ -1696,7 +1696,19 @@
|
||||
/* ═══════════════════════════════════════════════════
|
||||
MOBILE
|
||||
═══════════════════════════════════════════════════ */
|
||||
@media (max-width: 640px) {
|
||||
@media (max-width: 768px) {
|
||||
.ms-tabs {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.ms-tabs > * {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.ms-header__title {
|
||||
font-size: clamp(44px, 14vw, 70px);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
generateStyleBoost,
|
||||
generateVideo,
|
||||
} from '../../api';
|
||||
import PullToRefresh from '../../components/PullToRefresh';
|
||||
import FAB from '../../components/FAB';
|
||||
import './MusicStudio.css';
|
||||
import AudioPlayer from './components/AudioPlayer';
|
||||
import { fmtTime } from './components/AudioPlayer';
|
||||
@@ -1123,6 +1125,7 @@ export default function MusicStudio() {
|
||||
|
||||
{/* ═══ LIBRARY TAB ═══ */}
|
||||
{tab === 'library' && (
|
||||
<PullToRefresh onRefresh={loadLibrary}>
|
||||
<Library
|
||||
tracks={library}
|
||||
loading={libLoading}
|
||||
@@ -1137,6 +1140,7 @@ export default function MusicStudio() {
|
||||
onVideoGenerate={handleVideoGenerate}
|
||||
isGenerating={isGenerating}
|
||||
/>
|
||||
</PullToRefresh>
|
||||
)}
|
||||
|
||||
{/* ═══ LYRICS TAB ═══ */}
|
||||
@@ -1760,6 +1764,10 @@ export default function MusicStudio() {
|
||||
accentColor={accentColor}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === 'library' && (
|
||||
<FAB onClick={() => setTab('create')} label="음악 생성" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -952,13 +952,13 @@
|
||||
|
||||
/* ── 반응형 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
@media (max-width: 1024px) {
|
||||
.re-list-layout {
|
||||
grid-template-columns: 1fr 340px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@media (max-width: 768px) {
|
||||
.re-list-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -967,9 +967,6 @@
|
||||
position: static;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.re-header {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -2943,3 +2943,41 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* 필터 가로 스크롤 */
|
||||
.stock-filter-row {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.stock-filter-row > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 지표 카드 가로 스크롤 캐러셀 */
|
||||
.stock-snapshot {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
gap: 12px;
|
||||
padding-bottom: 8px;
|
||||
scroll-snap-type: x mandatory;
|
||||
}
|
||||
|
||||
.stock-snapshot > * {
|
||||
flex: 0 0 200px;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
/* 뉴스 1컬럼 */
|
||||
.stock-news-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* 매크로 지표 1컬럼 */
|
||||
.stock-macro-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getStockIndices, getStockNews, getFearAndGreed, getVix, getTreasury10Y, getWTI, getBrent } from '../../api';
|
||||
import Loading from '../../components/Loading';
|
||||
import FearGreedGauge from '../../components/FearGreedGauge';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import PullToRefresh from '../../components/PullToRefresh';
|
||||
import FAB from '../../components/FAB';
|
||||
import './Stock.css';
|
||||
|
||||
const formatDate = (value) => {
|
||||
@@ -109,6 +112,7 @@ const getVixLevel = (score) => {
|
||||
};
|
||||
|
||||
const Stock = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const [newsDomestic, setNewsDomestic] = useState([]);
|
||||
const [newsOverseas, setNewsOverseas] = useState([]);
|
||||
const [newsCategory, setNewsCategory] = useState('domestic');
|
||||
@@ -146,6 +150,10 @@ const Stock = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
await loadNews();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const loadIndices = async () => {
|
||||
setIndicesLoading(true);
|
||||
setIndicesError('');
|
||||
@@ -217,6 +225,7 @@ const Stock = () => {
|
||||
newsCategory === 'domestic' ? newsDomestic : newsOverseas;
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<div className="stock">
|
||||
<header className="stock-header">
|
||||
<div>
|
||||
@@ -559,6 +568,13 @@ const Stock = () => {
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<FAB onClick={loadNews} label="뉴스 새로고침" icon={
|
||||
<svg className="fab__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
} />
|
||||
</PullToRefresh>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import './Stock.css';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import SwipeableView from '../../components/SwipeableView';
|
||||
import {
|
||||
formatNumber, formatPercent,
|
||||
toNumeric, profitColorClass,
|
||||
@@ -28,6 +30,12 @@ import SellHistoryDrawer from './components/SellHistoryDrawer';
|
||||
|
||||
const StockTrade = () => {
|
||||
const [activeTab, setActiveTab] = React.useState(TAB_REPORT);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const TAB_ORDER = [TAB_PORTFOLIO, TAB_AI, TAB_REPORT, TAB_ADVISOR];
|
||||
const tabLabels = ['포트폴리오', 'AI 트레이드', '리포트', '어드바이저'];
|
||||
const tabIndex = TAB_ORDER.indexOf(activeTab);
|
||||
const handleTabChange = useCallback((idx) => setActiveTab(TAB_ORDER[idx]), []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
/* ── hooks ────────────────────────────────────────────────────── */
|
||||
const pf = usePortfolio();
|
||||
@@ -166,35 +174,54 @@ const StockTrade = () => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="stock-main-tabs">
|
||||
{[
|
||||
{ id: TAB_PORTFOLIO, icon: '💼', label: '쟁승토리 계좌', badge: pf.portfolioHoldings.length || null },
|
||||
{ id: TAB_AI, icon: '🤖', label: 'AI 투자', sub: '모의투자' },
|
||||
{ id: TAB_REPORT, icon: '📊', label: '리포트', sub: '분석·AI코치' },
|
||||
{ id: TAB_ADVISOR, icon: '🧠', label: 'AI 어드바이저', sub: 'Gemini Pro', className: 'stock-main-tab--advisor' },
|
||||
].map(({ id, icon, label, sub, badge, className: cls }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={`stock-main-tab ${cls ?? ''} ${activeTab === id ? 'is-active' : ''}`}
|
||||
onClick={() => setActiveTab(id)}
|
||||
>
|
||||
<span className="stock-main-tab__icon">{icon}</span>
|
||||
<span className="stock-main-tab__label">{label}</span>
|
||||
{sub && <span className="stock-main-tab__sub">{sub}</span>}
|
||||
{badge > 0 && <span className="stock-main-tab__badge">{badge}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Tab bar + Tab content */}
|
||||
{isMobile ? (
|
||||
<SwipeableView
|
||||
tabs={TAB_ORDER.map((tabId, i) => ({
|
||||
key: tabId,
|
||||
label: tabLabels[i],
|
||||
content: tabId === TAB_PORTFOLIO
|
||||
? <PortfolioTab pf={pf} asset={asset} handleSell={handleSell} handleSaveSnapshot={handleSaveSnapshot} />
|
||||
: tabId === TAB_AI
|
||||
? <AiTradeTab aib={aib} />
|
||||
: tabId === TAB_REPORT
|
||||
? <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} />
|
||||
: <AdvisorTab pf={pf} advisor={advisor} />,
|
||||
}))}
|
||||
activeIndex={tabIndex}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="stock-main-tabs">
|
||||
{[
|
||||
{ id: TAB_PORTFOLIO, icon: '💼', label: '쟁승토리 계좌', badge: pf.portfolioHoldings.length || null },
|
||||
{ id: TAB_AI, icon: '🤖', label: 'AI 투자', sub: '모의투자' },
|
||||
{ id: TAB_REPORT, icon: '📊', label: '리포트', sub: '분석·AI코치' },
|
||||
{ id: TAB_ADVISOR, icon: '🧠', label: 'AI 어드바이저', sub: 'Gemini Pro', className: 'stock-main-tab--advisor' },
|
||||
].map(({ id, icon, label, sub, badge, className: cls }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={`stock-main-tab ${cls ?? ''} ${activeTab === id ? 'is-active' : ''}`}
|
||||
onClick={() => setActiveTab(id)}
|
||||
>
|
||||
<span className="stock-main-tab__icon">{icon}</span>
|
||||
<span className="stock-main-tab__label">{label}</span>
|
||||
{sub && <span className="stock-main-tab__sub">{sub}</span>}
|
||||
{badge > 0 && <span className="stock-main-tab__badge">{badge}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === TAB_PORTFOLIO && (
|
||||
<PortfolioTab pf={pf} asset={asset} handleSell={handleSell} handleSaveSnapshot={handleSaveSnapshot} />
|
||||
{activeTab === TAB_PORTFOLIO && (
|
||||
<PortfolioTab pf={pf} asset={asset} handleSell={handleSell} handleSaveSnapshot={handleSaveSnapshot} />
|
||||
)}
|
||||
{activeTab === TAB_AI && <AiTradeTab aib={aib} />}
|
||||
{activeTab === TAB_REPORT && <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} />}
|
||||
{activeTab === TAB_ADVISOR && <AdvisorTab pf={pf} advisor={advisor} />}
|
||||
</>
|
||||
)}
|
||||
{activeTab === TAB_AI && <AiTradeTab aib={aib} />}
|
||||
{activeTab === TAB_REPORT && <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} />}
|
||||
{activeTab === TAB_ADVISOR && <AdvisorTab pf={pf} advisor={advisor} />}
|
||||
|
||||
{/* Sell history drawer (always mounted) */}
|
||||
<SellHistoryDrawer
|
||||
|
||||
@@ -1139,19 +1139,16 @@
|
||||
|
||||
/* ── 반응형 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
@media (max-width: 1024px) {
|
||||
.sub-list-layout { grid-template-columns: 1fr 360px; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@media (max-width: 768px) {
|
||||
.sub-list-layout { grid-template-columns: 1fr; }
|
||||
.sub-detail-panel { position: static; }
|
||||
.sub-profile-card { grid-template-columns: 1fr; }
|
||||
.sub-profile-card__right { flex-direction: column; align-items: flex-start; }
|
||||
.sub-profile-score__breakdown { min-width: 0; width: 100%; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sub-header { grid-template-columns: 1fr; }
|
||||
.sub-stats-bar { display: grid; grid-template-columns: repeat(2, 1fr); }
|
||||
.sub-stat-item { border-right: none; border-bottom: 1px solid var(--line); }
|
||||
@@ -1164,4 +1161,20 @@
|
||||
.sub-tabs-bar { flex-direction: column; align-items: flex-start; }
|
||||
.sub-sched-row { grid-template-columns: 90px 10px 1fr; gap: 0 10px; }
|
||||
.sub-compare__row { grid-template-columns: 70px 1fr 1fr 16px; }
|
||||
|
||||
/* 공고 카드 1컬럼 */
|
||||
.sub-card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* 탭 가로 스크롤 */
|
||||
.sub-tabs {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.sub-tabs > * {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../../api';
|
||||
import PullToRefresh from '../../components/PullToRefresh';
|
||||
import FAB from '../../components/FAB';
|
||||
import './Subscription.css';
|
||||
|
||||
// ── 상수 ───────────────────────────────────────────────────────────────────────
|
||||
@@ -1297,8 +1298,18 @@ function ProfileTab() {
|
||||
// ── Subscription (Main) ──────────────────────────────────────────────────────
|
||||
function Subscription() {
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshKey(k => k + 1);
|
||||
}, []);
|
||||
|
||||
const handleFABClick = useCallback(() => {
|
||||
setActiveTab(1); // 공고 목록 탭으로 이동
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<div className="sub">
|
||||
{/* Header */}
|
||||
<div className="sub-header">
|
||||
@@ -1328,12 +1339,15 @@ function Subscription() {
|
||||
|
||||
{/* Body */}
|
||||
<div className="sub-body">
|
||||
{activeTab === 0 && <DashboardTab />}
|
||||
{activeTab === 1 && <AnnouncementsTab />}
|
||||
{activeTab === 2 && <MatchesTab />}
|
||||
{activeTab === 3 && <ProfileTab />}
|
||||
{activeTab === 0 && <DashboardTab key={`dash-${refreshKey}`} />}
|
||||
{activeTab === 1 && <AnnouncementsTab key={`ann-${refreshKey}`} />}
|
||||
{activeTab === 2 && <MatchesTab key={`match-${refreshKey}`} />}
|
||||
{activeTab === 3 && <ProfileTab key={`prof-${refreshKey}`} />}
|
||||
</div>
|
||||
|
||||
<FAB onClick={handleFABClick} label="공고 목록" />
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -222,8 +222,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
@@ -370,11 +370,21 @@
|
||||
text-decoration-color: rgba(244, 114, 182, 0.4);
|
||||
}
|
||||
|
||||
/* ── 스와이프 보드 (모바일 전용) ──────────────────────────────────────── */
|
||||
|
||||
.todo-swipe-board {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Responsive ──────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.todo-board {
|
||||
grid-template-columns: 1fr;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.todo-swipe-board {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.todo-col {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { getTodos, addTodo, updateTodo, deleteTodo, clearTodos } from '../../api';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import SwipeableView from '../../components/SwipeableView';
|
||||
import FAB from '../../components/FAB';
|
||||
import MobileSheet from '../../components/MobileSheet';
|
||||
import PullToRefresh from '../../components/PullToRefresh';
|
||||
import './Todo.css';
|
||||
|
||||
const ACTIVE_COLUMNS = [
|
||||
@@ -19,11 +24,13 @@ const toDateStr = (iso) => {
|
||||
};
|
||||
|
||||
const Todo = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const [todos, setTodos] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [addSheetOpen, setAddSheetOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dragging, setDragging] = useState(null);
|
||||
const [dragOver, setDragOver] = useState(null);
|
||||
@@ -185,7 +192,66 @@ const Todo = () => {
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ── 칸반 컬럼 렌더러 (재사용) ── */
|
||||
const renderColumn = (col) => {
|
||||
const items = byStatus(col.id);
|
||||
return (
|
||||
<div
|
||||
key={col.id}
|
||||
className={`todo-col${dragOver === col.id ? ' is-drag-over' : ''}`}
|
||||
onDragOver={(e) => onDragOver(e, col.id)}
|
||||
onDrop={(e) => onDrop(e, col.id)}
|
||||
>
|
||||
<div className="todo-col__head">
|
||||
<span className="todo-col__title">{col.label}</span>
|
||||
<span className="todo-col__count">{items.length}</span>
|
||||
</div>
|
||||
<div className="todo-col__body">
|
||||
{items.length === 0 && (
|
||||
<p className="todo-col__empty">드래그하여 이동</p>
|
||||
)}
|
||||
{items.map((todo) => renderCard(todo, col.id))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ── 추가 폼 (공통) ── */
|
||||
const addForm = (
|
||||
<form className="todo-form" onSubmit={async (e) => { await handleAdd(e); setAddSheetOpen(false); }}>
|
||||
<label className="todo-form__field">
|
||||
<span>제목 *</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="태스크 제목을 입력하세요"
|
||||
value={form.title}
|
||||
onChange={(e) => setForm((v) => ({ ...v, title: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="todo-form__field">
|
||||
<span>설명</span>
|
||||
<textarea
|
||||
placeholder="설명 (선택)"
|
||||
value={form.description}
|
||||
rows={3}
|
||||
onChange={(e) => setForm((v) => ({ ...v, description: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<div className="todo-form__actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="button primary"
|
||||
disabled={saving || !form.title.trim()}
|
||||
>
|
||||
{saving ? '저장 중...' : '추가'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={load}>
|
||||
<div className="todo-page">
|
||||
{/* 툴바 */}
|
||||
<div className="todo-toolbar">
|
||||
@@ -194,7 +260,7 @@ const Todo = () => {
|
||||
className="button primary"
|
||||
onClick={() => setFormOpen((v) => !v)}
|
||||
>
|
||||
{formOpen ? '취소' : '+ 태스크 추가'}
|
||||
{formOpen ? '취소' : '+ 할일 추가'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -205,127 +271,126 @@ const Todo = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 추가 폼 */}
|
||||
{formOpen && (
|
||||
<form className="todo-form" onSubmit={handleAdd}>
|
||||
<label className="todo-form__field">
|
||||
<span>제목 *</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="태스크 제목을 입력하세요"
|
||||
value={form.title}
|
||||
onChange={(e) => setForm((v) => ({ ...v, title: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="todo-form__field">
|
||||
<span>설명</span>
|
||||
<textarea
|
||||
placeholder="설명 (선택)"
|
||||
value={form.description}
|
||||
rows={3}
|
||||
onChange={(e) => setForm((v) => ({ ...v, description: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<div className="todo-form__actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="button primary"
|
||||
disabled={saving || !form.title.trim()}
|
||||
>
|
||||
{saving ? '저장 중...' : '추가'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{/* 추가 폼 (데스크탑) */}
|
||||
{formOpen && !isMobile && addForm}
|
||||
|
||||
{error && <p className="todo-error">{error}</p>}
|
||||
{loading && todos.length === 0 && <p className="todo-loading">불러오는 중...</p>}
|
||||
|
||||
{/* 활성 보드 (할 일 + 진행 중) */}
|
||||
<div className="todo-board">
|
||||
{ACTIVE_COLUMNS.map((col) => {
|
||||
const items = byStatus(col.id);
|
||||
return (
|
||||
<div
|
||||
key={col.id}
|
||||
className={`todo-col${dragOver === col.id ? ' is-drag-over' : ''}`}
|
||||
onDragOver={(e) => onDragOver(e, col.id)}
|
||||
onDrop={(e) => onDrop(e, col.id)}
|
||||
>
|
||||
<div className="todo-col__head">
|
||||
<span className="todo-col__title">{col.label}</span>
|
||||
<span className="todo-col__count">{items.length}</span>
|
||||
</div>
|
||||
<div className="todo-col__body">
|
||||
{items.length === 0 && (
|
||||
<p className="todo-col__empty">드래그하여 이동</p>
|
||||
{/* 모바일: SwipeableView 칸반 */}
|
||||
{isMobile ? (
|
||||
<div className="todo-swipe-board">
|
||||
<SwipeableView
|
||||
tabs={[
|
||||
{ key: 'todo', label: '할 일', content: renderColumn({ id: 'todo', label: '할 일' }) },
|
||||
{ key: 'in_progress', label: '진행 중', content: renderColumn({ id: 'in_progress', label: '진행 중' }) },
|
||||
{ key: 'done', label: '완료', content: (
|
||||
<div
|
||||
className={`todo-done-panel${dragOver === 'done' ? ' is-drag-over' : ''}`}
|
||||
onDragOver={(e) => onDragOver(e, 'done')}
|
||||
onDrop={(e) => onDrop(e, 'done')}
|
||||
>
|
||||
<div className="todo-done-panel__head">
|
||||
<div className="todo-done-panel__title-row">
|
||||
<span className="todo-col__title">완료</span>
|
||||
<span className="todo-col__count">{doneTodos.length}</span>
|
||||
</div>
|
||||
<div className="todo-done-panel__filter">
|
||||
<button type="button" className={`todo-date-btn${doneDate === '' ? ' is-active' : ''}`} onClick={() => setDoneDate('')}>전체</button>
|
||||
{doneDates.map((d) => (
|
||||
<button key={d} type="button" className={`todo-date-btn${doneDate === d ? ' is-active' : ''}`} onClick={() => setDoneDate(d)}>
|
||||
{new Date(d + 'T00:00:00').toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="todo-done-panel__body">
|
||||
{doneTodos.length === 0 ? (
|
||||
<p className="todo-col__empty">{doneDate ? '해당 날짜에 완료된 항목이 없습니다' : '드래그하여 이동'}</p>
|
||||
) : (
|
||||
doneTodos.map((todo) => renderCard(todo, 'done'))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 데스크탑: 활성 보드 (할 일 + 진행 중) */}
|
||||
<div className="todo-board">
|
||||
{ACTIVE_COLUMNS.map((col) => renderColumn(col))}
|
||||
</div>
|
||||
|
||||
{/* 완료 패널 */}
|
||||
<div
|
||||
className={`todo-done-panel${dragOver === 'done' ? ' is-drag-over' : ''}`}
|
||||
onDragOver={(e) => onDragOver(e, 'done')}
|
||||
onDrop={(e) => onDrop(e, 'done')}
|
||||
>
|
||||
<div className="todo-done-panel__head">
|
||||
<div className="todo-done-panel__title-row">
|
||||
<span className="todo-col__title">완료</span>
|
||||
<span className="todo-col__count">{doneTodos.length}</span>
|
||||
{doneDates.length > 0 && doneDate === '' && (
|
||||
<span className="todo-done-panel__total-hint">
|
||||
전체 {todos.filter(t => t.status === 'done').length}건
|
||||
</span>
|
||||
)}
|
||||
{items.map((todo) => renderCard(todo, col.id))}
|
||||
</div>
|
||||
<div className="todo-done-panel__filter">
|
||||
<button
|
||||
type="button"
|
||||
className={`todo-date-btn${doneDate === '' ? ' is-active' : ''}`}
|
||||
onClick={() => setDoneDate('')}
|
||||
>
|
||||
전체
|
||||
</button>
|
||||
{doneDates.map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
className={`todo-date-btn${doneDate === d ? ' is-active' : ''}`}
|
||||
onClick={() => setDoneDate(d)}
|
||||
>
|
||||
{new Date(d + 'T00:00:00').toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })}
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
type="date"
|
||||
className="todo-date-input"
|
||||
value={doneDate}
|
||||
onChange={(e) => setDoneDate(e.target.value)}
|
||||
title="날짜 직접 선택"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="todo-done-panel__body">
|
||||
{doneTodos.length === 0 ? (
|
||||
<p className="todo-col__empty">
|
||||
{doneDate ? '해당 날짜에 완료된 항목이 없습니다' : '드래그하여 이동'}
|
||||
</p>
|
||||
) : (
|
||||
doneTodos.map((todo) => renderCard(todo, 'done'))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 완료 패널 */}
|
||||
<div
|
||||
className={`todo-done-panel${dragOver === 'done' ? ' is-drag-over' : ''}`}
|
||||
onDragOver={(e) => onDragOver(e, 'done')}
|
||||
onDrop={(e) => onDrop(e, 'done')}
|
||||
{/* 모바일: 추가 바텀시트 */}
|
||||
<MobileSheet
|
||||
open={addSheetOpen}
|
||||
onClose={() => { setAddSheetOpen(false); setForm(emptyForm); }}
|
||||
title="할일 추가"
|
||||
>
|
||||
{/* 완료 패널 헤더 */}
|
||||
<div className="todo-done-panel__head">
|
||||
<div className="todo-done-panel__title-row">
|
||||
<span className="todo-col__title">완료</span>
|
||||
<span className="todo-col__count">{doneTodos.length}</span>
|
||||
{doneDates.length > 0 && doneDate === '' && (
|
||||
<span className="todo-done-panel__total-hint">
|
||||
전체 {todos.filter(t => t.status === 'done').length}건
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 날짜 필터 */}
|
||||
<div className="todo-done-panel__filter">
|
||||
<button
|
||||
type="button"
|
||||
className={`todo-date-btn${doneDate === '' ? ' is-active' : ''}`}
|
||||
onClick={() => setDoneDate('')}
|
||||
>
|
||||
전체
|
||||
</button>
|
||||
{doneDates.map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
className={`todo-date-btn${doneDate === d ? ' is-active' : ''}`}
|
||||
onClick={() => setDoneDate(d)}
|
||||
>
|
||||
{new Date(d + 'T00:00:00').toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })}
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
type="date"
|
||||
className="todo-date-input"
|
||||
value={doneDate}
|
||||
onChange={(e) => setDoneDate(e.target.value)}
|
||||
title="날짜 직접 선택"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{addForm}
|
||||
</MobileSheet>
|
||||
|
||||
{/* 완료 카드 그리드 */}
|
||||
<div className="todo-done-panel__body">
|
||||
{doneTodos.length === 0 ? (
|
||||
<p className="todo-col__empty">
|
||||
{doneDate ? '해당 날짜에 완료된 항목이 없습니다' : '드래그하여 이동'}
|
||||
</p>
|
||||
) : (
|
||||
doneTodos.map((todo) => renderCard(todo, 'done'))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FAB onClick={() => setAddSheetOpen(true)} label="할일 추가" />
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
116
src/pages/travel/AlbumCard.css
Normal file
116
src/pages/travel/AlbumCard.css
Normal file
@@ -0,0 +1,116 @@
|
||||
/* ── AlbumCard ── */
|
||||
|
||||
.album-card {
|
||||
position: relative;
|
||||
height: 240px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(245, 230, 200, 0.08);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: transform 0.28s ease, box-shadow 0.28s ease;
|
||||
}
|
||||
|
||||
.album-card:hover,
|
||||
.album-card:focus-visible {
|
||||
transform: scale(1.03);
|
||||
box-shadow: 0 4px 24px color-mix(in srgb, var(--card-accent) 35%, transparent);
|
||||
}
|
||||
|
||||
/* cover image */
|
||||
.album-card__cover {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.35s ease;
|
||||
}
|
||||
|
||||
.album-card:hover .album-card__cover {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
/* gradient overlay */
|
||||
.album-card__gradient {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(transparent 50%, rgba(15, 12, 9, 0.85));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* meta */
|
||||
.album-card__meta {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 16px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.album-card__region-badge {
|
||||
align-self: flex-start;
|
||||
font: 10px var(--tv-mono);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--card-accent);
|
||||
background: rgba(15, 12, 9, 0.6);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.album-card__name {
|
||||
margin: 0;
|
||||
font: 600 24px/1.15 var(--tv-serif);
|
||||
color: var(--tv-text);
|
||||
}
|
||||
|
||||
.album-card__count {
|
||||
font: 11px var(--tv-mono);
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--tv-muted);
|
||||
background: rgba(15, 12, 9, 0.55);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* grid layout */
|
||||
.album-card-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.album-card-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.album-card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.album-card {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.album-card__name {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
/* reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.album-card,
|
||||
.album-card__cover {
|
||||
transition: none;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
55
src/pages/travel/AlbumCard.jsx
Normal file
55
src/pages/travel/AlbumCard.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, { useRef, useCallback } from 'react';
|
||||
import { getRegionAccent } from './MiniMap';
|
||||
import './AlbumCard.css';
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
AlbumCard — cover image + gradient + meta
|
||||
───────────────────────────────────────────── */
|
||||
export default function AlbumCard({ album, onClick }) {
|
||||
const cardRef = useRef(null);
|
||||
const accent = getRegionAccent(album.region || '');
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!onClick) return;
|
||||
const rect = cardRef.current?.getBoundingClientRect();
|
||||
onClick(album, rect);
|
||||
}, [album, onClick]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e) => {
|
||||
if (e.key === 'Enter') handleClick();
|
||||
},
|
||||
[handleClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className="album-card"
|
||||
style={{ '--card-accent': accent }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* cover */}
|
||||
<img
|
||||
className="album-card__cover"
|
||||
src={album.coverThumb}
|
||||
alt={album.name}
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
{/* gradient overlay */}
|
||||
<div className="album-card__gradient" />
|
||||
|
||||
{/* meta */}
|
||||
<div className="album-card__meta">
|
||||
<span className="album-card__region-badge">{album.regionName}</span>
|
||||
<h3 className="album-card__name">{album.name}</h3>
|
||||
<span className="album-card__count">{album.photoCount} frames</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
183
src/pages/travel/AlbumDetail.css
Normal file
183
src/pages/travel/AlbumDetail.css
Normal file
@@ -0,0 +1,183 @@
|
||||
/* ─────────────────────────────────────────────
|
||||
AlbumDetail — fixed overlay
|
||||
───────────────────────────────────────────── */
|
||||
.album-detail {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
background: var(--tv-bg, #0f0c09);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
transition: opacity 400ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 400ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.album-detail--open {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.album-detail--exit {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.album-detail__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--tv-line, rgba(232, 221, 208, 0.1));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.album-detail__back {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--tv-line-bright, rgba(232, 221, 208, 0.22));
|
||||
background: transparent;
|
||||
color: var(--tv-text, #e8ddd0);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 200ms, border-color 200ms;
|
||||
}
|
||||
|
||||
.album-detail__back:hover {
|
||||
background: var(--tv-line, rgba(232, 221, 208, 0.1));
|
||||
border-color: var(--tv-muted, rgba(232, 221, 208, 0.45));
|
||||
}
|
||||
|
||||
.album-detail__title-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.album-detail__name {
|
||||
font-family: var(--tv-serif, Georgia, 'Times New Roman', serif);
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
color: var(--tv-text, #e8ddd0);
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.album-detail__region {
|
||||
font-family: var(--tv-mono, 'SF Mono', 'Fira Code', monospace);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--tv-muted, rgba(232, 221, 208, 0.45));
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--tv-line, rgba(232, 221, 208, 0.1));
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.album-detail__count {
|
||||
font-family: var(--tv-mono, 'SF Mono', 'Fira Code', monospace);
|
||||
font-size: 11px;
|
||||
color: var(--tv-muted, rgba(232, 221, 208, 0.45));
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Body ── */
|
||||
.album-detail__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
|
||||
/* ── Loading dots ── */
|
||||
.album-detail__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 80px 0;
|
||||
}
|
||||
|
||||
.album-detail__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--tv-muted, rgba(232, 221, 208, 0.45));
|
||||
animation: albumDetailPulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.album-detail__dot:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.album-detail__dot:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes albumDetailPulse {
|
||||
0%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
50% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* ── Error / Empty ── */
|
||||
.album-detail__error,
|
||||
.album-detail__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 24px;
|
||||
text-align: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.album-detail__error-text {
|
||||
font-family: var(--tv-mono, 'SF Mono', 'Fira Code', monospace);
|
||||
font-size: 12px;
|
||||
color: #c85a4a;
|
||||
}
|
||||
|
||||
.album-detail__empty-text {
|
||||
font-family: var(--tv-mono, 'SF Mono', 'Fira Code', monospace);
|
||||
font-size: 12px;
|
||||
color: var(--tv-dim, rgba(232, 221, 208, 0.25));
|
||||
}
|
||||
|
||||
/* ── Mobile ── */
|
||||
@media (max-width: 768px) {
|
||||
.album-detail__header {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.album-detail__name {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.album-detail__body {
|
||||
padding-bottom: calc(64px + env(safe-area-inset-bottom, 0));
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Reduced motion ── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.album-detail {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.album-detail__dot {
|
||||
animation: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
216
src/pages/travel/AlbumDetail.jsx
Normal file
216
src/pages/travel/AlbumDetail.jsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import SwipeableView from '../../components/SwipeableView';
|
||||
import PullToRefresh from '../../components/PullToRefresh';
|
||||
import MasonryGrid from './MasonryGrid';
|
||||
import HeroLightbox from './HeroLightbox';
|
||||
import VideoTab from './VideoTab';
|
||||
import { getRegionAccent } from './MiniMap';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import './AlbumDetail.css';
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
AlbumDetail — full-screen album overlay
|
||||
───────────────────────────────────────────── */
|
||||
const ANIM_MS = 400;
|
||||
|
||||
const prefersReduced = () =>
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
export default function AlbumDetail({
|
||||
album,
|
||||
sourceRect,
|
||||
photos,
|
||||
photoSummary,
|
||||
loading,
|
||||
loadingMore,
|
||||
hasNext,
|
||||
error,
|
||||
onClose,
|
||||
onLoadMore,
|
||||
onReload,
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
/* ── Animation phases: enter → open → exit ── */
|
||||
const [phase, setPhase] = useState('enter');
|
||||
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(null);
|
||||
const [lightboxRect, setLightboxRect] = useState(null);
|
||||
const closingRef = useRef(false);
|
||||
|
||||
// Enter → open
|
||||
useEffect(() => {
|
||||
if (prefersReduced()) {
|
||||
setPhase('open');
|
||||
return;
|
||||
}
|
||||
const raf = requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => setPhase('open'));
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, []);
|
||||
|
||||
/* ── Body scroll lock (only when lightbox NOT open) ── */
|
||||
useEffect(() => {
|
||||
if (selectedPhotoIndex != null) return; // lightbox handles its own
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}, [selectedPhotoIndex]);
|
||||
|
||||
/* ── ESC key (close album when lightbox not open) ── */
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (e.key === 'Escape' && selectedPhotoIndex == null) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [selectedPhotoIndex]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
/* ── Close with exit animation ── */
|
||||
const handleClose = useCallback(() => {
|
||||
if (closingRef.current) return;
|
||||
closingRef.current = true;
|
||||
if (prefersReduced()) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
setPhase('exit');
|
||||
setTimeout(() => onClose(), ANIM_MS);
|
||||
}, [onClose]);
|
||||
|
||||
/* ── Photo selection → open lightbox ── */
|
||||
const handleSelectPhoto = useCallback((e, index) => {
|
||||
const el = e?.currentTarget || e?.target;
|
||||
const rect = el ? el.getBoundingClientRect() : null;
|
||||
setLightboxRect(rect);
|
||||
setSelectedPhotoIndex(index);
|
||||
}, []);
|
||||
|
||||
const handleLightboxClose = useCallback(() => {
|
||||
setSelectedPhotoIndex(null);
|
||||
setLightboxRect(null);
|
||||
}, []);
|
||||
|
||||
const handleLightboxNavigate = useCallback((idx) => {
|
||||
setSelectedPhotoIndex(idx);
|
||||
}, []);
|
||||
|
||||
/* ── Derived ── */
|
||||
const regionAccent = getRegionAccent(album?.region || album?.id || '');
|
||||
const photoCountLabel = photoSummary?.total
|
||||
? `${photoSummary.total} photos`
|
||||
: photos?.length
|
||||
? `${photos.length}${hasNext ? '+' : ''}`
|
||||
: '';
|
||||
|
||||
/* ── Phase → class ── */
|
||||
const cls = [
|
||||
'album-detail',
|
||||
phase === 'open' && 'album-detail--open',
|
||||
phase === 'exit' && 'album-detail--exit',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
/* ── Tab content: Photos ── */
|
||||
const photosContent = (
|
||||
<div className="album-detail__body">
|
||||
{loading ? (
|
||||
<div className="album-detail__loading">
|
||||
<span className="album-detail__dot" />
|
||||
<span className="album-detail__dot" />
|
||||
<span className="album-detail__dot" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="album-detail__error">
|
||||
<span className="album-detail__error-text">{error}</span>
|
||||
</div>
|
||||
) : !photos || photos.length === 0 ? (
|
||||
<div className="album-detail__empty">
|
||||
<span className="album-detail__empty-text">No photos</span>
|
||||
</div>
|
||||
) : (
|
||||
<PullToRefresh onRefresh={onReload}>
|
||||
<MasonryGrid
|
||||
photos={photos}
|
||||
onSelectPhoto={handleSelectPhoto}
|
||||
onLoadMore={onLoadMore}
|
||||
hasNext={hasNext}
|
||||
isLoadingMore={loadingMore}
|
||||
regionAccent={regionAccent}
|
||||
/>
|
||||
</PullToRefresh>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ── Tab content: Video ── */
|
||||
const videoContent = (
|
||||
<div className="album-detail__body">
|
||||
<VideoTab />
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ── Tabs ── */
|
||||
const tabLabel = `사진${photoCountLabel ? ` (${photoCountLabel})` : ''}`;
|
||||
const tabs = [
|
||||
{ key: 'photos', label: tabLabel, content: photosContent },
|
||||
{ key: 'video', label: '영상', content: videoContent },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cls}>
|
||||
{/* Header */}
|
||||
<div className="album-detail__header">
|
||||
<button
|
||||
className="album-detail__back"
|
||||
onClick={handleClose}
|
||||
aria-label="Back"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<path
|
||||
d="M11 4L6 9l5 5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="album-detail__title-group">
|
||||
<span className="album-detail__name">{album?.name || ''}</span>
|
||||
{album?.regionName && (
|
||||
<span className="album-detail__region">{album.regionName}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{photoCountLabel && (
|
||||
<span className="album-detail__count">{photoCountLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<SwipeableView tabs={tabs} />
|
||||
</div>
|
||||
|
||||
{/* Lightbox */}
|
||||
{selectedPhotoIndex != null && photos?.length > 0 && (
|
||||
<HeroLightbox
|
||||
photos={photos}
|
||||
selectedIndex={selectedPhotoIndex}
|
||||
albumName={album?.name}
|
||||
regionId={album?.region || album?.id}
|
||||
sourceRect={lightboxRect}
|
||||
hasNext={hasNext}
|
||||
loadingMore={loadingMore}
|
||||
onClose={handleLightboxClose}
|
||||
onNavigate={handleLightboxNavigate}
|
||||
onLoadMore={onLoadMore}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
279
src/pages/travel/HeroLightbox.css
Normal file
279
src/pages/travel/HeroLightbox.css
Normal file
@@ -0,0 +1,279 @@
|
||||
/* ═══════════════════════════════════════════
|
||||
HeroLightbox — fullscreen photo viewer
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
/* ── Root overlay ── */
|
||||
.hero-lb {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.35s ease;
|
||||
}
|
||||
.hero-lb--enter { opacity: 0; }
|
||||
.hero-lb--open { opacity: 1; }
|
||||
.hero-lb--exit { opacity: 0; pointer-events: none; }
|
||||
|
||||
/* ── Backdrop ── */
|
||||
.hero-lb__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
transition: background 0.35s ease;
|
||||
}
|
||||
.hero-lb--enter .hero-lb__backdrop { background: rgba(0, 0, 0, 0); }
|
||||
|
||||
/* ── Inner container ── */
|
||||
.hero-lb__inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-width: 1280px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 16px 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── Top bar ── */
|
||||
.hero-lb__topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 4px 0 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hero-lb__counter {
|
||||
font-family: var(--tv-mono, 'Space Mono', 'Courier New', monospace);
|
||||
font-size: 0.85rem;
|
||||
color: var(--tv-muted, rgba(232, 221, 208, 0.45));
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.hero-lb__counter-cur {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hero-lb__close {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--tv-text, #e8ddd0);
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hero-lb__close:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
/* ── Stage (photo + arrows) ── */
|
||||
.hero-lb__stage {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ── Photo ── */
|
||||
.hero-lb__photo {
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 200px);
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
/* ── Slide animations ── */
|
||||
.hero-lb__slide--next {
|
||||
animation: hero-slide-right 280ms cubic-bezier(0.22, 0.68, 0.36, 1) both;
|
||||
}
|
||||
.hero-lb__slide--prev {
|
||||
animation: hero-slide-left 280ms cubic-bezier(0.22, 0.68, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@keyframes hero-slide-right {
|
||||
from { opacity: 0; transform: translateX(24px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
@keyframes hero-slide-left {
|
||||
from { opacity: 0; transform: translateX(-24px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* ── Arrow buttons ── */
|
||||
.hero-lb__arrow {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--tv-text, #e8ddd0);
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.2s, transform 0.15s;
|
||||
}
|
||||
.hero-lb__arrow:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
transform: scale(1.06);
|
||||
}
|
||||
.hero-lb__arrow:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
.hero-lb__arrow--loading {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.hero-lb__arrow--loading:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ── Spinner ── */
|
||||
.hero-lb__spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.15);
|
||||
border-top-color: var(--tv-text, #e8ddd0);
|
||||
border-radius: 50%;
|
||||
animation: hero-spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes hero-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Meta ── */
|
||||
.hero-lb__meta {
|
||||
padding: 8px 0 4px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--tv-muted, rgba(232, 221, 208, 0.45));
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
.hero-lb__meta-album {
|
||||
font-family: var(--tv-serif, 'Cormorant Garamond', Georgia, serif);
|
||||
font-style: italic;
|
||||
}
|
||||
.hero-lb__meta-file {
|
||||
font-family: var(--tv-mono, 'Space Mono', 'Courier New', monospace);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* ── Thumbnail strip ── */
|
||||
.hero-lb__strip {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
justify-content: center;
|
||||
padding: 8px 0 4px;
|
||||
flex-shrink: 0;
|
||||
max-width: 100%;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.hero-lb__strip::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero-lb__thumb {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
padding: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s, opacity 0.2s;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.hero-lb__thumb:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.hero-lb__thumb--active {
|
||||
border-color: #f5e6c8;
|
||||
opacity: 1;
|
||||
}
|
||||
.hero-lb__thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
Mobile (<=768px)
|
||||
═══════════════════════════════════════════ */
|
||||
@media (max-width: 768px) {
|
||||
.hero-lb__inner {
|
||||
max-width: 100vw;
|
||||
padding: 12px 12px;
|
||||
}
|
||||
|
||||
.hero-lb__arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero-lb__thumb {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.hero-lb__photo {
|
||||
max-height: calc(100vh - 180px);
|
||||
}
|
||||
|
||||
.hero-lb__meta {
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
Reduced motion
|
||||
═══════════════════════════════════════════ */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero-lb,
|
||||
.hero-lb__backdrop,
|
||||
.hero-lb__close,
|
||||
.hero-lb__arrow,
|
||||
.hero-lb__thumb {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.hero-lb__slide--next,
|
||||
.hero-lb__slide--prev {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.hero-lb__spinner {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
268
src/pages/travel/HeroLightbox.jsx
Normal file
268
src/pages/travel/HeroLightbox.jsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useSwipeable } from 'react-swipeable';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { getRegionAccent } from './MiniMap';
|
||||
import './HeroLightbox.css';
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Helpers
|
||||
───────────────────────────────────────────── */
|
||||
const STRIP_LIMIT = 36;
|
||||
const THUMB_SIZE = 52;
|
||||
const THUMB_SIZE_MOBILE = 44;
|
||||
const ANIM_MS = 350;
|
||||
|
||||
function getStripRange(total, active) {
|
||||
if (total <= STRIP_LIMIT) return [0, total];
|
||||
const half = Math.floor(STRIP_LIMIT / 2);
|
||||
let start = active - half;
|
||||
if (start < 0) start = 0;
|
||||
let end = start + STRIP_LIMIT;
|
||||
if (end > total) {
|
||||
end = total;
|
||||
start = Math.max(0, end - STRIP_LIMIT);
|
||||
}
|
||||
return [start, end];
|
||||
}
|
||||
|
||||
const prefersReduced = () =>
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
HeroLightbox
|
||||
───────────────────────────────────────────── */
|
||||
export default function HeroLightbox({
|
||||
photos,
|
||||
selectedIndex,
|
||||
albumName,
|
||||
regionId,
|
||||
sourceRect,
|
||||
hasNext,
|
||||
loadingMore,
|
||||
onClose,
|
||||
onNavigate,
|
||||
onLoadMore,
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
const [phase, setPhase] = useState('enter');
|
||||
const [slideDir, setSlideDir] = useState(null);
|
||||
const [slideToken, setSlideToken] = useState(0);
|
||||
const pendingAdvanceRef = useRef(false);
|
||||
const stripRef = useRef(null);
|
||||
const prevOverflowRef = useRef('');
|
||||
const accent = useMemo(() => getRegionAccent(regionId), [regionId]);
|
||||
const reduced = useMemo(() => prefersReduced(), []);
|
||||
const animMs = reduced ? 0 : ANIM_MS;
|
||||
|
||||
/* — Phase transitions — */
|
||||
useEffect(() => {
|
||||
// enter → open via double rAF
|
||||
let raf1, raf2;
|
||||
raf1 = requestAnimationFrame(() => {
|
||||
raf2 = requestAnimationFrame(() => setPhase('open'));
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(raf1);
|
||||
cancelAnimationFrame(raf2);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/* — Body scroll lock — */
|
||||
useEffect(() => {
|
||||
prevOverflowRef.current = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = prevOverflowRef.current;
|
||||
};
|
||||
}, []);
|
||||
|
||||
/* — Pending advance after load more — */
|
||||
useEffect(() => {
|
||||
if (pendingAdvanceRef.current && !loadingMore) {
|
||||
pendingAdvanceRef.current = false;
|
||||
goNext();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loadingMore, photos.length]);
|
||||
|
||||
/* — Auto-center active thumb — */
|
||||
useEffect(() => {
|
||||
if (!stripRef.current) return;
|
||||
const thumbSize = isMobile ? THUMB_SIZE_MOBILE : THUMB_SIZE;
|
||||
const gap = 4;
|
||||
const stripW = stripRef.current.offsetWidth;
|
||||
const scrollTarget =
|
||||
selectedIndex * (thumbSize + gap) - stripW / 2 + thumbSize / 2;
|
||||
stripRef.current.scrollTo({ left: scrollTarget, behavior: reduced ? 'auto' : 'smooth' });
|
||||
}, [selectedIndex, isMobile, reduced]);
|
||||
|
||||
/* — Close handler — */
|
||||
const handleClose = useCallback(() => {
|
||||
setPhase('exit');
|
||||
setTimeout(onClose, animMs);
|
||||
}, [onClose, animMs]);
|
||||
|
||||
/* — Navigation — */
|
||||
const goPrev = useCallback(() => {
|
||||
if (selectedIndex <= 0) return;
|
||||
setSlideDir('prev');
|
||||
setSlideToken((t) => t + 1);
|
||||
onNavigate(selectedIndex - 1);
|
||||
}, [selectedIndex, onNavigate]);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
if (selectedIndex >= photos.length - 1) {
|
||||
if (hasNext) {
|
||||
pendingAdvanceRef.current = true;
|
||||
onLoadMore?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
setSlideDir('next');
|
||||
setSlideToken((t) => t + 1);
|
||||
onNavigate(selectedIndex + 1);
|
||||
}, [selectedIndex, photos.length, hasNext, onNavigate, onLoadMore]);
|
||||
|
||||
/* — Keyboard — */
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (e.key === 'Escape') handleClose();
|
||||
else if (e.key === 'ArrowLeft') goPrev();
|
||||
else if (e.key === 'ArrowRight') goNext();
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [handleClose, goPrev, goNext]);
|
||||
|
||||
/* — Swipe — */
|
||||
const swipeHandlers = useSwipeable({
|
||||
onSwipedLeft: goNext,
|
||||
onSwipedRight: goPrev,
|
||||
onSwipedDown: (e) => {
|
||||
if (e.absY > 100) handleClose();
|
||||
},
|
||||
trackMouse: false,
|
||||
delta: 30,
|
||||
});
|
||||
|
||||
/* — Current photo — */
|
||||
const photo = photos[selectedIndex];
|
||||
if (!photo) return null;
|
||||
|
||||
const thumbSz = isMobile ? THUMB_SIZE_MOBILE : THUMB_SIZE;
|
||||
const [stripStart, stripEnd] = getStripRange(photos.length, selectedIndex);
|
||||
const stripPhotos = photos.slice(stripStart, stripEnd);
|
||||
|
||||
const slideClass =
|
||||
slideDir === 'next'
|
||||
? 'hero-lb__slide--next'
|
||||
: slideDir === 'prev'
|
||||
? 'hero-lb__slide--prev'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`hero-lb hero-lb--${phase}`}
|
||||
{...swipeHandlers}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Photo viewer"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div className="hero-lb__backdrop" onClick={handleClose} />
|
||||
|
||||
{/* Inner */}
|
||||
<div className="hero-lb__inner">
|
||||
{/* Top bar */}
|
||||
<div className="hero-lb__topbar">
|
||||
<span className="hero-lb__counter">
|
||||
<span className="hero-lb__counter-cur" style={{ color: accent }}>
|
||||
{selectedIndex + 1}
|
||||
</span>
|
||||
{' / '}
|
||||
{photos.length}
|
||||
</span>
|
||||
<button
|
||||
className="hero-lb__close"
|
||||
onClick={handleClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main photo area */}
|
||||
<div className="hero-lb__stage">
|
||||
{/* Left arrow */}
|
||||
{!isMobile && selectedIndex > 0 && (
|
||||
<button className="hero-lb__arrow hero-lb__arrow--left" onClick={goPrev} aria-label="Previous">
|
||||
‹
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Photo */}
|
||||
<img
|
||||
key={slideToken}
|
||||
className={`hero-lb__photo ${slideClass}`}
|
||||
src={photo.url || photo.src}
|
||||
alt={photo.filename || photo.name || ''}
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
{/* Right arrow */}
|
||||
{!isMobile && selectedIndex < photos.length - 1 && (
|
||||
<button className="hero-lb__arrow hero-lb__arrow--right" onClick={goNext} aria-label="Next">
|
||||
›
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Loading spinner for load-more */}
|
||||
{!isMobile && loadingMore && selectedIndex >= photos.length - 1 && (
|
||||
<button className="hero-lb__arrow hero-lb__arrow--right hero-lb__arrow--loading" disabled aria-label="Loading">
|
||||
<span className="hero-lb__spinner" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="hero-lb__meta">
|
||||
<span className="hero-lb__meta-album">{albumName}</span>
|
||||
{' · '}
|
||||
<span className="hero-lb__meta-file">{photo.filename || photo.name || ''}</span>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail strip */}
|
||||
<div className="hero-lb__strip" ref={stripRef}>
|
||||
{stripPhotos.map((p, i) => {
|
||||
const realIdx = stripStart + i;
|
||||
const isActive = realIdx === selectedIndex;
|
||||
return (
|
||||
<button
|
||||
key={p.id || realIdx}
|
||||
className={`hero-lb__thumb${isActive ? ' hero-lb__thumb--active' : ''}`}
|
||||
style={{
|
||||
width: thumbSz,
|
||||
height: thumbSz,
|
||||
borderColor: isActive ? '#f5e6c8' : 'transparent',
|
||||
}}
|
||||
onClick={() => {
|
||||
setSlideDir(realIdx > selectedIndex ? 'next' : 'prev');
|
||||
setSlideToken((t) => t + 1);
|
||||
onNavigate(realIdx);
|
||||
}}
|
||||
aria-label={`Photo ${realIdx + 1}`}
|
||||
>
|
||||
<img
|
||||
src={p.thumbUrl || p.thumb || p.url || p.src}
|
||||
alt=""
|
||||
draggable={false}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
src/pages/travel/MasonryGrid.css
Normal file
138
src/pages/travel/MasonryGrid.css
Normal file
@@ -0,0 +1,138 @@
|
||||
/* ── MasonryGrid ── */
|
||||
|
||||
.masonry-grid {
|
||||
column-count: 4;
|
||||
column-gap: 8px;
|
||||
}
|
||||
|
||||
/* item */
|
||||
.masonry-item {
|
||||
break-inside: avoid;
|
||||
margin-bottom: 8px;
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
cursor: zoom-in;
|
||||
|
||||
/* scroll-reveal initial state */
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
transition: opacity 0.45s ease, transform 0.45s ease;
|
||||
}
|
||||
|
||||
.masonry-item--revealed {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.masonry-item__img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
transition: filter 0.25s ease;
|
||||
}
|
||||
|
||||
.masonry-item:hover .masonry-item__img {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
/* hover overlay */
|
||||
.masonry-item__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: 8px 10px;
|
||||
background: linear-gradient(transparent 60%, rgba(15, 12, 9, 0.7));
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.masonry-item:hover .masonry-item__overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.masonry-item__label {
|
||||
font: 11px var(--tv-mono);
|
||||
color: var(--tv-text);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* sentinel */
|
||||
.masonry-sentinel {
|
||||
height: 1px;
|
||||
column-span: all;
|
||||
}
|
||||
|
||||
/* loading dots */
|
||||
.masonry-loading {
|
||||
column-span: all;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.masonry-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--tv-muted);
|
||||
animation: masonry-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.masonry-dot:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
|
||||
.masonry-dot:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
@keyframes masonry-pulse {
|
||||
0%, 80%, 100% { opacity: 0.25; transform: scale(0.8); }
|
||||
40% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* end message */
|
||||
.masonry-end {
|
||||
column-span: all;
|
||||
text-align: center;
|
||||
font: 11px var(--tv-mono);
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--tv-dim);
|
||||
padding: 32px 0 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* responsive */
|
||||
@media (max-width: 1024px) {
|
||||
.masonry-grid {
|
||||
column-count: 3;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.masonry-grid {
|
||||
column-count: 2;
|
||||
}
|
||||
}
|
||||
|
||||
/* reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.masonry-item {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.masonry-item__img,
|
||||
.masonry-item__overlay {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.masonry-dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
117
src/pages/travel/MasonryGrid.jsx
Normal file
117
src/pages/travel/MasonryGrid.jsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React, { useEffect, useRef, useCallback } from 'react';
|
||||
import './MasonryGrid.css';
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Utility
|
||||
───────────────────────────────────────────── */
|
||||
function getPhotoLabel(photo) {
|
||||
if (photo.label) return photo.label;
|
||||
if (photo.name) {
|
||||
const base = photo.name.replace(/\.[^.]+$/, '');
|
||||
return base.replace(/[_-]/g, ' ');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
MasonryGrid — CSS columns + infinite scroll
|
||||
───────────────────────────────────────────── */
|
||||
export default function MasonryGrid({
|
||||
photos,
|
||||
onSelectPhoto,
|
||||
onLoadMore,
|
||||
hasNext,
|
||||
isLoadingMore,
|
||||
regionAccent,
|
||||
}) {
|
||||
const sentinelRef = useRef(null);
|
||||
const itemRefs = useRef([]);
|
||||
|
||||
/* infinite scroll sentinel */
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel || !hasNext) return;
|
||||
|
||||
const io = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting && !isLoadingMore && onLoadMore) {
|
||||
onLoadMore();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '300px' },
|
||||
);
|
||||
io.observe(sentinel);
|
||||
return () => io.disconnect();
|
||||
}, [hasNext, isLoadingMore, onLoadMore]);
|
||||
|
||||
/* scroll-reveal */
|
||||
useEffect(() => {
|
||||
const nodes = itemRefs.current.filter(Boolean);
|
||||
if (!nodes.length) return;
|
||||
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('masonry-item--revealed');
|
||||
io.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ rootMargin: '120px', threshold: 0.05 },
|
||||
);
|
||||
|
||||
nodes.forEach((n) => io.observe(n));
|
||||
return () => io.disconnect();
|
||||
}, [photos]);
|
||||
|
||||
const setItemRef = useCallback((el, idx) => {
|
||||
itemRefs.current[idx] = el;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="masonry-grid" style={{ '--region-accent': regionAccent }}>
|
||||
{photos.map((photo, idx) => {
|
||||
const label = getPhotoLabel(photo);
|
||||
return (
|
||||
<div
|
||||
key={photo.id || photo.src || idx}
|
||||
className="masonry-item"
|
||||
ref={(el) => setItemRef(el, idx)}
|
||||
onClick={() => onSelectPhoto && onSelectPhoto(photo, idx)}
|
||||
>
|
||||
<img
|
||||
className="masonry-item__img"
|
||||
src={photo.thumb || photo.src}
|
||||
alt={label}
|
||||
loading={idx < 8 ? 'eager' : 'lazy'}
|
||||
draggable={false}
|
||||
/>
|
||||
{label && (
|
||||
<div className="masonry-item__overlay">
|
||||
<span className="masonry-item__label">{label}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* sentinel for infinite scroll */}
|
||||
{hasNext && <div ref={sentinelRef} className="masonry-sentinel" />}
|
||||
|
||||
{/* loading indicator */}
|
||||
{isLoadingMore && (
|
||||
<div className="masonry-loading">
|
||||
<span className="masonry-dot" />
|
||||
<span className="masonry-dot" />
|
||||
<span className="masonry-dot" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* end message */}
|
||||
{!hasNext && photos.length > 0 && (
|
||||
<p className="masonry-end">— {photos.length} frames developed —</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
src/pages/travel/MiniMap.css
Normal file
106
src/pages/travel/MiniMap.css
Normal file
@@ -0,0 +1,106 @@
|
||||
/* ── MiniMap ── */
|
||||
|
||||
.minimap-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* toolbar */
|
||||
.minimap-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.minimap-toggle-btn,
|
||||
.minimap-clear-btn {
|
||||
background: var(--tv-surface);
|
||||
color: var(--tv-muted);
|
||||
border: 1px solid var(--tv-line-bright);
|
||||
border-radius: var(--tv-r-sm);
|
||||
padding: 5px 14px;
|
||||
font: 11px var(--tv-mono);
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.minimap-toggle-btn:hover,
|
||||
.minimap-clear-btn:hover {
|
||||
color: var(--tv-text);
|
||||
border-color: var(--tv-accent);
|
||||
}
|
||||
|
||||
/* container */
|
||||
.minimap-container {
|
||||
position: relative;
|
||||
height: var(--minimap-h, 200px);
|
||||
border-radius: var(--tv-r-lg);
|
||||
border: 1px solid var(--tv-line-bright);
|
||||
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.35);
|
||||
overflow: hidden;
|
||||
transition: height 0.35s ease, opacity 0.35s ease;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.minimap-collapsed {
|
||||
height: 0 !important;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* leaflet overrides */
|
||||
.minimap-leaflet {
|
||||
background: var(--tv-bg);
|
||||
}
|
||||
|
||||
.minimap-leaflet .leaflet-tile-pane {
|
||||
filter: brightness(0.7) saturate(0.4);
|
||||
}
|
||||
|
||||
/* hint overlay */
|
||||
.minimap-hint {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 800;
|
||||
font: 10px var(--tv-mono);
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--tv-dim);
|
||||
background: rgba(15, 12, 9, 0.65);
|
||||
padding: 4px 14px;
|
||||
border-radius: var(--tv-r-sm);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* tooltip */
|
||||
.minimap-tooltip {
|
||||
background: var(--tv-surface) !important;
|
||||
color: var(--tv-text) !important;
|
||||
border: 1px solid var(--tv-line-bright) !important;
|
||||
border-radius: var(--tv-r-sm) !important;
|
||||
font: 11px var(--tv-mono) !important;
|
||||
padding: 3px 10px !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4) !important;
|
||||
}
|
||||
|
||||
.minimap-tooltip::before {
|
||||
border-top-color: var(--tv-surface) !important;
|
||||
}
|
||||
|
||||
/* mobile */
|
||||
@media (max-width: 768px) {
|
||||
.minimap-container {
|
||||
height: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
/* reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.minimap-container {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
166
src/pages/travel/MiniMap.jsx
Normal file
166
src/pages/travel/MiniMap.jsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { MapContainer, TileLayer, GeoJSON, useMap } from 'react-leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import './MiniMap.css';
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Region accent palette
|
||||
───────────────────────────────────────────── */
|
||||
export const REGION_PALETTE = {
|
||||
japan: '#e05c4b',
|
||||
korea: '#d64f6e',
|
||||
china: '#c84b3a',
|
||||
europe: '#5b8fc4',
|
||||
france: '#6f8fc4',
|
||||
italy: '#78a46e',
|
||||
spain: '#c4844a',
|
||||
sea: '#4aad8b',
|
||||
thailand: '#4aad8b',
|
||||
vietnam: '#5faa78',
|
||||
bali: '#7aac5a',
|
||||
indonesia: '#8aaa4a',
|
||||
america: '#b4885c',
|
||||
usa: '#b4885c',
|
||||
canada: '#6a9890',
|
||||
africa: '#c47c3c',
|
||||
middle: '#c4a24a',
|
||||
dubai: '#c4a24a',
|
||||
default: '#c8905e',
|
||||
};
|
||||
|
||||
export function getRegionAccent(regionId = '') {
|
||||
const id = regionId.toLowerCase();
|
||||
for (const [key, color] of Object.entries(REGION_PALETTE)) {
|
||||
if (key !== 'default' && id.includes(key)) return color;
|
||||
}
|
||||
return REGION_PALETTE.default;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
MapLayer — internal component
|
||||
───────────────────────────────────────────── */
|
||||
function MapLayer({ geojson, selectedRegionId, onSelectRegion }) {
|
||||
const map = useMap();
|
||||
|
||||
const style = useCallback(
|
||||
(feature) => {
|
||||
const rid = feature.properties?.id || feature.properties?.name || '';
|
||||
const isSelected =
|
||||
selectedRegionId && rid.toLowerCase() === selectedRegionId.toLowerCase();
|
||||
const accent = getRegionAccent(rid);
|
||||
return {
|
||||
fillColor: isSelected ? accent : 'rgba(232,221,208,0.12)',
|
||||
fillOpacity: isSelected ? 0.45 : 0.18,
|
||||
color: isSelected ? accent : 'rgba(232,221,208,0.25)',
|
||||
weight: isSelected ? 2.5 : 1,
|
||||
};
|
||||
},
|
||||
[selectedRegionId],
|
||||
);
|
||||
|
||||
const onEachFeature = useCallback(
|
||||
(feature, layer) => {
|
||||
const name =
|
||||
feature.properties?.name_ko ||
|
||||
feature.properties?.name ||
|
||||
feature.properties?.id ||
|
||||
'';
|
||||
if (name) {
|
||||
layer.bindTooltip(name, {
|
||||
className: 'minimap-tooltip',
|
||||
sticky: true,
|
||||
});
|
||||
}
|
||||
layer.on('click', () => {
|
||||
const rid = feature.properties?.id || feature.properties?.name || '';
|
||||
onSelectRegion(rid);
|
||||
const bounds = layer.getBounds();
|
||||
if (bounds.isValid()) {
|
||||
map.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
|
||||
}
|
||||
});
|
||||
},
|
||||
[map, onSelectRegion],
|
||||
);
|
||||
|
||||
if (!geojson) return null;
|
||||
|
||||
return (
|
||||
<GeoJSON
|
||||
key={selectedRegionId || '__all__'}
|
||||
data={geojson}
|
||||
style={style}
|
||||
onEachFeature={onEachFeature}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
MiniMap
|
||||
───────────────────────────────────────────── */
|
||||
export default function MiniMap({
|
||||
geojson,
|
||||
selectedRegionId,
|
||||
onSelectRegion,
|
||||
onClearRegion,
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const toggleExpanded = () => setExpanded((v) => !v);
|
||||
|
||||
return (
|
||||
<div className="minimap-wrapper">
|
||||
{/* toolbar */}
|
||||
<div className="minimap-toolbar">
|
||||
<button
|
||||
className="minimap-toggle-btn"
|
||||
onClick={toggleExpanded}
|
||||
aria-label={expanded ? '지도 접기' : '지도 펼치기'}
|
||||
>
|
||||
{expanded ? '▲ 지도 접기' : '▼ 지도 펼치기'}
|
||||
</button>
|
||||
|
||||
{selectedRegionId && (
|
||||
<button className="minimap-clear-btn" onClick={onClearRegion}>
|
||||
전체 보기
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* map container */}
|
||||
<div
|
||||
className={`minimap-container${expanded ? '' : ' minimap-collapsed'}`}
|
||||
style={{
|
||||
'--minimap-h': isMobile ? '150px' : '200px',
|
||||
}}
|
||||
>
|
||||
<MapContainer
|
||||
center={[30, 125]}
|
||||
zoom={2}
|
||||
minZoom={2}
|
||||
maxZoom={7}
|
||||
zoomControl={false}
|
||||
attributionControl={false}
|
||||
className="minimap-leaflet"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
<TileLayer
|
||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
attribution=""
|
||||
/>
|
||||
<MapLayer
|
||||
geojson={geojson}
|
||||
selectedRegionId={selectedRegionId}
|
||||
onSelectRegion={onSelectRegion}
|
||||
/>
|
||||
</MapContainer>
|
||||
|
||||
{!selectedRegionId && expanded && (
|
||||
<div className="minimap-hint">CLICK A REGION</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -166,73 +166,16 @@
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
MAP SECTION
|
||||
ALBUMS SECTION — card grid
|
||||
═══════════════════════════════════════════════════ */
|
||||
.tv-map-section {
|
||||
.tv-albums {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.tv-albums__grid {
|
||||
display: grid;
|
||||
gap: 28px;
|
||||
transition: opacity 0.35s ease;
|
||||
}
|
||||
|
||||
.tv-map-section.is-dimmed {
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tv-map-wrap {
|
||||
position: relative;
|
||||
border-radius: var(--tv-r-lg);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--tv-line-bright);
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.tv-map {
|
||||
width: 100%;
|
||||
height: 480px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tv-map {
|
||||
height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Leaflet map tooltip override */
|
||||
.map-tooltip {
|
||||
font-family: var(--tv-mono) !important;
|
||||
font-size: 10px !important;
|
||||
letter-spacing: 0.12em !important;
|
||||
text-transform: uppercase !important;
|
||||
background: rgba(15, 12, 9, 0.92) !important;
|
||||
border: 1px solid rgba(232, 221, 208, 0.2) !important;
|
||||
border-radius: 6px !important;
|
||||
color: #e8ddd0 !important;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5) !important;
|
||||
}
|
||||
|
||||
.map-tooltip::before {
|
||||
border-top-color: rgba(232, 221, 208, 0.15) !important;
|
||||
}
|
||||
|
||||
/* Map overlay hint */
|
||||
.tv-map__overlay-hint {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(15, 12, 9, 0.85);
|
||||
border: 1px solid rgba(232, 221, 208, 0.2);
|
||||
border-radius: 999px;
|
||||
padding: 7px 18px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tv-map__overlay-hint span {
|
||||
font-family: var(--tv-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.24em;
|
||||
color: var(--tv-muted);
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* ── Loading / Error states ──────────────────────── */
|
||||
@@ -286,693 +229,16 @@
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
ALBUM HEADER
|
||||
═══════════════════════════════════════════════════ */
|
||||
.tv-album-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--tv-line);
|
||||
}
|
||||
|
||||
.tv-album-header__left {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.tv-album-header__region {
|
||||
font-family: var(--tv-serif);
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.tv-album-header__albums {
|
||||
font-family: var(--tv-mono);
|
||||
font-size: 10px;
|
||||
color: var(--tv-muted);
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tv-album-header__count {
|
||||
font-family: var(--tv-mono);
|
||||
font-size: 11px;
|
||||
color: var(--tv-dim);
|
||||
letter-spacing: 0.12em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
PHOTO MOSAIC — 4-column editorial grid
|
||||
═══════════════════════════════════════════════════ */
|
||||
.photo-mosaic {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-auto-rows: 240px;
|
||||
grid-auto-flow: dense;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.photo-mosaic {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-auto-rows: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.photo-mosaic {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-auto-rows: 180px;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
PHOTO CARD
|
||||
═══════════════════════════════════════════════════ */
|
||||
.photo-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: var(--tv-r-sm);
|
||||
cursor: pointer;
|
||||
background: var(--tv-surface);
|
||||
|
||||
/* Scroll-reveal */
|
||||
opacity: 0;
|
||||
transform: scale(0.97) translateY(10px);
|
||||
transition:
|
||||
opacity 0.5s ease,
|
||||
transform 0.5s ease,
|
||||
box-shadow 0.25s ease;
|
||||
transition-delay: var(--reveal-delay, 0ms);
|
||||
}
|
||||
|
||||
.photo-card[data-revealed='true'] {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
|
||||
/* Layout variants */
|
||||
.photo-card--hero {
|
||||
grid-column: span 2;
|
||||
grid-row: span 2;
|
||||
}
|
||||
|
||||
.photo-card--tall {
|
||||
grid-row: span 2;
|
||||
}
|
||||
|
||||
.photo-card--wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
/* Image */
|
||||
.photo-card img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
transition: transform 0.6s cubic-bezier(0.25, 0, 0, 1), filter 0.4s ease;
|
||||
filter: saturate(0.85) brightness(0.92);
|
||||
}
|
||||
|
||||
.photo-card:hover img {
|
||||
transform: scale(1.04);
|
||||
filter: saturate(1) brightness(1);
|
||||
}
|
||||
|
||||
/* Hover overlay */
|
||||
.photo-card__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
rgba(15, 12, 9, 0) 40%,
|
||||
rgba(15, 12, 9, 0.75) 100%
|
||||
);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.photo-card:hover .photo-card__overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.photo-card__overlay-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.photo-card__index {
|
||||
font-family: var(--tv-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--accent, var(--tv-accent));
|
||||
}
|
||||
|
||||
.photo-card__label {
|
||||
font-family: var(--tv-mono);
|
||||
font-size: 10px;
|
||||
color: rgba(232, 221, 208, 0.85);
|
||||
margin: 0;
|
||||
letter-spacing: 0.06em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Decorative print-border effect */
|
||||
.photo-card__frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--tv-r-sm);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
pointer-events: none;
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.photo-card:hover .photo-card__frame {
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.photo-card:focus-visible {
|
||||
outline: 2px solid var(--tv-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
MOSAIC FOOTER — sentinel + end message
|
||||
═══════════════════════════════════════════════════ */
|
||||
.mosaic-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 24px 0 8px;
|
||||
min-height: 48px;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.mosaic-loading {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mosaic-loading__dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--tv-accent);
|
||||
animation: tv-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.mosaic-loading__dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.mosaic-loading__dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
.mosaic-end {
|
||||
font-family: var(--tv-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.22em;
|
||||
color: var(--tv-dim);
|
||||
text-transform: uppercase;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mosaic-end span {
|
||||
color: var(--tv-line-bright);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
FILM STRIP — thumbnail rail
|
||||
═══════════════════════════════════════════════════ */
|
||||
.filmstrip {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
background: #0a0806;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--tv-line);
|
||||
}
|
||||
|
||||
.filmstrip__nav {
|
||||
width: 32px;
|
||||
background: rgba(15, 12, 9, 0.9);
|
||||
border: none;
|
||||
color: var(--tv-muted);
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease, background 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filmstrip__nav:hover {
|
||||
color: var(--tv-text);
|
||||
background: rgba(15, 12, 9, 0.6);
|
||||
}
|
||||
|
||||
.filmstrip__rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Perforation strip */
|
||||
.filmstrip__holes {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0;
|
||||
padding: 5px 8px;
|
||||
background: #0a0806;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.filmstrip__hole {
|
||||
width: 10px;
|
||||
height: 8px;
|
||||
flex-shrink: 0;
|
||||
margin-right: 14px;
|
||||
border-radius: 2px;
|
||||
background: var(--tv-surface);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* Thumbnail frames */
|
||||
.filmstrip__frames {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
padding: 5px 8px;
|
||||
overflow-x: auto;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.filmstrip__frames::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.filmstrip__frame {
|
||||
position: relative;
|
||||
width: 68px;
|
||||
height: 52px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: var(--tv-surface-2);
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.filmstrip__frame img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
filter: saturate(0.7);
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
|
||||
.filmstrip__frame:hover img,
|
||||
.filmstrip__frame.is-active img {
|
||||
filter: saturate(1);
|
||||
}
|
||||
|
||||
.filmstrip__frame:hover {
|
||||
transform: scale(1.06);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.filmstrip__frame.is-active {
|
||||
border-color: var(--tv-accent);
|
||||
box-shadow: 0 0 0 1px var(--tv-accent);
|
||||
}
|
||||
|
||||
.filmstrip__frame-num {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 3px;
|
||||
font-family: var(--tv-mono);
|
||||
font-size: 7px;
|
||||
color: rgba(232, 221, 208, 0.6);
|
||||
letter-spacing: 0.06em;
|
||||
pointer-events: none;
|
||||
line-height: 1;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
LIGHTBOX — cinematic full-screen viewer
|
||||
═══════════════════════════════════════════════════ */
|
||||
.lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(10, 8, 6, 0.9);
|
||||
backdrop-filter: blur(var(--lb-blur, 6px));
|
||||
-webkit-backdrop-filter: blur(var(--lb-blur, 6px));
|
||||
z-index: 3000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.lightbox__inner {
|
||||
width: min(1280px, 98vw);
|
||||
max-height: 100dvh;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto auto auto;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Top bar ──────────────────────────────────────── */
|
||||
.lightbox__topbar {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--tv-line);
|
||||
background: rgba(10, 8, 6, 0.7);
|
||||
}
|
||||
|
||||
.lightbox__counter {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
font-family: var(--tv-mono);
|
||||
}
|
||||
|
||||
.lightbox__counter-current {
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.lightbox__counter-sep {
|
||||
font-size: 12px;
|
||||
color: var(--tv-line-bright);
|
||||
}
|
||||
|
||||
.lightbox__counter-total {
|
||||
font-size: 12px;
|
||||
color: var(--tv-muted);
|
||||
}
|
||||
|
||||
.lightbox__region {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.lightbox__region-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent, var(--tv-accent));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lightbox__region-name {
|
||||
font-family: var(--tv-serif);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--tv-text);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.lightbox__album {
|
||||
font-family: var(--tv-mono);
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--tv-muted);
|
||||
padding-left: 10px;
|
||||
border-left: 1px solid var(--tv-line-bright);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.lightbox__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.lb-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-family: var(--tv-mono);
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--tv-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lb-control input[type='range'] {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 100px;
|
||||
height: 3px;
|
||||
background: rgba(232, 221, 208, 0.15);
|
||||
border-radius: 999px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.lb-control input[type='range']::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--tv-text);
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.lb-control input[type='range']::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--tv-text);
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.lb-control__val {
|
||||
font-size: 9px;
|
||||
min-width: 16px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.lightbox__close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(232, 221, 208, 0.18);
|
||||
background: rgba(15, 12, 9, 0.8);
|
||||
color: var(--tv-text);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lightbox__close:hover {
|
||||
border-color: rgba(232, 221, 208, 0.5);
|
||||
background: rgba(232, 221, 208, 0.08);
|
||||
}
|
||||
|
||||
/* ── Photo stage ──────────────────────────────────── */
|
||||
.lightbox__stage {
|
||||
display: grid;
|
||||
grid-template-columns: 56px 1fr 56px;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
min-height: 0;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.lightbox__frame {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: clamp(300px, 58vh, 700px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lightbox__photo {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.lightbox__photo.slide-next {
|
||||
animation: lb-slide-in-right 280ms cubic-bezier(0.25, 0, 0.25, 1) forwards;
|
||||
}
|
||||
|
||||
.lightbox__photo.slide-prev {
|
||||
animation: lb-slide-in-left 280ms cubic-bezier(0.25, 0, 0.25, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes lb-slide-in-right {
|
||||
from { opacity: 0; transform: translateX(24px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateX(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes lb-slide-in-left {
|
||||
from { opacity: 0; transform: translateX(-24px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateX(0) scale(1); }
|
||||
}
|
||||
|
||||
/* Decorative film frame border */
|
||||
.lightbox__photo-frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
border-radius: 4px;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.06),
|
||||
0 2px 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Navigation arrows */
|
||||
.lightbox__arrow {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(232, 221, 208, 0.18);
|
||||
background: rgba(15, 12, 9, 0.85);
|
||||
color: var(--tv-text);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-self: center;
|
||||
position: relative;
|
||||
transition: border-color 0.2s ease, transform 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.lightbox__arrow:hover {
|
||||
border-color: rgba(232, 221, 208, 0.45);
|
||||
background: rgba(232, 221, 208, 0.06);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.lightbox__arrow:disabled {
|
||||
opacity: 0.25;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.lightbox__arrow.is-loading {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.lightbox__spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(232, 221, 208, 0.25);
|
||||
border-top-color: var(--tv-accent);
|
||||
animation: tv-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes tv-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Photo meta */
|
||||
.lightbox__meta {
|
||||
padding: 6px 20px;
|
||||
font-family: var(--tv-mono);
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--tv-muted);
|
||||
margin: 0;
|
||||
border-top: 1px solid var(--tv-line);
|
||||
}
|
||||
|
||||
.lightbox__meta span {
|
||||
color: var(--tv-dim);
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.lightbox__toast {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
bottom: 16px;
|
||||
background: rgba(15, 12, 9, 0.92);
|
||||
border: 1px solid rgba(232, 221, 208, 0.2);
|
||||
border-radius: 999px;
|
||||
padding: 7px 14px;
|
||||
font-family: var(--tv-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--tv-text);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
pointer-events: none;
|
||||
animation: lb-toast-in 0.22s ease;
|
||||
}
|
||||
|
||||
@keyframes lb-toast-in {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
SCROLL REVEAL
|
||||
═══════════════════════════════════════════════════ */
|
||||
[data-reveal] {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
transition: opacity 0.6s ease, transform 0.6s ease;
|
||||
}
|
||||
|
||||
[data-reveal][data-revealed='true'] {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
RESPONSIVE
|
||||
═══════════════════════════════════════════════════ */
|
||||
@media (max-width: 900px) {
|
||||
@media (max-width: 768px) {
|
||||
.tv-header {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@media (max-width: 480px) {
|
||||
.travel {
|
||||
gap: 28px;
|
||||
}
|
||||
@@ -986,41 +252,9 @@
|
||||
font-size: clamp(40px, 12vw, 60px);
|
||||
}
|
||||
|
||||
.lightbox__topbar {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.lightbox__controls {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lightbox__stage {
|
||||
grid-template-columns: 44px 1fr 44px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.lightbox__frame {
|
||||
height: clamp(240px, 50vh, 480px);
|
||||
}
|
||||
|
||||
.filmstrip__frame {
|
||||
width: 56px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.photo-mosaic {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.photo-card--hero {
|
||||
grid-column: span 2;
|
||||
grid-row: span 1;
|
||||
}
|
||||
|
||||
.photo-card--wide {
|
||||
grid-column: span 2;
|
||||
.tv-albums__grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1028,19 +262,8 @@
|
||||
REDUCED MOTION
|
||||
═══════════════════════════════════════════════════ */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.photo-card,
|
||||
[data-reveal] {
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.lightbox__photo.slide-next,
|
||||
.lightbox__photo.slide-prev {
|
||||
.tv-state__loader span {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.photo-card img {
|
||||
transition: none !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
28
src/pages/travel/VideoTab.css
Normal file
28
src/pages/travel/VideoTab.css
Normal file
@@ -0,0 +1,28 @@
|
||||
/* ── VideoTab placeholder ── */
|
||||
|
||||
.video-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 300px;
|
||||
gap: 12px;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.video-tab__icon {
|
||||
color: var(--tv-dim);
|
||||
}
|
||||
|
||||
.video-tab__title {
|
||||
margin: 0;
|
||||
font: 600 20px/1.3 var(--tv-serif);
|
||||
color: var(--tv-text);
|
||||
}
|
||||
|
||||
.video-tab__desc {
|
||||
margin: 0;
|
||||
font: 11px var(--tv-mono);
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--tv-muted);
|
||||
}
|
||||
44
src/pages/travel/VideoTab.jsx
Normal file
44
src/pages/travel/VideoTab.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import './VideoTab.css';
|
||||
|
||||
export default function VideoTab() {
|
||||
return (
|
||||
<div className="video-tab">
|
||||
<svg
|
||||
className="video-tab__icon"
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 48 48"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect
|
||||
x="4"
|
||||
y="10"
|
||||
width="30"
|
||||
height="28"
|
||||
rx="4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M34 18l10-6v24l-10-6V18z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="19" cy="24" r="6" stroke="currentColor" strokeWidth="2" />
|
||||
<path
|
||||
d="M17 24l4-2.5v5L17 24z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<h2 className="video-tab__title">영상 기능 준비 중</h2>
|
||||
<p className="video-tab__desc">
|
||||
여행 영상을 감상할 수 있는 기능이 곧 추가됩니다.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
368
src/pages/travel/useTravelData.js
Normal file
368
src/pages/travel/useTravelData.js
Normal file
@@ -0,0 +1,368 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Constants
|
||||
───────────────────────────────────────────── */
|
||||
const PAGE_SIZE = 20;
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Utility — normalise raw API items to a
|
||||
consistent photo shape
|
||||
───────────────────────────────────────────── */
|
||||
export const normalizePhotos = (items = []) =>
|
||||
items
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') return { src: item, title: '', original: item, file: '', album: '' };
|
||||
if (!item) return null;
|
||||
return {
|
||||
src: item.thumb || item.url || item.path || item.src || '',
|
||||
title: item.title || item.name || item.file || '',
|
||||
original: item.url || item.path || item.src || '',
|
||||
file: item.file || '',
|
||||
album: item.album || '',
|
||||
};
|
||||
})
|
||||
.filter((item) => item && item.src);
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Internal helper — parse fetch JSON to
|
||||
normalised photo list + summary metadata
|
||||
───────────────────────────────────────────── */
|
||||
const parsePhotoResponse = (json) => {
|
||||
const items = Array.isArray(json) ? json : json.items ?? [];
|
||||
const meta = Array.isArray(json) ? {} : json ?? {};
|
||||
const normalized = normalizePhotos(items);
|
||||
const hasNext =
|
||||
typeof meta.has_next === 'boolean'
|
||||
? meta.has_next
|
||||
: typeof meta.hasNext === 'boolean'
|
||||
? meta.hasNext
|
||||
: normalized.length >= PAGE_SIZE;
|
||||
const summary =
|
||||
meta && (Object.prototype.hasOwnProperty.call(meta, 'total') ||
|
||||
Object.prototype.hasOwnProperty.call(meta, 'matched_albums'))
|
||||
? { total: meta.total, albums: meta.matched_albums ?? [] }
|
||||
: null;
|
||||
return { normalized, hasNext, summary, matchedAlbums: meta.matched_albums ?? [] };
|
||||
};
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
useTravelData — data layer hook for the
|
||||
Travel gallery page
|
||||
───────────────────────────────────────────── */
|
||||
const useTravelData = () => {
|
||||
// ── Region & GeoJSON ─────────────────────
|
||||
const [regions, setRegions] = useState(null); // GeoJSON FeatureCollection
|
||||
const [selectedRegion, setSelectedRegion] = useState(null); // { id, name }
|
||||
|
||||
// ── Album list ───────────────────────────
|
||||
const [albums, setAlbums] = useState([]); // built from per-region page-1 fetch
|
||||
const [loadingAlbums, setLoadingAlbums] = useState(false);
|
||||
|
||||
// ── Photo list for selected album ────────
|
||||
const [photos, setPhotos] = useState([]);
|
||||
const [photoSummary, setPhotoSummary] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasNext, setHasNext] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// ── Internal refs ────────────────────────
|
||||
const pageRef = useRef(1);
|
||||
const currentAlbumRef = useRef(null); // { regionId, albumName }
|
||||
const cacheRef = useRef(new Map()); // photo data cache key: `${regionId}::${albumName}`
|
||||
const albumCacheRef = useRef(new Map()); // album metadata cache key: regionId
|
||||
const loadAbortRef = useRef(null); // AbortController for loadAlbumPhotos
|
||||
|
||||
/* ── Load GeoJSON regions once ──────────── */
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/travel/regions', { signal: controller.signal });
|
||||
if (!res.ok) throw new Error(`지역 정보 로딩 실패 (${res.status})`);
|
||||
const geojson = await res.json();
|
||||
setRegions(geojson);
|
||||
} catch (err) {
|
||||
if (err?.name !== 'AbortError') {
|
||||
setError(err?.message ?? String(err));
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
/* ── Build album list when regions arrive ── */
|
||||
useEffect(() => {
|
||||
if (!regions?.features?.length) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
(async () => {
|
||||
setLoadingAlbums(true);
|
||||
const builtAlbums = [];
|
||||
|
||||
for (const feature of regions.features) {
|
||||
if (controller.signal.aborted) break;
|
||||
const regionId = feature?.properties?.id;
|
||||
const regionName = feature?.properties?.name || regionId || '';
|
||||
if (!regionId) continue;
|
||||
|
||||
// Use cached album metadata if fresh
|
||||
const cached = albumCacheRef.current.get(regionId);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
||||
builtAlbums.push(...cached.albums);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/travel/photos?region=${encodeURIComponent(regionId)}&page=1&size=${PAGE_SIZE}`,
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
if (!res.ok) continue; // skip failed regions silently
|
||||
const json = await res.json();
|
||||
const { normalized, matchedAlbums } = parsePhotoResponse(json);
|
||||
|
||||
const regionAlbums = matchedAlbums.map((ma) => {
|
||||
// Find first photo that belongs to this album for coverThumb
|
||||
const cover = normalized.find((p) => p.album === ma.album);
|
||||
return {
|
||||
id: `${regionId}::${ma.album}`,
|
||||
name: ma.album,
|
||||
region: regionId,
|
||||
regionName,
|
||||
photoCount: ma.count ?? 0,
|
||||
coverThumb: cover?.src || '',
|
||||
};
|
||||
});
|
||||
|
||||
// If API returned no matched_albums, create a single implicit album
|
||||
if (regionAlbums.length === 0 && normalized.length > 0) {
|
||||
regionAlbums.push({
|
||||
id: `${regionId}::`,
|
||||
name: regionName,
|
||||
region: regionId,
|
||||
regionName,
|
||||
photoCount: normalized.length,
|
||||
coverThumb: normalized[0]?.src || '',
|
||||
});
|
||||
}
|
||||
|
||||
albumCacheRef.current.set(regionId, {
|
||||
timestamp: Date.now(),
|
||||
albums: regionAlbums,
|
||||
});
|
||||
builtAlbums.push(...regionAlbums);
|
||||
} catch (err) {
|
||||
if (err?.name === 'AbortError') break;
|
||||
// Non-fatal — continue with other regions
|
||||
}
|
||||
}
|
||||
|
||||
if (!controller.signal.aborted) {
|
||||
setAlbums(builtAlbums);
|
||||
setLoadingAlbums(false);
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [regions]);
|
||||
|
||||
/* ── loadAlbumPhotos — initial load ────── */
|
||||
const loadAlbumPhotos = useCallback(async (regionId, albumName) => {
|
||||
if (!regionId) return;
|
||||
|
||||
const cacheKey = `${regionId}::${albumName ?? ''}`;
|
||||
currentAlbumRef.current = { regionId, albumName };
|
||||
|
||||
// Check photo cache
|
||||
const cached = cacheRef.current.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
||||
setPhotos(cached.items);
|
||||
setPhotoSummary(cached.summary ?? null);
|
||||
pageRef.current = cached.page ?? 2;
|
||||
setHasNext(cached.hasNext ?? false);
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
setError('');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setLoadingMore(false);
|
||||
setError('');
|
||||
setPhotos([]);
|
||||
setPhotoSummary(null);
|
||||
setHasNext(false);
|
||||
pageRef.current = 1;
|
||||
|
||||
// Abort any in-flight loadAlbumPhotos request
|
||||
if (loadAbortRef.current) loadAbortRef.current.abort();
|
||||
const controller = new AbortController();
|
||||
loadAbortRef.current = controller;
|
||||
try {
|
||||
let url = `/api/travel/photos?region=${encodeURIComponent(regionId)}&page=1&size=${PAGE_SIZE}`;
|
||||
if (albumName) url += `&album=${encodeURIComponent(albumName)}`;
|
||||
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
if (!res.ok) throw new Error(`사진 로딩 실패 (${res.status})`);
|
||||
const json = await res.json();
|
||||
const { normalized, hasNext: hn, summary } = parsePhotoResponse(json);
|
||||
|
||||
// Filter by album name client-side when API doesn't support album param
|
||||
const filtered = albumName
|
||||
? normalized.filter((p) => !p.album || p.album === albumName)
|
||||
: normalized;
|
||||
|
||||
pageRef.current = 2;
|
||||
setPhotos(filtered);
|
||||
setPhotoSummary(summary);
|
||||
setHasNext(hn);
|
||||
|
||||
cacheRef.current.set(cacheKey, {
|
||||
timestamp: Date.now(),
|
||||
items: filtered,
|
||||
page: 2,
|
||||
hasNext: hn,
|
||||
summary,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err?.name === 'AbortError') return;
|
||||
setError(err?.message ?? String(err));
|
||||
setPhotos([]);
|
||||
setPhotoSummary(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/* ── loadMorePhotos — infinite scroll ──── */
|
||||
const loadMorePhotos = useCallback(async (regionId, albumName) => {
|
||||
const activeRegion = regionId ?? currentAlbumRef.current?.regionId;
|
||||
const activeAlbum = albumName ?? currentAlbumRef.current?.albumName;
|
||||
if (!activeRegion || loading || loadingMore || !hasNext) return;
|
||||
|
||||
setLoadingMore(true);
|
||||
setError('');
|
||||
|
||||
const moreController = new AbortController();
|
||||
try {
|
||||
let url = `/api/travel/photos?region=${encodeURIComponent(activeRegion)}&page=${pageRef.current}&size=${PAGE_SIZE}`;
|
||||
if (activeAlbum) url += `&album=${encodeURIComponent(activeAlbum)}`;
|
||||
|
||||
const res = await fetch(url, { signal: moreController.signal });
|
||||
if (!res.ok) throw new Error(`사진 로딩 실패 (${res.status})`);
|
||||
const json = await res.json();
|
||||
const { normalized, hasNext: hn, summary } = parsePhotoResponse(json);
|
||||
|
||||
// Filter by album name client-side
|
||||
const filtered = activeAlbum
|
||||
? normalized.filter((p) => !p.album || p.album === activeAlbum)
|
||||
: normalized;
|
||||
|
||||
const cacheKey = `${activeRegion}::${activeAlbum ?? ''}`;
|
||||
setPhotos((prev) => {
|
||||
const merged = [...prev, ...filtered];
|
||||
cacheRef.current.set(cacheKey, {
|
||||
timestamp: Date.now(),
|
||||
items: merged,
|
||||
page: pageRef.current + 1,
|
||||
hasNext: hn,
|
||||
summary: photoSummary ?? summary,
|
||||
});
|
||||
return merged;
|
||||
});
|
||||
if (!photoSummary && summary) setPhotoSummary(summary);
|
||||
setHasNext(hn);
|
||||
pageRef.current += 1;
|
||||
} catch (err) {
|
||||
setError(err?.message ?? String(err));
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [hasNext, loading, loadingMore, photoSummary]);
|
||||
|
||||
/* ── reloadAlbumPhotos — pull-to-refresh ─ */
|
||||
const reloadAlbumPhotos = useCallback(async (regionId, albumName) => {
|
||||
const activeRegion = regionId ?? currentAlbumRef.current?.regionId;
|
||||
const activeAlbum = albumName ?? currentAlbumRef.current?.albumName;
|
||||
if (!activeRegion) return;
|
||||
|
||||
const cacheKey = `${activeRegion}::${activeAlbum ?? ''}`;
|
||||
cacheRef.current.delete(cacheKey);
|
||||
|
||||
let url = `/api/travel/photos?region=${encodeURIComponent(activeRegion)}&page=1&size=${PAGE_SIZE}`;
|
||||
if (activeAlbum) url += `&album=${encodeURIComponent(activeAlbum)}`;
|
||||
|
||||
const reloadController = new AbortController();
|
||||
try {
|
||||
const res = await fetch(url, { signal: reloadController.signal });
|
||||
if (!res.ok) throw new Error(`사진 로딩 실패 (${res.status})`);
|
||||
const json = await res.json();
|
||||
const { normalized, hasNext: hn, summary } = parsePhotoResponse(json);
|
||||
|
||||
const filtered = activeAlbum
|
||||
? normalized.filter((p) => !p.album || p.album === activeAlbum)
|
||||
: normalized;
|
||||
|
||||
pageRef.current = 2;
|
||||
setPhotos(filtered);
|
||||
setHasNext(hn);
|
||||
cacheRef.current.set(cacheKey, {
|
||||
timestamp: Date.now(),
|
||||
items: filtered,
|
||||
page: 2,
|
||||
hasNext: hn,
|
||||
summary,
|
||||
});
|
||||
if (summary) setPhotoSummary(summary);
|
||||
} catch (err) {
|
||||
if (err?.name === 'AbortError') return;
|
||||
setError(err?.message ?? String(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
/* ── getFilteredAlbums — filter by region ─ */
|
||||
const getFilteredAlbums = useCallback(
|
||||
(regionId) => {
|
||||
if (!regionId) return albums;
|
||||
return albums.filter((a) => a.region === regionId);
|
||||
},
|
||||
[albums]
|
||||
);
|
||||
|
||||
return {
|
||||
// GeoJSON data
|
||||
regions,
|
||||
|
||||
// Album list
|
||||
albums,
|
||||
loadingAlbums,
|
||||
|
||||
// Region filter
|
||||
selectedRegion,
|
||||
setSelectedRegion,
|
||||
|
||||
// Photo data
|
||||
photos,
|
||||
photoSummary,
|
||||
|
||||
// Loading states
|
||||
loading,
|
||||
loadingMore,
|
||||
|
||||
// Error
|
||||
error,
|
||||
|
||||
// Pagination
|
||||
hasNext,
|
||||
|
||||
// Actions
|
||||
loadAlbumPhotos,
|
||||
loadMorePhotos,
|
||||
reloadAlbumPhotos,
|
||||
getFilteredAlbums,
|
||||
};
|
||||
};
|
||||
|
||||
export default useTravelData;
|
||||
Reference in New Issue
Block a user