Compare commits

35 Commits

Author SHA1 Message Date
27dca3df69 refactor(travel): Travel.jsx 리팩토링 — 컴포넌트 분리 + 앨범 카드 기반 UI
모놀리식 Travel.jsx(1024줄)를 정리하여 useTravelData, MiniMap,
AlbumCard, AlbumDetail 등 추출된 컴포넌트를 조합하는 깔끔한
메인 컨테이너로 교체. Travel.css에서 photo-mosaic, photo-card,
lightbox, filmstrip 등 개별 컴포넌트 CSS로 이동된 스타일 제거.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 01:28:27 +09:00
439844cd14 feat(travel): AlbumDetail 오버레이 — 사진/영상 탭 + 진입/이탈 애니메이션
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 01:25:30 +09:00
085481e104 feat(travel): HeroLightbox — shared element transition + 스와이프 탐색
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 01:22:49 +09:00
f9495f0c30 feat(travel): VideoTab 플레이스홀더 — 영상 탭 UI 셸
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 01:19:45 +09:00
4655e9ab3b feat(travel): MasonryGrid 컴포넌트 — CSS columns Masonry + 무한스크롤
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 01:19:41 +09:00
5efb9525d5 feat(travel): AlbumCard 컴포넌트 — 대표사진 + 그라디언트 + 메타정보
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 01:19:37 +09:00
201601dc95 feat(travel): MiniMap 컴포넌트 — 접기/펼치기 + 전체보기
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 01:19:33 +09:00
1072a5eb21 fix(travel): useTravelData AbortController 및 에러 핸들링 보완
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 01:14:54 +09:00
c9df3e0e88 feat(travel): useTravelData 훅 추출 — API/캐싱/페이지네이션 로직 분리
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 01:13:11 +09:00
6ef687378d fix(components): CSS 변수명 수정 + dead code 제거
- --border-line → --line (5개 컴포넌트 8곳)
- --gradient-accent → --grad-accent (FAB)
- --text-default → --text (MobileSheet)
- useSwipe.js 삭제 (미사용 dead code)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 15:15:55 +09:00
ca9929faac fix(a11y): 글로벌 prefers-reduced-motion 추가 + Blog 버튼 위치 수정
- App.css: 글로벌 reduced-motion 블록 (모든 animation/transition 비활성화)
- index.css: scroll-behavior: smooth → auto (reduced-motion)
- BlogMarketing.css: 스피너 reduced-motion 처리
- Blog.css: 플로팅 토글 버튼 bottom-nav 위로 재배치

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 15:12:24 +09:00
0198fec43c refactor(responsive): Phase 3 코드 품질 개선
- Blog/BlogMarketing/Subscription/MusicStudio: 미사용 useIsMobile 제거
- Subscription: 미사용 Link import 제거
- Blog.css: 중복 display:block 제거
- BlogMarketing: dead prop onGenerate 제거
- Todo: 카드 버튼 터치 타겟 26→36px 확대

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 15:06:56 +09:00
901cfd7e1b fix(responsive): Phase 3 spec compliance 수정
- Blog: 태그 필터 칩 바 모바일 가로 스크롤 추가
- BlogMarketing: FAB 전 탭에서 표시 + 대시보드 480px 1컬럼
- Subscription: PullToRefresh refreshKey 패턴 적용, FAB→공고 목록 탭 이동
- Todo: FAB 라벨 "할일 추가"로 spec 일치

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 15:02:12 +09:00
c7cad9da61 feat(effect-lab): 모바일 반응형 — SwordStream 터치 대응
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:55:50 +09:00
28a80b5bd7 feat(agent-office): 모바일 반응형 — 바텀시트 에이전트 상세
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:55:40 +09:00
00f8e00436 feat(todo): 모바일 반응형 — 스와이프 칸반 + FAB + 바텀시트 입력
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:55:05 +09:00
326d54c73f feat(music): 모바일 반응형 — FAB + 풀다운 리프레시 + 1컬럼 라이브러리
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:54:04 +09:00
5c10952e39 feat(subscription): 모바일 반응형 — 바텀시트 필터 + FAB
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:53:12 +09:00
2b826ed700 feat(blog): 모바일 반응형 — FAB + 풀다운 리프레시 + 칩 필터
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:52:36 +09:00
d5ef77ad17 fix(lotto): 모바일 볼 크기 36px→32px 수정 2026-04-23 14:49:06 +09:00
033b89f87d feat(travel): 모바일 반응형 — 풀다운 리프레시 + 풀스크린 라이트박스
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:47:01 +09:00
e7427ff1d5 feat(stock): 모바일 반응형 — 캐러셀 지표 + 스와이프 탭 + FAB
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:46:58 +09:00
fd13f65faa feat(lotto): 모바일 반응형 — 스와이프 탭 전환
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:46:54 +09:00
2c2011659a feat(home): 모바일 반응형 — 스와이프 TODO + 풀다운 리프레시
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:46:51 +09:00
0922261c74 feat: 앱 셸 모바일 레이아웃 — BottomNav 통합 + 사이드바 조건부 렌더링
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:38:49 +09:00
d53108f1c9 feat: MobileSheet 바텀시트 모달 컴포넌트 2026-04-23 14:36:43 +09:00
80921563be feat: FAB 플로팅 액션 버튼 컴포넌트 2026-04-23 14:36:38 +09:00
6875a28e92 feat: SwipeableView 스와이프 탭 전환 컴포넌트 2026-04-23 14:36:35 +09:00
2db0c1b3eb feat: PullToRefresh 풀다운 새로고침 컴포넌트 2026-04-23 14:36:32 +09:00
bce5ae9fac feat: BottomNav 모바일 하단 네비게이션 컴포넌트 2026-04-23 14:34:32 +09:00
a053cf2d71 feat: react-swipeable 설치 + useIsMobile/useSwipe 훅 추가
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:33:15 +09:00
08efaa722a style(responsive): standardize RealEstate breakpoints
- RealEstate.css: 1100px → 1024px; merge 900px into 768px block

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 14:30:19 +09:00
2cdecd918e style(responsive): standardize Subscription, MusicStudio, BlogMarketing breakpoints
- Subscription.css: 1100px → 1024px; merge 900px into 768px block
- MusicStudio.css: 960px → 1024px; both 640px blocks → 480px
- BlogMarketing.css: 640px → 480px

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 14:30:07 +09:00
1e60524cfc style(responsive): standardize breakpoints for Home, Lotto, Travel, Blog
- Home.css: 960px → 1024px
- Lotto.css: merge 900px into 768px block; both 640px blocks → 480px
- Travel.css: merge 900px into 768px block; both 640px blocks → 480px
- Blog.css: merge 900px into 768px block (preserving all styles)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 14:29:34 +09:00
75d1558508 style(responsive): add viewport-fit=cover and safe area CSS variables
Add viewport-fit=cover to meta tag for notched devices.
Add --bottom-nav-h / --safe-area-bottom tokens and body padding-bottom
for mobile bottom navigation safe area support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 14:28:22 +09:00
55 changed files with 3938 additions and 2058 deletions

View File

@@ -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
View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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;
}
}

View File

@@ -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>
);
}

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

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

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

View 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>
</>
);
}

View File

@@ -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;
}
}

View File

@@ -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>
);
};

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

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

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

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

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View File

@@ -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>
);
}

View File

@@ -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; }
}

View File

@@ -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>
);
}

View File

@@ -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;
}
}

View File

@@ -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>
);
};

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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">

View File

@@ -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>
);
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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>
);
};

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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>
);
}

View File

@@ -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 {

View File

@@ -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>
);
};

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

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

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

View 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}
/>
)}
</>
);
}

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

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

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

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

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

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

View File

@@ -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

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

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

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