Phase 1a: breakpoint 통일 (Task 1-4) Phase 1b: 공통 컴포넌트 + 앱 셸 (Task 5-12) Phase 2: 주요 4페이지 (Task 13-16) Phase 3: 나머지 페이지 (Task 17-22) Phase 4: 검증 (Task 23) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
59 KiB
반응형 웹 UI/UX 전면 개선 구현 계획
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 14개 뷰 전체에 모바일 반응형 + 풀 모바일 UX 패턴(바텀네비, 스와이프, 풀다운 리프레시, FAB, 바텀시트) 적용
Architecture: 공통 모바일 인프라(컴포넌트 5개 + 훅 2개)를 먼저 구축한 뒤, 주요 4개 페이지 → 나머지 페이지 순으로 적용. 기존 사이드바 네비게이션은 모바일에서 바텀 네비게이션으로 대체.
Tech Stack: React 18, Vite, react-swipeable, 커스텀 CSS (디자인 토큰 기반)
Spec: docs/superpowers/specs/2026-04-23-responsive-web-design.md
작업 대상 리포지토리: C:\Users\jaeoh\Desktop\workspace\web-ui (프론트엔드 별도 Git)
File Structure
신규 생성 파일
| 파일 | 역할 |
|---|---|
src/hooks/useIsMobile.js |
768px 이하 감지 훅 (matchMedia) |
src/hooks/useSwipe.js |
react-swipeable 래핑 훅 |
src/components/BottomNav.jsx |
모바일 하단 네비게이션 |
src/components/BottomNav.css |
바텀네비 스타일 |
src/components/PullToRefresh.jsx |
풀다운 새로고침 래퍼 |
src/components/PullToRefresh.css |
풀다운 스타일 |
src/components/SwipeableView.jsx |
좌우 스와이프 탭 전환 |
src/components/SwipeableView.css |
스와이프 뷰 스타일 |
src/components/FAB.jsx |
플로팅 액션 버튼 |
src/components/FAB.css |
FAB 스타일 |
src/components/MobileSheet.jsx |
바텀시트 모달 |
src/components/MobileSheet.css |
바텀시트 스타일 |
수정 파일
| 파일 | 수정 내용 |
|---|---|
index.html |
viewport-fit=cover 추가 |
src/index.css |
breakpoint CSS 변수, safe-area 변수 |
src/App.jsx |
BottomNav 조건부 렌더링 |
src/App.css |
앱 셸 모바일 레이아웃 (padding-bottom 등) |
src/components/Navbar.jsx |
모바일 사이드바/햄버거 제거 |
src/components/Navbar.css |
모바일 미디어쿼리 정리 |
src/pages/home/Home.jsx |
SwipeableView, PullToRefresh 적용 |
src/pages/home/Home.css |
breakpoint 통일 + 모바일 레이아웃 |
src/pages/lotto/Lotto.jsx (또는 Functions.jsx) |
탭 스와이프, FAB 적용 |
src/pages/lotto/Lotto.css |
breakpoint 통일 + 모바일 레이아웃 |
src/pages/stock/Stock.jsx |
필터 칩, FAB 적용 |
src/pages/stock/Stock.css |
breakpoint 통일 (예외 유지) + 모바일 |
src/pages/stock/StockTrade.jsx |
카드형 리스트, FAB 적용 |
src/pages/travel/Travel.jsx |
풀스크린 뷰어, PullToRefresh |
src/pages/travel/Travel.css |
breakpoint 통일 + 모바일 |
src/pages/blog/Blog.jsx |
FAB, PullToRefresh |
src/pages/blog/Blog.css |
breakpoint 통일 |
src/pages/blog-marketing/BlogMarketing.jsx |
FAB, PullToRefresh |
src/pages/blog-marketing/BlogMarketing.css |
모바일 개선 |
src/pages/subscription/Subscription.jsx |
FAB, MobileSheet |
src/pages/subscription/Subscription.css |
breakpoint 통일 |
src/pages/music/MusicStudio.jsx |
FAB, PullToRefresh |
src/pages/music/MusicStudio.css |
breakpoint 통일 |
src/pages/todo/Todo.jsx |
SwipeableView, FAB, MobileSheet |
src/pages/todo/Todo.css |
스와이프 탭 |
src/pages/agent-office/AgentOffice.jsx |
MobileSheet |
src/pages/agent-office/AgentOffice.css |
모바일 개선 |
src/pages/effect-lab/EffectLab.css |
모바일 개선 |
src/pages/effect-lab/DayCalc.css |
모바일 개선 |
src/pages/effect-lab/SwordStream.css |
모바일 미디어쿼리 추가 |
package.json |
react-swipeable 의존성 추가 |
Phase 1a: Breakpoint 정리
Task 1: viewport-fit 및 글로벌 CSS 변수 추가
Files:
-
Modify:
index.html:6 -
Modify:
src/index.css:15-105 -
Step 1: index.html에 viewport-fit=cover 추가
<!-- 기존 (line 6) -->
<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" />
- Step 2: index.css에 breakpoint 및 safe-area CSS 변수 추가
src/index.css의 :root 블록(line 15) 안에 layout tokens 섹션(line 73-74) 뒤에 추가:
/* ── Layout ── */
--sidebar-w: 240px;
--topbar-h: 56px;
--bottom-nav-h: 64px;
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
- Step 3: 모바일 body 스타일에 safe-area 패딩 추가
src/index.css line 239-244의 모바일 미디어쿼리를 확장:
@media (max-width: 768px) {
body {
overflow: auto;
background-attachment: scroll;
padding-bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom));
}
}
- Step 4: 개발 서버 실행 후 데스크톱/모바일 확인
Run: cd C:\Users\jaeoh\Desktop\workspace\web-ui && npm run dev
DevTools에서 375px, 768px, 1024px 뷰포트로 확인. 기존 레이아웃에 변화 없어야 함.
- Step 5: 커밋
cd C:\Users\jaeoh\Desktop\workspace\web-ui
git add index.html src/index.css
git commit -m "feat: viewport-fit=cover 및 모바일 CSS 변수 추가"
Task 2: Breakpoint 통일 — Home, Lotto, Travel, Blog
Files:
-
Modify:
src/pages/home/Home.css:730(960px → 1024px) -
Modify:
src/pages/lotto/Lotto.css:1077(900px → 768px) -
Modify:
src/pages/travel/Travel.css:969(900px → 768px) -
Modify:
src/pages/blog/Blog.css:454(900px → 768px) -
Step 1: Home.css — 960px → 1024px로 변경
src/pages/home/Home.css line 730:
/* 기존 */
@media (max-width: 960px) {
/* 변경 */
@media (max-width: 1024px) {
- Step 2: Lotto.css — 900px → 768px로 변경
src/pages/lotto/Lotto.css line 1077:
/* 기존 */
@media (max-width: 900px) {
/* 변경 */
@media (max-width: 768px) {
주의: 이 블록 내의 스타일이 기존 768px 블록(line 1159)과 충돌하지 않는지 확인. 충돌 시 두 블록을 병합한다.
- Step 3: Travel.css — 900px → 768px로 변경
src/pages/travel/Travel.css line 969:
/* 기존 */
@media (max-width: 900px) {
/* 변경 */
@media (max-width: 768px) {
기존 640px 블록(line 975)과 겹치지 않는지 확인.
- Step 4: Blog.css — 900px → 768px로 변경
src/pages/blog/Blog.css line 454:
/* 기존 */
@media (max-width: 900px) {
/* 변경 */
@media (max-width: 768px) {
기존 768px 블록(line 504)과 병합 필요 시 병합.
- Step 5: 각 페이지를 DevTools 768px/1024px에서 확인
각 페이지가 기존과 동일하게 렌더링되는지 확인. 특히:
-
Home: 히어로 그리드 전환 시점
-
Lotto: 헤더/분석 카드 1컬럼 전환 시점
-
Travel: 헤더 레이아웃 전환 시점
-
Blog: 사이드 목록 오버레이 전환 시점
-
Step 6: 커밋
git add src/pages/home/Home.css src/pages/lotto/Lotto.css src/pages/travel/Travel.css src/pages/blog/Blog.css
git commit -m "refactor: Home/Lotto/Travel/Blog breakpoint 표준화 (960→1024, 900→768)"
Task 3: Breakpoint 통일 — Subscription, MusicStudio, Lotto(640px)
Files:
-
Modify:
src/pages/subscription/Subscription.css:1142(1100px → 1024px) -
Modify:
src/pages/subscription/Subscription.css:1146(900px → 768px) -
Modify:
src/pages/music/MusicStudio.css:320(960px → 1024px) -
Modify:
src/pages/lotto/Lotto.css:1111,1462(640px → 480px) -
Modify:
src/pages/music/MusicStudio.css:490,640,1699(640px → 480px) -
Modify:
src/pages/travel/Travel.css:349,975(640px → 480px) -
Modify:
src/pages/blog-marketing/BlogMarketing.css:128(640px → 480px) -
Step 1: Subscription.css — 1100px → 1024px, 900px → 768px
/* line 1142: 1100px → 1024px */
@media (max-width: 1024px) {
/* line 1146: 900px → 768px */
@media (max-width: 768px) {
기존 768px 블록(line 1154)과 병합 필요 시 병합.
- Step 2: MusicStudio.css — 960px → 1024px
src/pages/music/MusicStudio.css line 320:
/* 기존 */
@media (max-width: 960px) {
/* 변경 */
@media (max-width: 1024px) {
- Step 3: 640px → 480px 일괄 변경
각 파일의 640px 미디어쿼리를 480px로 변경:
Lotto.cssline 1111, 1462MusicStudio.cssline 490, 640, 1699Travel.cssline 349, 975BlogMarketing.cssline 128
각 파일에서:
/* 기존 */
@media (max-width: 640px) {
/* 변경 */
@media (max-width: 480px) {
-
Step 4: 각 페이지 480px/768px/1024px에서 확인
-
Step 5: 커밋
git add src/pages/subscription/Subscription.css src/pages/music/MusicStudio.css src/pages/lotto/Lotto.css src/pages/travel/Travel.css src/pages/blog-marketing/BlogMarketing.css
git commit -m "refactor: Subscription/Music/Lotto/Travel/BlogMarketing breakpoint 표준화"
Task 4: RealEstate.css breakpoint 통일 (routes.jsx 미등록이지만 CSS는 존재)
Files:
-
Modify:
src/pages/realestate/RealEstate.css:955,961 -
Step 1: RealEstate.css — 1100px → 1024px, 900px → 768px
/* line 955 */
@media (max-width: 1024px) {
/* line 961 */
@media (max-width: 768px) {
- Step 2: 커밋
git add src/pages/realestate/RealEstate.css
git commit -m "refactor: RealEstate breakpoint 표준화 (1100→1024, 900→768)"
Note: Stock.css의 420px/520px/700px은 spec에 따라 예외로 유지.
Phase 1b: 공통 컴포넌트 & 앱 셸
Task 5: react-swipeable 설치 + useIsMobile 훅
Files:
-
Modify:
package.json -
Create:
src/hooks/useIsMobile.js -
Step 1: react-swipeable 설치
cd C:\Users\jaeoh\Desktop\workspace\web-ui
npm install react-swipeable
- Step 2: useIsMobile 훅 작성
// src/hooks/useIsMobile.js
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;
}
- Step 3: 커밋
git add package.json package-lock.json src/hooks/useIsMobile.js
git commit -m "feat: react-swipeable 설치 + useIsMobile 훅 추가"
Task 6: useSwipe 훅
Files:
-
Create:
src/hooks/useSwipe.js -
Step 1: useSwipe 훅 작성
// src/hooks/useSwipe.js
import { useSwipeable } from 'react-swipeable';
/**
* 스와이프 방향 감지 훅
* @param {Object} options
* @param {Function} options.onSwipedLeft - 왼쪽 스와이프 콜백
* @param {Function} options.onSwipedRight - 오른쪽 스와이프 콜백
* @param {number} options.threshold - 스와이프 감지 최소 거리 (기본 50px)
* @returns {Object} swipeHandlers - DOM 요소에 spread할 핸들러
*/
export function useSwipe({ onSwipedLeft, onSwipedRight, threshold = 50 } = {}) {
const handlers = useSwipeable({
onSwipedLeft,
onSwipedRight,
delta: threshold,
trackMouse: false,
preventScrollOnSwipe: true,
});
return handlers;
}
- Step 2: 커밋
git add src/hooks/useSwipe.js
git commit -m "feat: useSwipe 훅 추가 (react-swipeable 기반)"
Task 7: BottomNav 컴포넌트
Files:
-
Create:
src/components/BottomNav.jsx -
Create:
src/components/BottomNav.css -
Step 1: BottomNav.css 작성
/* src/components/BottomNav.css */
.bottom-nav {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: var(--bottom-nav-h, 64px);
padding-bottom: var(--safe-area-bottom, 0px);
background: var(--bg-secondary);
border-top: 1px solid var(--border-line);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
z-index: 300;
}
@media (max-width: 768px) {
.bottom-nav {
display: flex;
align-items: center;
justify-content: space-around;
}
}
.bottom-nav__item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
min-width: 48px;
min-height: 48px;
padding: 6px 12px;
background: none;
border: none;
color: var(--text-dim);
font-size: 10px;
font-family: var(--font-body);
cursor: pointer;
transition: color 0.2s var(--ease-out);
-webkit-tap-highlight-color: transparent;
text-decoration: none;
}
.bottom-nav__item:hover,
.bottom-nav__item.is-active {
color: var(--neon-cyan);
}
.bottom-nav__item.is-active .bottom-nav__icon {
filter: drop-shadow(0 0 6px var(--neon-cyan));
}
.bottom-nav__icon {
width: 22px;
height: 22px;
transition: filter 0.2s var(--ease-out);
}
.bottom-nav__label {
font-size: 10px;
line-height: 1;
white-space: nowrap;
}
/* 더보기 오버레이 */
.bottom-nav__more-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
z-index: 299;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s var(--ease-out);
}
.bottom-nav__more-overlay.is-visible {
opacity: 1;
pointer-events: auto;
}
.bottom-nav__more-panel {
position: fixed;
bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom, 0px));
left: 0;
right: 0;
background: var(--bg-secondary);
border-top: 1px solid var(--border-line);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
padding: 16px;
transform: translateY(100%);
transition: transform 0.3s var(--ease-out);
z-index: 301;
}
.bottom-nav__more-panel.is-visible {
transform: translateY(0);
}
.bottom-nav__more-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.bottom-nav__more-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px 8px;
background: var(--surface);
border: 1px solid var(--border-line);
border-radius: var(--radius-sm);
color: var(--text-default);
font-size: 11px;
text-decoration: none;
transition: background 0.2s, border-color 0.2s;
}
.bottom-nav__more-item:hover,
.bottom-nav__more-item.is-active {
background: var(--surface-raised);
border-color: var(--neon-cyan-dim);
color: var(--neon-cyan);
}
.bottom-nav__more-icon {
width: 24px;
height: 24px;
}
@media (prefers-reduced-motion: reduce) {
.bottom-nav__more-panel,
.bottom-nav__more-overlay {
transition: none;
}
}
- Step 2: BottomNav.jsx 작성
// src/components/BottomNav.jsx
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'];
export default function BottomNav() {
const [moreOpen, setMoreOpen] = useState(false);
const location = useLocation();
const toggleMore = useCallback(() => setMoreOpen(v => !v), []);
const closeMore = useCallback(() => setMoreOpen(false), []);
const primaryLinks = navLinks.filter(l => PRIMARY_PATHS.includes(l.path));
const moreLinks = navLinks.filter(l => !PRIMARY_PATHS.includes(l.path));
const isMoreActive = moreLinks.some(l => location.pathname.startsWith(l.path));
return (
<>
<div
className={`bottom-nav__more-overlay ${moreOpen ? 'is-visible' : ''}`}
onClick={closeMore}
/>
<div className={`bottom-nav__more-panel ${moreOpen ? 'is-visible' : ''}`}>
<div className="bottom-nav__more-grid">
{moreLinks.map(link => (
<NavLink
key={link.id}
to={link.path}
className={({ isActive }) =>
`bottom-nav__more-item ${isActive ? 'is-active' : ''}`
}
onClick={closeMore}
>
<span className="bottom-nav__more-icon">{link.icon}</span>
<span>{link.label}</span>
</NavLink>
))}
</div>
</div>
<nav className="bottom-nav">
{primaryLinks.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>
))}
<button
className={`bottom-nav__item ${isMoreActive || moreOpen ? 'is-active' : ''}`}
onClick={toggleMore}
aria-label="더보기 메뉴"
>
<span className="bottom-nav__icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="5" r="1.5"/>
<circle cx="12" cy="12" r="1.5"/>
<circle cx="12" cy="19" r="1.5"/>
</svg>
</span>
<span className="bottom-nav__label">더보기</span>
</button>
</nav>
</>
);
}
- Step 3: 데스크톱에서 바텀네비가 숨겨지는지 확인
DevTools에서 1024px → 바텀네비 display: none, 768px 이하 → display: flex 확인.
- Step 4: 커밋
git add src/components/BottomNav.jsx src/components/BottomNav.css
git commit -m "feat: BottomNav 모바일 하단 네비게이션 컴포넌트"
Task 8: PullToRefresh 컴포넌트
Files:
-
Create:
src/components/PullToRefresh.jsx -
Create:
src/components/PullToRefresh.css -
Step 1: PullToRefresh.css 작성
/* src/components/PullToRefresh.css */
.pull-to-refresh {
position: relative;
overscroll-behavior-y: contain;
}
.pull-to-refresh__indicator {
position: absolute;
top: -48px;
left: 50%;
transform: translateX(-50%);
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--surface-card);
border: 1px solid var(--border-line);
box-shadow: var(--shadow-md);
transition: transform 0.2s var(--ease-out), opacity 0.2s;
opacity: 0;
z-index: 10;
}
.pull-to-refresh__indicator.is-pulling {
opacity: 1;
}
.pull-to-refresh__indicator.is-refreshing {
opacity: 1;
transform: translateX(-50%) translateY(56px);
}
.pull-to-refresh__spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border-line);
border-top-color: var(--neon-cyan);
border-radius: 50%;
animation: ptr-spin 0.8s linear infinite;
}
.pull-to-refresh__arrow {
width: 18px;
height: 18px;
color: var(--neon-cyan);
transition: transform 0.2s;
}
.pull-to-refresh__arrow.is-ready {
transform: rotate(180deg);
}
@keyframes ptr-spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.pull-to-refresh__spinner {
animation: none;
border-top-color: var(--neon-cyan);
opacity: 0.7;
}
.pull-to-refresh__indicator {
transition: none;
}
}
- Step 2: PullToRefresh.jsx 작성
// src/components/PullToRefresh.jsx
import { useState, useRef, useCallback } from 'react';
import { useIsMobile } from '../hooks/useIsMobile';
import './PullToRefresh.css';
const THRESHOLD = 60;
const MAX_PULL = 120;
export default function PullToRefresh({ onRefresh, children, className = '' }) {
const isMobile = useIsMobile();
const [pullDistance, setPullDistance] = useState(0);
const [refreshing, setRefreshing] = useState(false);
const startY = useRef(0);
const containerRef = useRef(null);
const handleTouchStart = useCallback((e) => {
if (containerRef.current?.scrollTop === 0) {
startY.current = e.touches[0].clientY;
}
}, []);
const handleTouchMove = useCallback((e) => {
if (!startY.current || refreshing) return;
const diff = e.touches[0].clientY - startY.current;
if (diff > 0 && containerRef.current?.scrollTop === 0) {
const distance = Math.min(diff * 0.5, MAX_PULL);
setPullDistance(distance);
}
}, [refreshing]);
const handleTouchEnd = useCallback(async () => {
if (pullDistance >= THRESHOLD && onRefresh) {
setRefreshing(true);
try {
await onRefresh();
} finally {
setRefreshing(false);
}
}
setPullDistance(0);
startY.current = 0;
}, [pullDistance, onRefresh]);
if (!isMobile) {
return <div className={className}>{children}</div>;
}
const isPulling = pullDistance > 10;
const isReady = pullDistance >= THRESHOLD;
return (
<div
ref={containerRef}
className={`pull-to-refresh ${className}`}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<div
className={`pull-to-refresh__indicator ${refreshing ? 'is-refreshing' : isPulling ? 'is-pulling' : ''}`}
style={isPulling && !refreshing ? { transform: `translateX(-50%) translateY(${pullDistance}px)` } : undefined}
>
{refreshing ? (
<div className="pull-to-refresh__spinner" />
) : (
<svg className={`pull-to-refresh__arrow ${isReady ? 'is-ready' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="6 9 12 15 18 9" />
</svg>
)}
</div>
<div style={isPulling && !refreshing ? { transform: `translateY(${pullDistance * 0.3}px)`, transition: 'none' } : undefined}>
{children}
</div>
</div>
);
}
- Step 3: 커밋
git add src/components/PullToRefresh.jsx src/components/PullToRefresh.css
git commit -m "feat: PullToRefresh 풀다운 새로고침 컴포넌트"
Task 9: SwipeableView 컴포넌트
Files:
-
Create:
src/components/SwipeableView.jsx -
Create:
src/components/SwipeableView.css -
Step 1: SwipeableView.css 작성
/* src/components/SwipeableView.css */
.swipeable-view {
overflow: hidden;
position: relative;
}
.swipeable-view__track {
display: flex;
transition: transform 0.3s var(--ease-out);
will-change: transform;
}
.swipeable-view__track.is-swiping {
transition: none;
}
.swipeable-view__panel {
flex: 0 0 100%;
min-width: 0;
overflow-y: auto;
}
.swipeable-view__tabs {
display: flex;
gap: 4px;
padding: 4px;
background: var(--surface);
border-radius: var(--radius-md);
border: 1px solid var(--border-line);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.swipeable-view__tabs::-webkit-scrollbar {
display: none;
}
.swipeable-view__tab {
flex: 1;
min-width: fit-content;
padding: 8px 16px;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--text-dim);
font-family: var(--font-body);
font-size: 13px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: background 0.2s, color 0.2s;
}
.swipeable-view__tab.is-active {
background: var(--surface-raised);
color: var(--neon-cyan);
}
@media (prefers-reduced-motion: reduce) {
.swipeable-view__track {
transition: none;
}
}
- Step 2: SwipeableView.jsx 작성
// src/components/SwipeableView.jsx
import { useState, useRef, useCallback } from 'react';
import { useSwipeable } from 'react-swipeable';
import { useIsMobile } from '../hooks/useIsMobile';
import './SwipeableView.css';
/**
* @param {Object} props
* @param {Array<{key: string, label: string, content: React.ReactNode}>} props.tabs
* @param {number} props.activeIndex - 외부 제어용 (optional)
* @param {Function} props.onTabChange - 탭 변경 콜백 (optional)
* @param {boolean} props.showTabs - 탭 바 표시 여부 (기본 true)
*/
export default function SwipeableView({ tabs, activeIndex: controlledIndex, onTabChange, showTabs = true }) {
const [internalIndex, setInternalIndex] = useState(0);
const [isSwiping, setIsSwiping] = useState(false);
const [swipeOffset, setSwipeOffset] = useState(0);
const isMobile = useIsMobile();
const activeIndex = controlledIndex ?? internalIndex;
const setActiveIndex = useCallback((idx) => {
const clamped = Math.max(0, Math.min(idx, tabs.length - 1));
setInternalIndex(clamped);
onTabChange?.(clamped);
}, [tabs.length, onTabChange]);
const handlers = useSwipeable({
onSwiping: (e) => {
if (!isMobile) return;
setIsSwiping(true);
setSwipeOffset(e.deltaX);
},
onSwipedLeft: () => {
if (!isMobile) return;
setIsSwiping(false);
setSwipeOffset(0);
if (activeIndex < tabs.length - 1) setActiveIndex(activeIndex + 1);
},
onSwipedRight: () => {
if (!isMobile) return;
setIsSwiping(false);
setSwipeOffset(0);
if (activeIndex > 0) setActiveIndex(activeIndex - 1);
},
onSwiped: () => {
setIsSwiping(false);
setSwipeOffset(0);
},
delta: 30,
trackMouse: false,
preventScrollOnSwipe: true,
});
const translateX = isSwiping
? -(activeIndex * 100) + (swipeOffset / (window.innerWidth || 1)) * 100
: -(activeIndex * 100);
return (
<div className="swipeable-view">
{showTabs && (
<div className="swipeable-view__tabs">
{tabs.map((tab, i) => (
<button
key={tab.key}
className={`swipeable-view__tab ${i === activeIndex ? 'is-active' : ''}`}
onClick={() => setActiveIndex(i)}
>
{tab.label}
</button>
))}
</div>
)}
<div {...handlers}>
<div
className={`swipeable-view__track ${isSwiping ? 'is-swiping' : ''}`}
style={{ transform: `translateX(${translateX}%)` }}
>
{tabs.map((tab) => (
<div key={tab.key} className="swipeable-view__panel">
{tab.content}
</div>
))}
</div>
</div>
</div>
);
}
- Step 3: 커밋
git add src/components/SwipeableView.jsx src/components/SwipeableView.css
git commit -m "feat: SwipeableView 스와이프 탭 전환 컴포넌트"
Task 10: FAB 컴포넌트
Files:
-
Create:
src/components/FAB.jsx -
Create:
src/components/FAB.css -
Step 1: FAB.css 작성
/* src/components/FAB.css */
.fab {
display: none;
position: fixed;
right: 20px;
bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px) + 16px);
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--gradient-accent);
border: none;
color: var(--bg);
font-size: 24px;
cursor: pointer;
box-shadow: 0 4px 20px rgba(0, 255, 255, 0.3);
z-index: 250;
transition: transform 0.2s var(--ease-out), box-shadow 0.2s;
-webkit-tap-highlight-color: transparent;
align-items: center;
justify-content: center;
}
@media (max-width: 768px) {
.fab {
display: flex;
}
}
.fab:active {
transform: scale(0.92);
}
.fab:hover {
box-shadow: 0 6px 28px rgba(0, 255, 255, 0.45);
}
.fab__icon {
width: 24px;
height: 24px;
}
/* FAB + 미니플레이어 공존 시 (뮤직 페이지) */
.fab--above-player {
bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px) + 56px + 16px);
}
@media (prefers-reduced-motion: reduce) {
.fab {
transition: none;
}
}
- Step 2: FAB.jsx 작성
// src/components/FAB.jsx
import { useIsMobile } from '../hooks/useIsMobile';
import './FAB.css';
/**
* @param {Object} props
* @param {Function} props.onClick
* @param {React.ReactNode} props.icon - 아이콘 (기본: + 아이콘)
* @param {string} props.label - 접근성 라벨
* @param {string} props.className - 추가 클래스 (e.g. 'fab--above-player')
*/
export default function FAB({ onClick, icon, label, className = '' }) {
const isMobile = useIsMobile();
if (!isMobile) return null;
return (
<button
className={`fab ${className}`}
onClick={onClick}
aria-label={label}
>
{icon || (
<svg className="fab__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
)}
</button>
);
}
- Step 3: 커밋
git add src/components/FAB.jsx src/components/FAB.css
git commit -m "feat: FAB 플로팅 액션 버튼 컴포넌트"
Task 11: MobileSheet 컴포넌트
Files:
-
Create:
src/components/MobileSheet.jsx -
Create:
src/components/MobileSheet.css -
Step 1: MobileSheet.css 작성
/* src/components/MobileSheet.css */
.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-visible {
opacity: 1;
pointer-events: auto;
}
.mobile-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
max-height: 90vh;
background: var(--bg-secondary);
border-top: 1px solid var(--border-line);
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
z-index: 401;
transform: translateY(100%);
transition: transform 0.3s var(--ease-out);
display: flex;
flex-direction: column;
touch-action: none;
}
.mobile-sheet.is-visible {
transform: translateY(0);
}
.mobile-sheet.snap-half {
max-height: 50vh;
}
.mobile-sheet__handle {
display: flex;
justify-content: center;
padding: 12px 0 8px;
cursor: grab;
flex-shrink: 0;
}
.mobile-sheet__handle-bar {
width: 36px;
height: 4px;
background: var(--text-muted);
border-radius: 2px;
}
.mobile-sheet__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px 12px;
border-bottom: 1px solid var(--border-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 {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
padding: 8px;
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
.mobile-sheet__body {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
padding-bottom: calc(16px + var(--safe-area-bottom, 0px));
overscroll-behavior: contain;
}
@media (prefers-reduced-motion: reduce) {
.mobile-sheet,
.mobile-sheet__backdrop {
transition: none;
}
}
- Step 2: MobileSheet.jsx 작성
// src/components/MobileSheet.jsx
import { useEffect, useCallback, useRef } from 'react';
import './MobileSheet.css';
/**
* @param {Object} props
* @param {boolean} props.open
* @param {Function} props.onClose
* @param {string} props.title
* @param {string} props.snap - 'full' | 'half' (기본 'full')
* @param {React.ReactNode} props.children
*/
export default function MobileSheet({ open, onClose, title, snap = 'full', children }) {
const sheetRef = useRef(null);
const startY = useRef(0);
const currentY = useRef(0);
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => { document.body.style.overflow = ''; };
}, [open]);
const handleTouchStart = useCallback((e) => {
startY.current = e.touches[0].clientY;
}, []);
const handleTouchMove = useCallback((e) => {
currentY.current = e.touches[0].clientY;
const diff = currentY.current - startY.current;
if (diff > 0 && sheetRef.current) {
sheetRef.current.style.transform = `translateY(${diff}px)`;
sheetRef.current.style.transition = 'none';
}
}, []);
const handleTouchEnd = useCallback(() => {
const diff = currentY.current - startY.current;
if (sheetRef.current) {
sheetRef.current.style.transition = '';
sheetRef.current.style.transform = '';
}
if (diff > 100) {
onClose();
}
startY.current = 0;
currentY.current = 0;
}, [onClose]);
return (
<>
<div
className={`mobile-sheet__backdrop ${open ? 'is-visible' : ''}`}
onClick={onClose}
/>
<div
ref={sheetRef}
className={`mobile-sheet ${open ? 'is-visible' : ''} ${snap === 'half' ? 'snap-half' : ''}`}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<div className="mobile-sheet__handle">
<div className="mobile-sheet__handle-bar" />
</div>
{title && (
<div className="mobile-sheet__header">
<span className="mobile-sheet__title">{title}</span>
<button className="mobile-sheet__close" onClick={onClose} aria-label="닫기">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
)}
<div className="mobile-sheet__body">
{children}
</div>
</div>
</>
);
}
- Step 3: 커밋
git add src/components/MobileSheet.jsx src/components/MobileSheet.css
git commit -m "feat: MobileSheet 바텀시트 모달 컴포넌트"
Task 12: 앱 셸 수정 — BottomNav 통합 + 사이드바 모바일 제거
Files:
-
Modify:
src/App.jsx -
Modify:
src/App.css:45-49,62-66,472-493 -
Modify:
src/components/Navbar.jsx:7-16,20-40 -
Modify:
src/components/Navbar.css:335-359 -
Step 1: App.jsx에 BottomNav 추가
src/App.jsx를 수정:
import { Suspense } from 'react';
import { Outlet, useLocation } 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';
export default function App() {
const isMobile = useIsMobile();
return (
<div className="app-shell">
<Navbar />
<div className="app-content">
<PageHeader />
<main className="site-main">
<Suspense fallback={<Loading className="suspend-loading" />}>
<Outlet />
</Suspense>
</main>
</div>
{isMobile && <BottomNav />}
</div>
);
}
- Step 2: App.css 모바일 레이아웃 수정
src/App.css에서 line 62-66의 모바일 미디어쿼리를 수정:
/* 기존 768px 미디어쿼리에 padding-bottom 추가 */
@media (max-width: 768px) {
.site-main {
padding: 16px;
padding-bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px) + 16px);
}
}
- Step 3: Navbar.jsx에서 모바일 햄버거/오버레이 제거
src/components/Navbar.jsx를 수정 — 모바일 토글/오버레이 관련 코드를 useIsMobile로 조건부 처리:
import { useState, useEffect } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { navLinks } from '../routes';
import { useIsMobile } from '../hooks/useIsMobile';
import './Navbar.css';
export default function Navbar() {
const [menuOpen, setMenuOpen] = useState(false);
const closeMenu = () => setMenuOpen(false);
const location = useLocation();
const isMobile = useIsMobile();
useEffect(() => {
if (menuOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => { document.body.style.overflow = ''; };
}, [menuOpen]);
useEffect(() => {
closeMenu();
}, [location.pathname]);
// 모바일에서는 사이드바를 렌더링하지 않음 (BottomNav가 대체)
if (isMobile) return null;
return (
<aside className="sidebar">
<div className="sidebar__brand">
<img src="/logo.webp" alt="" className="sidebar__logo" />
<div>
<span className="sidebar__name">gahusb</span>
<span className="sidebar__tagline">Personal Lab</span>
</div>
</div>
<div className="sidebar__divider" />
<nav className="sidebar__nav">
{navLinks.map(link => (
<NavLink
key={link.id}
to={link.path}
end={link.path === '/'}
className={({ isActive }) =>
`sidebar__item ${isActive ? 'is-active' : ''}`
}
>
<span className="sidebar__icon">{link.icon}</span>
<span className="sidebar__label">{link.label}</span>
{link.subtitle && <span className="sidebar__subtitle">{link.subtitle}</span>}
</NavLink>
))}
</nav>
<div className="sidebar__footer">
<span className="sidebar__status" />
<span className="sidebar__version">v2.0</span>
</div>
</aside>
);
}
Note: 기존 Navbar.jsx의 정확한 JSX 구조(link.icon, link.subtitle 등)는 실제 파일을 읽어서 맞춰야 함. 위 코드는 탐색 결과 기반 근사치.
- Step 4: Navbar.css 모바일 미디어쿼리 정리
모바일 관련 미디어쿼리(line 335-359)에서 .sidebar-toggle, .sidebar__overlay 스타일은 더 이상 사용되지 않으므로 제거하거나 유지해도 무방 (컴포넌트가 렌더링되지 않으므로).
정리를 위해 제거 권장:
/* 기존 (lines 335-359) — 전체 삭제 가능 */
/* @media (max-width: 768px) { ... } */
/* @media (min-width: 769px) { ... } */
대신 데스크톱 전용 사이드바만 유지:
/* Navbar.css 말미 — 사이드바는 데스크톱 전용 */
@media (max-width: 768px) {
.sidebar {
display: none;
}
}
-
Step 5: 데스크톱/모바일에서 확인
-
데스크톱: 사이드바 정상 표시, 바텀네비 숨김
-
모바일 (768px 이하): 사이드바 완전 숨김, 바텀네비 표시
-
더보기 메뉴 열기/닫기 동작
-
네비게이션 링크 클릭 시 라우팅 정상 동작
-
Step 6: 커밋
git add src/App.jsx src/App.css src/components/Navbar.jsx src/components/Navbar.css
git commit -m "feat: 앱 셸 모바일 레이아웃 — BottomNav 통합 + 사이드바 조건부 렌더링"
Phase 2: 주요 페이지 적용
Task 13: 홈 페이지 모바일 개선
Files:
-
Modify:
src/pages/home/Home.jsx -
Modify:
src/pages/home/Home.css -
Step 1: Home.css 모바일 레이아웃 보강
기존 768px 미디어쿼리(line 740)에 다음을 추가/수정:
@media (max-width: 768px) {
/* 기존 스타일 유지하면서 추가 */
.home__hero-grid {
grid-template-columns: 1fr;
}
.home__nav-grid {
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.home__nav-card {
min-height: 80px;
}
.home__todo-board {
/* 스와이프 탭으로 대체되므로 3컬럼 그리드 숨김 */
display: none;
}
.home__todo-swipe {
display: block;
}
.home__blog-grid {
grid-template-columns: 1fr;
}
.home__profile {
margin-top: 16px;
}
}
/* 데스크톱에서는 스와이프 뷰 숨김 */
.home__todo-swipe {
display: none;
}
- Step 2: Home.jsx에 SwipeableView + PullToRefresh 적용
TODO 보드 영역에 모바일 전용 SwipeableView 추가:
import { useIsMobile } from '../../hooks/useIsMobile';
import SwipeableView from '../../components/SwipeableView';
import PullToRefresh from '../../components/PullToRefresh';
// 컴포넌트 내부:
const isMobile = useIsMobile();
// 기존 TODO 칸반 보드 아래에 추가:
{isMobile && (
<div className="home__todo-swipe">
<SwipeableView
tabs={[
{ key: 'todo', label: 'TODO', content: <TodoColumn items={todoItems} /> },
{ key: 'progress', label: '진행중', content: <TodoColumn items={progressItems} /> },
{ key: 'done', label: '완료', content: <TodoColumn items={doneItems} /> },
]}
/>
</div>
)}
블로그 포스트 영역을 PullToRefresh로 래핑:
<PullToRefresh onRefresh={fetchBlogPosts}>
{/* 기존 블로그 포스트 그리드 */}
</PullToRefresh>
Note: 실제 변수명/함수명은 Home.jsx를 읽어서 매칭해야 함.
-
Step 3: 모바일 375px에서 확인
-
히어로: 1컬럼 스택
-
네비 카드: 2컬럼
-
TODO: 스와이프 탭 동작
-
블로그: 1컬럼 + 풀다운 리프레시
-
Step 4: 커밋
git add src/pages/home/Home.jsx src/pages/home/Home.css
git commit -m "feat(home): 모바일 반응형 — 스와이프 TODO + 풀다운 리프레시"
Task 14: 로또 페이지 모바일 개선
Files:
-
Modify:
src/pages/lotto/Lotto.jsx(또는Functions.jsx— 3탭 구조 파일) -
Modify:
src/pages/lotto/Lotto.css -
Step 1: Lotto.css 모바일 추가 스타일
기존 768px 미디어쿼리에 추가:
@media (max-width: 768px) {
/* 기존 스타일 유지 */
/* 구매 이력 테이블 가로 스크롤 */
.purchase-table-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.lotto-ball {
width: 32px;
height: 32px;
font-size: 13px;
}
/* 전략 차트 축소 */
.strategy-chart-wrapper {
overflow-x: auto;
}
}
- Step 2: Lotto.jsx/Functions.jsx에 SwipeableView + FAB 적용
3탭 구조에 SwipeableView 래핑:
import SwipeableView from '../../components/SwipeableView';
import FAB from '../../components/FAB';
import PullToRefresh from '../../components/PullToRefresh';
import { useIsMobile } from '../../hooks/useIsMobile';
// 탭 영역:
const isMobile = useIsMobile();
// 모바일에서 기존 탭 컨텐츠를 SwipeableView로 래핑
{isMobile ? (
<PullToRefresh onRefresh={refreshData}>
<SwipeableView
tabs={[
{ key: 'briefing', label: '브리핑', content: <BriefingTab /> },
{ key: 'analysis', label: '분석', content: <AnalysisTab /> },
{ key: 'purchase', label: '구매', content: <PurchaseTab /> },
]}
activeIndex={activeTab}
onTabChange={setActiveTab}
/>
</PullToRefresh>
) : (
/* 기존 데스크톱 탭 구조 유지 */
)}
// FAB
<FAB
onClick={() => { /* 빠른 추천 로직 */ }}
label="추천받기"
icon={<svg>...</svg>}
/>
-
Step 3: 모바일에서 확인
-
3탭 스와이프 전환 동작
-
번호 볼 크기 축소
-
구매 이력 테이블 가로 스크롤
-
FAB 위치 (바텀네비 위)
-
Step 4: 커밋
git add src/pages/lotto/ src/pages/lotto/Lotto.css
git commit -m "feat(lotto): 모바일 반응형 — 스와이프 탭 + FAB + 볼 축소"
Task 15: 주식 페이지 모바일 개선 (Stock + StockTrade)
Files:
-
Modify:
src/pages/stock/Stock.jsx -
Modify:
src/pages/stock/StockTrade.jsx -
Modify:
src/pages/stock/Stock.css -
Step 1: Stock.css 모바일 보강
기존 768px 미디어쿼리에 추가:
@media (max-width: 768px) {
/* 기존 스타일 유지 */
/* 필터 가로 스크롤 칩 */
.stock-filter-row {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
flex-wrap: nowrap;
gap: 8px;
padding-bottom: 4px;
}
.stock-filter-row > * {
flex-shrink: 0;
}
/* 지표 카드 캐러셀 */
.stock-indices-grid {
display: flex;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
gap: 12px;
padding-bottom: 8px;
scroll-snap-type: x mandatory;
}
.stock-indices-grid > * {
flex: 0 0 240px;
scroll-snap-align: start;
}
/* 뉴스 1컬럼 */
.stock-news-grid {
grid-template-columns: 1fr;
}
}
Note: 420px/520px/700px breakpoint는 예외로 유지 (spec 규정).
- Step 2: Stock.jsx에 FAB + PullToRefresh 적용
import FAB from '../../components/FAB';
import PullToRefresh from '../../components/PullToRefresh';
// PullToRefresh 래핑
<PullToRefresh onRefresh={fetchNews}>
{/* 기존 Stock 콘텐츠 */}
</PullToRefresh>
// FAB
<FAB onClick={() => { /* 종목 추가 모달 */ }} label="종목 추가" />
- Step 3: StockTrade.jsx 모바일 처리
포트폴리오 테이블을 모바일에서 카드형으로 전환:
import { useIsMobile } from '../../hooks/useIsMobile';
import FAB from '../../components/FAB';
import MobileSheet from '../../components/MobileSheet';
const isMobile = useIsMobile();
// 포트폴리오 영역
{isMobile ? (
<div className="stock-portfolio-cards">
{portfolio.map(item => (
<div key={item.id} className="stock-portfolio-card" onClick={() => openDetail(item)}>
<div className="stock-portfolio-card__name">{item.name}</div>
<div className="stock-portfolio-card__price">{item.currentPrice}</div>
<div className="stock-portfolio-card__profit" data-positive={item.profit > 0}>
{item.profitRate}%
</div>
</div>
))}
</div>
) : (
/* 기존 테이블 */
)}
// FAB
<FAB onClick={() => { /* 매도 기록 */ }} label="매도 기록" />
// 상세 바텀시트
<MobileSheet open={detailOpen} onClose={() => setDetailOpen(false)} title="종목 상세">
{/* 상세 내용 */}
</MobileSheet>
- Step 4: Stock.css에 포트폴리오 카드 스타일 추가
/* 모바일 포트폴리오 카드 */
.stock-portfolio-cards {
display: flex;
flex-direction: column;
gap: 8px;
}
.stock-portfolio-card {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: var(--surface-card);
border: 1px solid var(--border-line);
border-radius: var(--radius-sm);
cursor: pointer;
}
.stock-portfolio-card__name {
font-weight: 600;
color: var(--text-bright);
}
.stock-portfolio-card__profit[data-positive="true"] {
color: #4caf50;
}
.stock-portfolio-card__profit[data-positive="false"] {
color: #f44336;
}
-
Step 5: Stock + StockTrade 모바일 확인 (함께 검증)
-
Stock: 필터 칩 가로스크롤, 지표 카드 캐러셀, 뉴스 1컬럼
-
StockTrade: 포트폴리오 카드형, 매도이력 스크롤, FAB
-
Step 6: 커밋
git add src/pages/stock/
git commit -m "feat(stock): 모바일 반응형 — 카드형 포트폴리오 + 캐러셀 지표 + FAB"
Task 16: 여행 페이지 모바일 개선
Files:
-
Modify:
src/pages/travel/Travel.jsx -
Modify:
src/pages/travel/Travel.css -
Step 1: Travel.css 모바일 보강
기존 768px 미디어쿼리에 추가:
@media (max-width: 768px) {
/* 기존 스타일 유지 */
/* 지도 높이 축소 */
.travel-map-container {
height: 35vh;
}
/* 사진 그리드 2컬럼 */
.travel-photo-grid {
grid-template-columns: 1fr 1fr;
}
/* 라이트박스 풀스크린 */
.travel-lightbox {
border-radius: 0;
max-width: 100vw;
max-height: 100vh;
width: 100vw;
height: 100vh;
}
.travel-lightbox__image {
object-fit: contain;
width: 100%;
height: 100%;
}
}
@media (max-width: 480px) {
.travel-photo-grid {
grid-template-columns: 1fr;
}
}
- Step 2: Travel.jsx에 PullToRefresh + 라이트박스 스와이프 적용
import PullToRefresh from '../../components/PullToRefresh';
import { useSwipe } from '../../hooks/useSwipe';
import { useIsMobile } from '../../hooks/useIsMobile';
// 사진 목록 PullToRefresh 래핑
<PullToRefresh onRefresh={reloadPhotos}>
{/* 기존 사진 그리드 */}
</PullToRefresh>
// 라이트박스에 스와이프 네비게이션 추가
const lightboxSwipe = useSwipe({
onSwipedLeft: () => nextPhoto(),
onSwipedRight: () => prevPhoto(),
});
// 라이트박스 내부:
<div {...lightboxSwipe} className="travel-lightbox__stage">
<img src={currentPhoto} className="travel-lightbox__image" />
</div>
-
Step 3: 모바일 확인
-
지도 높이 35vh
-
사진 2컬럼/1컬럼
-
라이트박스 풀스크린 + 스와이프 넘기기
-
풀다운 리프레시
-
Step 4: 커밋
git add src/pages/travel/
git commit -m "feat(travel): 모바일 반응형 — 풀스크린 라이트박스 + 스와이프 + 지도 축소"
Phase 3: 나머지 페이지 확장
Task 17: 블로그 + 블로그 마케팅 모바일 개선
Files:
-
Modify:
src/pages/blog/Blog.jsx -
Modify:
src/pages/blog/Blog.css -
Modify:
src/pages/blog-marketing/BlogMarketing.jsx -
Modify:
src/pages/blog-marketing/BlogMarketing.css -
Step 1: Blog.css 모바일 보강
@media (max-width: 768px) {
/* 기존 스타일 유지 */
/* 태그 필터 가로 스크롤 칩 */
.blog-tags {
display: flex;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
gap: 8px;
flex-wrap: nowrap;
padding-bottom: 4px;
}
.blog-tags > * {
flex-shrink: 0;
}
/* 에디터 풀 너비 */
.blog-editor {
width: 100%;
}
}
- Step 2: Blog.jsx에 FAB + PullToRefresh 적용
import FAB from '../../components/FAB';
import PullToRefresh from '../../components/PullToRefresh';
<PullToRefresh onRefresh={fetchPosts}>
{/* 기존 블로그 콘텐츠 */}
</PullToRefresh>
<FAB onClick={() => { /* 글 쓰기 모달 */ }} label="글 쓰기" />
- Step 3: BlogMarketing.css 모바일 보강
기존 480px 미디어쿼리(원래 640px → 표준화됨)에 추가:
@media (max-width: 768px) {
.blog-marketing__pipeline-table {
display: none;
}
.blog-marketing__pipeline-cards {
display: flex;
flex-direction: column;
gap: 8px;
}
}
/* 데스크톱에서 카드 숨김 */
.blog-marketing__pipeline-cards {
display: none;
}
- Step 4: BlogMarketing.jsx에 FAB + PullToRefresh + 카드 뷰 적용
import FAB from '../../components/FAB';
import PullToRefresh from '../../components/PullToRefresh';
import { useIsMobile } from '../../hooks/useIsMobile';
<PullToRefresh onRefresh={fetchDashboard}>
{/* 기존 콘텐츠 */}
</PullToRefresh>
<FAB onClick={() => { /* 키워드 분석 시작 */ }} label="키워드 분석" />
- Step 5: 커밋
git add src/pages/blog/ src/pages/blog-marketing/
git commit -m "feat(blog): 모바일 반응형 — FAB + 풀다운 리프레시 + 칩 필터"
Task 18: 부동산 청약 모바일 개선
Files:
-
Modify:
src/pages/subscription/Subscription.jsx -
Modify:
src/pages/subscription/Subscription.css -
Step 1: Subscription.css 모바일 보강
@media (max-width: 768px) {
/* 기존 스타일 유지 */
/* 필터를 바텀시트로 대체하므로 인라인 필터 숨김 */
.subscription__filter-inline {
display: none;
}
.subscription__filter-trigger {
display: flex;
}
/* 공고 카드 1컬럼 */
.subscription__list {
grid-template-columns: 1fr;
}
}
/* 데스크톱에서 필터 트리거 숨김 */
.subscription__filter-trigger {
display: none;
}
- Step 2: Subscription.jsx에 FAB + MobileSheet 필터 적용
import FAB from '../../components/FAB';
import MobileSheet from '../../components/MobileSheet';
import PullToRefresh from '../../components/PullToRefresh';
import { useIsMobile } from '../../hooks/useIsMobile';
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
const isMobile = useIsMobile();
// 모바일 필터 트리거 버튼
{isMobile && (
<button className="subscription__filter-trigger" onClick={() => setFilterSheetOpen(true)}>
필터
</button>
)}
// 필터 바텀시트
<MobileSheet open={filterSheetOpen} onClose={() => setFilterSheetOpen(false)} title="필터">
{/* 기존 필터 폼 컴포넌트 */}
</MobileSheet>
<PullToRefresh onRefresh={fetchAnnouncements}>
{/* 기존 콘텐츠 */}
</PullToRefresh>
<FAB onClick={() => { /* 공고 등록 */ }} label="공고 등록" />
- Step 3: 커밋
git add src/pages/subscription/
git commit -m "feat(subscription): 모바일 반응형 — 바텀시트 필터 + FAB"
Task 19: 뮤직 스튜디오 모바일 개선
Files:
-
Modify:
src/pages/music/MusicStudio.jsx -
Modify:
src/pages/music/MusicStudio.css -
Step 1: MusicStudio.css 모바일 보강
@media (max-width: 768px) {
/* 라이브러리 1컬럼 */
.music-library-grid {
grid-template-columns: 1fr;
}
/* 레이더 위젯 중앙 */
.music-radar {
margin: 0 auto;
}
/* 미니 플레이어 고정 */
.music-mini-player {
position: fixed;
bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px));
left: 0;
right: 0;
height: 56px;
z-index: 250;
background: var(--bg-secondary);
border-top: 1px solid var(--border-line);
display: flex;
align-items: center;
padding: 0 16px;
gap: 12px;
}
/* 미니 플레이어 존재 시 콘텐츠 여백 */
.music-content--with-player {
padding-bottom: calc(var(--bottom-nav-h) + 56px + var(--safe-area-bottom, 0px) + 16px);
}
}
- Step 2: MusicStudio.jsx에 FAB + PullToRefresh 적용
import FAB from '../../components/FAB';
import PullToRefresh from '../../components/PullToRefresh';
<PullToRefresh onRefresh={fetchLibrary}>
{/* 기존 콘텐츠 */}
</PullToRefresh>
<FAB
onClick={() => { /* 음악 생성 모달 */ }}
label="음악 생성"
className={isPlaying ? 'fab--above-player' : ''}
/>
- Step 3: 커밋
git add src/pages/music/
git commit -m "feat(music): 모바일 반응형 — 미니 플레이어 + FAB + 1컬럼 라이브러리"
Task 20: TODO 모바일 개선
Files:
-
Modify:
src/pages/todo/Todo.jsx -
Modify:
src/pages/todo/Todo.css -
Step 1: Todo.css 모바일 보강
@media (max-width: 768px) {
/* 기존 스타일 유지 */
/* 칸반 보드 숨김 (스와이프로 대체) */
.todo-board {
display: none;
}
.todo-swipe-board {
display: block;
}
}
/* 데스크톱에서 스와이프 보드 숨김 */
.todo-swipe-board {
display: none;
}
- Step 2: Todo.jsx에 SwipeableView + FAB + MobileSheet 적용
import SwipeableView from '../../components/SwipeableView';
import FAB from '../../components/FAB';
import MobileSheet from '../../components/MobileSheet';
import { useIsMobile } from '../../hooks/useIsMobile';
const [addSheetOpen, setAddSheetOpen] = useState(false);
const isMobile = useIsMobile();
// 모바일 스와이프 보드
{isMobile && (
<div className="todo-swipe-board">
<SwipeableView
tabs={[
{ key: 'todo', label: 'TODO', content: <TodoColumn status="todo" /> },
{ key: 'progress', label: '진행중', content: <TodoColumn status="in_progress" /> },
{ key: 'done', label: '완료', content: <TodoColumn status="done" /> },
]}
/>
</div>
)}
// FAB → 바텀시트 입력
<FAB onClick={() => setAddSheetOpen(true)} label="할일 추가" />
<MobileSheet open={addSheetOpen} onClose={() => setAddSheetOpen(false)} title="할일 추가">
{/* 기존 입력 폼 */}
</MobileSheet>
- Step 3: 커밋
git add src/pages/todo/
git commit -m "feat(todo): 모바일 반응형 — 스와이프 칸반 + FAB + 바텀시트 입력"
Task 21: 에이전트 오피스 모바일 개선
Files:
-
Modify:
src/pages/agent-office/AgentOffice.jsx -
Modify:
src/pages/agent-office/AgentOffice.css -
Step 1: AgentOffice.css 모바일 보강
기존 768px 미디어쿼리에 추가:
@media (max-width: 768px) {
/* 기존 스타일 유지 */
/* 캔버스 풀스크린 */
.agent-office__canvas {
width: 100%;
height: 50vh;
touch-action: pan-x pan-y;
}
/* 명령 입력 하단 고정 */
.agent-office__command {
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);
border-top: 1px solid var(--border-line);
z-index: 200;
}
}
- Step 2: AgentOffice.jsx에 MobileSheet 적용
import MobileSheet from '../../components/MobileSheet';
import { useIsMobile } from '../../hooks/useIsMobile';
const [agentSheetOpen, setAgentSheetOpen] = useState(false);
const [selectedAgent, setSelectedAgent] = useState(null);
const isMobile = useIsMobile();
// 에이전트 클릭 시 바텀시트
const handleAgentClick = (agent) => {
if (isMobile) {
setSelectedAgent(agent);
setAgentSheetOpen(true);
}
// 데스크톱은 기존 사이드 패널 동작
};
<MobileSheet open={agentSheetOpen} onClose={() => setAgentSheetOpen(false)} title={selectedAgent?.name}>
{/* 에이전트 상세 + 로그 */}
</MobileSheet>
- Step 3: 커밋
git add src/pages/agent-office/
git commit -m "feat(agent-office): 모바일 반응형 — 바텀시트 에이전트 상세 + 하단 명령 입력"
Task 22: 이펙트 랩 모바일 개선
Files:
-
Modify:
src/pages/effect-lab/EffectLab.css -
Modify:
src/pages/effect-lab/DayCalc.css -
Modify:
src/pages/effect-lab/SwordStream.css -
Step 1: EffectLab.css — 기존 768px 이미 1컬럼, 추가 수정 불필요
확인만 수행. line 183의 기존 미디어쿼리가 충분한지 검증.
- Step 2: DayCalc.css — 기존 768px 이미 적용됨, 확인
line 417의 기존 미디어쿼리가 충분한지 검증.
- Step 3: SwordStream.css — 모바일 미디어쿼리 추가
SwordStream은 미디어쿼리가 없음 (83줄). 모바일 대응 추가:
@media (max-width: 768px) {
.sword-stream {
touch-action: none; /* 터치 인터랙션 유지 */
}
.sword-stream__controls {
position: fixed;
bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px) + 8px);
left: 8px;
right: 8px;
padding: 8px;
background: rgba(0, 0, 0, 0.7);
border-radius: var(--radius-sm);
backdrop-filter: blur(8px);
}
}
Note: 실제 클래스명은 SwordStream.css를 읽어서 매칭해야 함.
- Step 4: 커밋
git add src/pages/effect-lab/
git commit -m "feat(effect-lab): SwordStream 모바일 터치 대응 + 오버레이 컨트롤"
Phase 4: 검증
Task 23: 전체 뷰 모바일 UI 검증
대상 뷰포트:
-
360px (Galaxy S)
-
390px (iPhone 14)
-
768px (iPad)
-
1024px (데스크톱)
-
Step 1: 개발 서버 실행
cd C:\Users\jaeoh\Desktop\workspace\web-ui && npm run dev
- Step 2: 각 페이지별 4개 뷰포트 확인
DevTools에서 각 뷰포트 크기로 전환하며 확인:
| 페이지 | 360px | 390px | 768px | 1024px |
|---|---|---|---|---|
| Home | ☐ | ☐ | ☐ | ☐ |
| Lotto | ☐ | ☐ | ☐ | ☐ |
| Stock | ☐ | ☐ | ☐ | ☐ |
| StockTrade | ☐ | ☐ | ☐ | ☐ |
| Travel | ☐ | ☐ | ☐ | ☐ |
| Blog | ☐ | ☐ | ☐ | ☐ |
| BlogMarketing | ☐ | ☐ | ☐ | ☐ |
| Subscription | ☐ | ☐ | ☐ | ☐ |
| Music | ☐ | ☐ | ☐ | ☐ |
| Todo | ☐ | ☐ | ☐ | ☐ |
| AgentOffice | ☐ | ☐ | ☐ | ☐ |
| EffectLab | ☐ | ☐ | ☐ | ☐ |
| DayCalc | ☐ | ☐ | ☐ | ☐ |
| SwordStream | ☐ | ☐ | ☐ | ☐ |
확인 항목:
-
UI 짤림 없음
-
터치 타겟 44×44px 이상
-
FAB가 바텀네비 위에 올바르게 위치
-
바텀네비 더보기 메뉴 동작
-
스와이프 동작 (Lotto, Todo, Home TODO)
-
풀다운 리프레시 동작
-
바텀시트 열기/닫기/드래그 닫기
-
가로 스크롤 테이블/캐러셀
-
Step 3: prefers-reduced-motion 확인
DevTools → Rendering → Emulate CSS media feature prefers-reduced-motion: reduce
모든 애니메이션이 비활성화되는지 확인:
-
바텀네비 더보기 패널 전환
-
바텀시트 슬라이드
-
풀다운 리프레시 스피너
-
스와이프 전환
-
Step 4: 발견된 문제 수정 + 커밋
git add -A
git commit -m "fix: 모바일 UI 검증 후 수정"
- Step 5: 데스크톱 회귀 확인
1024px 이상에서 모든 페이지가 기존과 동일하게 동작하는지 확인:
- 사이드바 정상 표시
- 바텀네비 숨김
- FAB 숨김
- 기존 레이아웃 유지