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>
2393 lines
59 KiB
Markdown
2393 lines
59 KiB
Markdown
# 반응형 웹 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 추가**
|
||
|
||
```html
|
||
<!-- 기존 (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) 뒤에 추가:
|
||
|
||
```css
|
||
/* ── 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의 모바일 미디어쿼리를 확장:
|
||
|
||
```css
|
||
@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: 커밋**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```css
|
||
/* 기존 */
|
||
@media (max-width: 960px) {
|
||
|
||
/* 변경 */
|
||
@media (max-width: 1024px) {
|
||
```
|
||
|
||
- [ ] **Step 2: Lotto.css — 900px → 768px로 변경**
|
||
|
||
`src/pages/lotto/Lotto.css` line 1077:
|
||
|
||
```css
|
||
/* 기존 */
|
||
@media (max-width: 900px) {
|
||
|
||
/* 변경 */
|
||
@media (max-width: 768px) {
|
||
```
|
||
|
||
주의: 이 블록 내의 스타일이 기존 768px 블록(line 1159)과 충돌하지 않는지 확인. 충돌 시 두 블록을 병합한다.
|
||
|
||
- [ ] **Step 3: Travel.css — 900px → 768px로 변경**
|
||
|
||
`src/pages/travel/Travel.css` line 969:
|
||
|
||
```css
|
||
/* 기존 */
|
||
@media (max-width: 900px) {
|
||
|
||
/* 변경 */
|
||
@media (max-width: 768px) {
|
||
```
|
||
|
||
기존 640px 블록(line 975)과 겹치지 않는지 확인.
|
||
|
||
- [ ] **Step 4: Blog.css — 900px → 768px로 변경**
|
||
|
||
`src/pages/blog/Blog.css` line 454:
|
||
|
||
```css
|
||
/* 기존 */
|
||
@media (max-width: 900px) {
|
||
|
||
/* 변경 */
|
||
@media (max-width: 768px) {
|
||
```
|
||
|
||
기존 768px 블록(line 504)과 병합 필요 시 병합.
|
||
|
||
- [ ] **Step 5: 각 페이지를 DevTools 768px/1024px에서 확인**
|
||
|
||
각 페이지가 기존과 동일하게 렌더링되는지 확인. 특히:
|
||
- Home: 히어로 그리드 전환 시점
|
||
- Lotto: 헤더/분석 카드 1컬럼 전환 시점
|
||
- Travel: 헤더 레이아웃 전환 시점
|
||
- Blog: 사이드 목록 오버레이 전환 시점
|
||
|
||
- [ ] **Step 6: 커밋**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```css
|
||
/* 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:
|
||
|
||
```css
|
||
/* 기존 */
|
||
@media (max-width: 960px) {
|
||
|
||
/* 변경 */
|
||
@media (max-width: 1024px) {
|
||
```
|
||
|
||
- [ ] **Step 3: 640px → 480px 일괄 변경**
|
||
|
||
각 파일의 640px 미디어쿼리를 480px로 변경:
|
||
|
||
- `Lotto.css` line 1111, 1462
|
||
- `MusicStudio.css` line 490, 640, 1699
|
||
- `Travel.css` line 349, 975
|
||
- `BlogMarketing.css` line 128
|
||
|
||
각 파일에서:
|
||
|
||
```css
|
||
/* 기존 */
|
||
@media (max-width: 640px) {
|
||
|
||
/* 변경 */
|
||
@media (max-width: 480px) {
|
||
```
|
||
|
||
- [ ] **Step 4: 각 페이지 480px/768px/1024px에서 확인**
|
||
|
||
- [ ] **Step 5: 커밋**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```css
|
||
/* line 955 */
|
||
@media (max-width: 1024px) {
|
||
|
||
/* line 961 */
|
||
@media (max-width: 768px) {
|
||
```
|
||
|
||
- [ ] **Step 2: 커밋**
|
||
|
||
```bash
|
||
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 설치**
|
||
|
||
```bash
|
||
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||
npm install react-swipeable
|
||
```
|
||
|
||
- [ ] **Step 2: useIsMobile 훅 작성**
|
||
|
||
```jsx
|
||
// 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: 커밋**
|
||
|
||
```bash
|
||
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 훅 작성**
|
||
|
||
```jsx
|
||
// 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: 커밋**
|
||
|
||
```bash
|
||
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 작성**
|
||
|
||
```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 작성**
|
||
|
||
```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: 커밋**
|
||
|
||
```bash
|
||
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 작성**
|
||
|
||
```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 작성**
|
||
|
||
```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: 커밋**
|
||
|
||
```bash
|
||
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 작성**
|
||
|
||
```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 작성**
|
||
|
||
```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: 커밋**
|
||
|
||
```bash
|
||
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 작성**
|
||
|
||
```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 작성**
|
||
|
||
```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: 커밋**
|
||
|
||
```bash
|
||
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 작성**
|
||
|
||
```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 작성**
|
||
|
||
```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: 커밋**
|
||
|
||
```bash
|
||
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`를 수정:
|
||
|
||
```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의 모바일 미디어쿼리를 수정:
|
||
|
||
```css
|
||
/* 기존 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로 조건부 처리:
|
||
|
||
```jsx
|
||
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` 스타일은 더 이상 사용되지 않으므로 제거하거나 유지해도 무방 (컴포넌트가 렌더링되지 않으므로).
|
||
|
||
정리를 위해 제거 권장:
|
||
|
||
```css
|
||
/* 기존 (lines 335-359) — 전체 삭제 가능 */
|
||
/* @media (max-width: 768px) { ... } */
|
||
/* @media (min-width: 769px) { ... } */
|
||
```
|
||
|
||
대신 데스크톱 전용 사이드바만 유지:
|
||
|
||
```css
|
||
/* Navbar.css 말미 — 사이드바는 데스크톱 전용 */
|
||
@media (max-width: 768px) {
|
||
.sidebar {
|
||
display: none;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 데스크톱/모바일에서 확인**
|
||
|
||
- 데스크톱: 사이드바 정상 표시, 바텀네비 숨김
|
||
- 모바일 (768px 이하): 사이드바 완전 숨김, 바텀네비 표시
|
||
- 더보기 메뉴 열기/닫기 동작
|
||
- 네비게이션 링크 클릭 시 라우팅 정상 동작
|
||
|
||
- [ ] **Step 6: 커밋**
|
||
|
||
```bash
|
||
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)에 다음을 추가/수정:
|
||
|
||
```css
|
||
@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 추가:
|
||
|
||
```jsx
|
||
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로 래핑:
|
||
|
||
```jsx
|
||
<PullToRefresh onRefresh={fetchBlogPosts}>
|
||
{/* 기존 블로그 포스트 그리드 */}
|
||
</PullToRefresh>
|
||
```
|
||
|
||
> Note: 실제 변수명/함수명은 Home.jsx를 읽어서 매칭해야 함.
|
||
|
||
- [ ] **Step 3: 모바일 375px에서 확인**
|
||
|
||
- 히어로: 1컬럼 스택
|
||
- 네비 카드: 2컬럼
|
||
- TODO: 스와이프 탭 동작
|
||
- 블로그: 1컬럼 + 풀다운 리프레시
|
||
|
||
- [ ] **Step 4: 커밋**
|
||
|
||
```bash
|
||
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 미디어쿼리에 추가:
|
||
|
||
```css
|
||
@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 래핑:
|
||
|
||
```jsx
|
||
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: 커밋**
|
||
|
||
```bash
|
||
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 미디어쿼리에 추가:
|
||
|
||
```css
|
||
@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 적용**
|
||
|
||
```jsx
|
||
import FAB from '../../components/FAB';
|
||
import PullToRefresh from '../../components/PullToRefresh';
|
||
|
||
// PullToRefresh 래핑
|
||
<PullToRefresh onRefresh={fetchNews}>
|
||
{/* 기존 Stock 콘텐츠 */}
|
||
</PullToRefresh>
|
||
|
||
// FAB
|
||
<FAB onClick={() => { /* 종목 추가 모달 */ }} label="종목 추가" />
|
||
```
|
||
|
||
- [ ] **Step 3: StockTrade.jsx 모바일 처리**
|
||
|
||
포트폴리오 테이블을 모바일에서 카드형으로 전환:
|
||
|
||
```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에 포트폴리오 카드 스타일 추가**
|
||
|
||
```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: 커밋**
|
||
|
||
```bash
|
||
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 미디어쿼리에 추가:
|
||
|
||
```css
|
||
@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 + 라이트박스 스와이프 적용**
|
||
|
||
```jsx
|
||
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: 커밋**
|
||
|
||
```bash
|
||
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 모바일 보강**
|
||
|
||
```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 적용**
|
||
|
||
```jsx
|
||
import FAB from '../../components/FAB';
|
||
import PullToRefresh from '../../components/PullToRefresh';
|
||
|
||
<PullToRefresh onRefresh={fetchPosts}>
|
||
{/* 기존 블로그 콘텐츠 */}
|
||
</PullToRefresh>
|
||
|
||
<FAB onClick={() => { /* 글 쓰기 모달 */ }} label="글 쓰기" />
|
||
```
|
||
|
||
- [ ] **Step 3: BlogMarketing.css 모바일 보강**
|
||
|
||
기존 480px 미디어쿼리(원래 640px → 표준화됨)에 추가:
|
||
|
||
```css
|
||
@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 + 카드 뷰 적용**
|
||
|
||
```jsx
|
||
import FAB from '../../components/FAB';
|
||
import PullToRefresh from '../../components/PullToRefresh';
|
||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||
|
||
<PullToRefresh onRefresh={fetchDashboard}>
|
||
{/* 기존 콘텐츠 */}
|
||
</PullToRefresh>
|
||
|
||
<FAB onClick={() => { /* 키워드 분석 시작 */ }} label="키워드 분석" />
|
||
```
|
||
|
||
- [ ] **Step 5: 커밋**
|
||
|
||
```bash
|
||
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 모바일 보강**
|
||
|
||
```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 필터 적용**
|
||
|
||
```jsx
|
||
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: 커밋**
|
||
|
||
```bash
|
||
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 모바일 보강**
|
||
|
||
```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 적용**
|
||
|
||
```jsx
|
||
import FAB from '../../components/FAB';
|
||
import PullToRefresh from '../../components/PullToRefresh';
|
||
|
||
<PullToRefresh onRefresh={fetchLibrary}>
|
||
{/* 기존 콘텐츠 */}
|
||
</PullToRefresh>
|
||
|
||
<FAB
|
||
onClick={() => { /* 음악 생성 모달 */ }}
|
||
label="음악 생성"
|
||
className={isPlaying ? 'fab--above-player' : ''}
|
||
/>
|
||
```
|
||
|
||
- [ ] **Step 3: 커밋**
|
||
|
||
```bash
|
||
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 모바일 보강**
|
||
|
||
```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 적용**
|
||
|
||
```jsx
|
||
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: 커밋**
|
||
|
||
```bash
|
||
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 미디어쿼리에 추가:
|
||
|
||
```css
|
||
@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 적용**
|
||
|
||
```jsx
|
||
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: 커밋**
|
||
|
||
```bash
|
||
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줄). 모바일 대응 추가:
|
||
|
||
```css
|
||
@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: 커밋**
|
||
|
||
```bash
|
||
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: 개발 서버 실행**
|
||
|
||
```bash
|
||
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: 발견된 문제 수정 + 커밋**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "fix: 모바일 UI 검증 후 수정"
|
||
```
|
||
|
||
- [ ] **Step 5: 데스크톱 회귀 확인**
|
||
|
||
1024px 이상에서 모든 페이지가 기존과 동일하게 동작하는지 확인:
|
||
- 사이드바 정상 표시
|
||
- 바텀네비 숨김
|
||
- FAB 숨김
|
||
- 기존 레이아웃 유지
|