Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,4 @@ __pycache__/

# 디자인 참고 자료 (git에 올리지 않음)
design_handoff/
img.png
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
- **뮤지션 인증 플로우** — 일반 유저의 뮤지션 인증 신청 → 어드민 승인/거부
- **어드민 관리** — 뮤지션·공연장·공연 등록/수정(출연 라인업 지정 포함), 뮤지션 인증 대기 목록 관리
- **공연장 좌표 크롤링** — Kakao Local API로 기존 공연장 주소를 위도/경도로 변환해 저장
- **모바일 반응형 UI** — 768px 이하에서 햄버거 메뉴 + 슬라이드 드로어로 전환, 그리드 1열 재배치

---

Expand Down
19 changes: 16 additions & 3 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import './styles/global.css';
import Topbar from './components/Topbar';
import Sidebar from './components/Sidebar';
import AdminSidebar from './components/AdminSidebar';
import MobileNav from './components/MobileNav'; // 모바일 전용 내비게이션 드로어
import Toast from './components/Toast';

import KakaoLoginModal from './pages/auth/KakaoLoginModal';
Expand Down Expand Up @@ -57,6 +58,8 @@ export default function App() {
// me: 로그인한 유저 정보 (null = 비로그인 or 아직 로드 중)
// 로그인 후: { id, nickname, role, musicianId?, stageName?, position?, bio?, snsUrl? }
const [me, setMe] = useState(null);
// menuOpen: 모바일 드로어(햄버거 메뉴) 열림 여부 — PC 화면에서는 사용되지 않음
const [menuOpen, setMenuOpen] = useState(false);

const route = routeState.route;
const routeParams = routeState.params;
Expand Down Expand Up @@ -125,6 +128,7 @@ export default function App() {
: '#' + r;
history.pushState({ r, params }, '', encoded);
setRouteState({ route: r, params });
setMenuOpen(false); // 화면 이동 시 모바일 드로어는 항상 닫는다
window.scrollTo(0, 0);
};

Expand Down Expand Up @@ -235,21 +239,30 @@ export default function App() {
auth={auth}
onLoginClick={openLogin}
onLogout={handleLogout}
onMenuClick={() => setMenuOpen((o) => !o)} // 햄버거 버튼: 드로어 열기/닫기 토글
/>

{useLayout ? (
<>
{/* 대시보드/관리자 화면: 사이드바가 모바일에서는 드로어(mobileOpen)로 동작 */}
{layoutAdmin ? (
<AdminSidebar route={route} navigate={navigate} onLogout={handleLogout} />
<AdminSidebar route={route} navigate={navigate} onLogout={handleLogout} mobileOpen={menuOpen} />
) : (
<Sidebar route={route} navigate={navigate} me={me} onLogout={handleLogout} />
<Sidebar route={route} navigate={navigate} me={me} onLogout={handleLogout} mobileOpen={menuOpen} />
)}
<main>{renderContent()}</main>
</>
) : (
renderContent()
<>
{/* 일반 화면: 모바일 전용 메뉴 드로어 (PC에서는 CSS로 숨겨짐) */}
<MobileNav open={menuOpen} route={route} navigate={navigate} auth={auth} onLoginClick={(reason) => { setMenuOpen(false); openLogin(reason); }} />
{renderContent()}
</>
)}

{/* 드로어가 열려 있을 때 본문을 어둡게 덮는 배경 — 탭하면 드로어 닫힘 */}
{menuOpen && <div className="drawer-scrim" onClick={() => setMenuOpen(false)} />}

{toast && <Toast msg={toast.msg} kind={toast.kind} />}
{loginModal && <KakaoLoginModal onClose={() => setLoginModal(false)} onLogin={handleLogin} reason={loginReason} />}
</div>
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/components/AdminSidebar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import Avatar from './Avatar'; // 이니셜 아바타
import Icon from './Icon'; // 아이콘
import { api } from '../api/client'; // 백엔드 API 호출 함수 모음

export default function AdminSidebar({ route, navigate, onLogout }) {
// mobileOpen: 모바일 드로어 열림 여부 — true면 .open 클래스가 붙어 슬라이드 인 (PC에서는 영향 없음)
export default function AdminSidebar({ route, navigate, onLogout, mobileOpen }) {
// 각 항목의 건수 — 실제 API 데이터 기반
const [pendingCount, setPendingCount] = useState(null); // 인증 대기
const [musicianCount, setMusicianCount] = useState(null); // 뮤지션 수
Expand All @@ -28,7 +29,7 @@ export default function AdminSidebar({ route, navigate, onLogout }) {
];

return (
<div className="sidebar admin">
<div className={`sidebar admin ${mobileOpen ? 'open' : ''}`}>
<div className="section">관리자 콘솔</div>
{items.map(([r, ico, label, count]) => (
<a key={r} className={route === r ? 'active' : ''} onClick={() => navigate(r)}>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/Icon.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export default function Icon({ name, size = 18, color = 'currentColor', stroke =
case 'logout': return <svg {...props}><path d="M15 4h3a2 2 0 012 2v12a2 2 0 01-2 2h-3M10 17l-5-5 5-5M5 12h11" /></svg>;
case 'chat': return <svg {...props}><path d="M12 4C7 4 3 7.1 3 11c0 2.4 1.6 4.6 4 5.9-.2.9-.7 2.3-1.3 3.1-.2.3 0 .6.4.5 1.8-.4 3.4-1.3 4.3-2 .5.1 1 .1 1.6.1 5 0 9-3.1 9-7s-4-7-9-7z" fill="currentColor" stroke="none" /></svg>;
case 'lock': return <svg {...props}><rect x="5" y="11" width="14" height="9" rx="2" /><path d="M8 11V8a4 4 0 018 0v3" /></svg>;
case 'menu': return <svg {...props}><path d="M4 6h16M4 12h16M4 18h16" /></svg>; // 햄버거(☰) — 모바일 메뉴 버튼
default: return <svg {...props}><circle cx="12" cy="12" r="9" /></svg>;
}
}
83 changes: 83 additions & 0 deletions frontend/src/components/MobileNav.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// 모바일 전용 내비게이션 드로어 — PC에서 topbar에 가로로 나열되던 메뉴를 대신한다.
// 대시보드/관리자 화면에서는 Sidebar/AdminSidebar가 드로어 역할을 하므로 이 컴포넌트는 쓰이지 않는다.
// CSS의 .sidebar.mobile-nav 규칙에 의해 PC(769px 이상)에서는 아예 표시되지 않는다.
import Icon from './Icon'; // 메뉴 아이콘

// open: 드로어 열림 여부 (true면 .open 클래스가 붙어 슬라이드 인)
// route/navigate: 현재 라우트와 화면 이동 함수
// auth: 로그인 상태 — 역할(guest/general/musician/admin)에 따라 하단 메뉴가 달라짐
// onLoginClick: 카카오 로그인 모달 열기
export default function MobileNav({ open, route, navigate, auth = { role: 'guest' }, onLoginClick }) {
// 모든 방문자에게 보이는 공용 메뉴 (topbar의 가로 메뉴와 동일)
const links = [
['directory', 'users', '뮤지션'],
['venues', 'building', '공연장'],
['concerts', 'ticket', '공연'],
['playdb', 'graph', '인맥지도'],
];

return (
// .sidebar 스타일(드로어 슬라이드)을 재사용하고, .mobile-nav로 PC에서는 숨긴다
<div className={`sidebar mobile-nav ${open ? 'open' : ''}`}>
<div className="section">둘러보기</div>
{links.map(([r, ico, label]) => (
<a key={r} className={route === r ? 'active' : ''} onClick={() => navigate(r)}>
<span className="ico"><Icon name={ico} /></span>
{label}
</a>
))}

{/* 비로그인: 로그인/뮤지션 등록 안내 (topbar에서 hide-sm으로 숨긴 항목의 대체) */}
{auth.role === 'guest' && (
<>
<div className="section" style={{ marginTop: 16 }}>계정</div>
<a onClick={() => onLoginClick && onLoginClick()}>
<span className="ico"><Icon name="chat" /></span>
카카오 로그인
</a>
<a onClick={() => onLoginClick && onLoginClick('become')}>
<span className="ico"><Icon name="music" /></span>
뮤지션이세요?
</a>
</>
)}

{/* 일반 회원: 뮤지션 등록 신청 */}
{auth.role === 'general' && (
<>
<div className="section" style={{ marginTop: 16 }}>내 계정</div>
<a onClick={() => navigate('become-musician')}>
<span className="ico"><Icon name="plus" /></span>
뮤지션 등록
</a>
</>
)}

{/* 뮤지션: 마이페이지/공개 프로필 바로가기 */}
{auth.role === 'musician' && (
<>
<div className="section" style={{ marginTop: 16 }}>내 계정</div>
<a onClick={() => navigate('dashboard')}>
<span className="ico"><Icon name="home" /></span>
마이페이지
</a>
<a onClick={() => navigate('profile-public')}>
<span className="ico"><Icon name="external" /></span>
공개 프로필
</a>
</>
)}

{/* 관리자: 관리자 콘솔 바로가기 */}
{auth.role === 'admin' && (
<>
<div className="section" style={{ marginTop: 16 }}>관리</div>
<a onClick={() => navigate('admin-home')}>
<span className="ico"><Icon name="shield" /></span>
관리자 대시보드
</a>
</>
)}
</div>
);
}
5 changes: 3 additions & 2 deletions frontend/src/components/Sidebar.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import Avatar from './Avatar';
import Icon from './Icon';

export default function Sidebar({ route, navigate, me, onLogout }) {
// mobileOpen: 모바일 드로어 열림 여부 — true면 .open 클래스가 붙어 슬라이드 인 (PC에서는 영향 없음)
export default function Sidebar({ route, navigate, me, onLogout, mobileOpen }) {
const items = [
['dashboard', 'home', '대시보드'],
['profile-edit', 'user', '내 프로필'],
Expand All @@ -10,7 +11,7 @@ export default function Sidebar({ route, navigate, me, onLogout }) {
['graph-mine', 'graph', '관계도'],
];
return (
<div className="sidebar">
<div className={`sidebar ${mobileOpen ? 'open' : ''}`}>
<div className="section">관리</div>
{items.map(([r, ico, label]) => (
<a key={r} className={route === r ? 'active' : ''} onClick={() => navigate(r)}>
Expand Down
21 changes: 15 additions & 6 deletions frontend/src/components/Topbar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@ import Logo from './Logo';
import Avatar from './Avatar';
import Icon from './Icon';

export default function Topbar({ route, navigate, mode = 'visitor', me, auth = { role: 'guest' }, onLoginClick, onLogout }) {
// onMenuClick: 모바일 햄버거(☰) 버튼 클릭 시 드로어를 열고 닫는 콜백 (App.jsx에서 전달)
export default function Topbar({ route, navigate, mode = 'visitor', me, auth = { role: 'guest' }, onLoginClick, onLogout, onMenuClick }) {
const publicNav = true; // 모든 역할에서 공연장/공연/인맥지도 nav 표시
return (
<div className="topbar">
<div className="row" style={{ gap: 32 }}>
{/* 햄버거 버튼 — CSS(.menu-btn)에 의해 768px 이하에서만 보임 */}
<button className="btn icon ghost sm menu-btn" aria-label="메뉴 열기" onClick={onMenuClick}>
<Icon name="menu" size={18} />
</button>
<Logo tagline onClick={() => navigate('directory')} />
{publicNav ? (
<nav className="nav">
Expand All @@ -20,7 +25,8 @@ export default function Topbar({ route, navigate, mode = 'visitor', me, auth = {
<div className="right">
{auth.role === 'guest' ? (
<>
<button className="btn secondary sm" onClick={() => onLoginClick && onLoginClick('become')}>뮤지션이세요?</button>
{/* hide-sm: 모바일에서는 숨김 (드로어 메뉴에 같은 항목이 있음) */}
<button className="btn secondary sm hide-sm" onClick={() => onLoginClick && onLoginClick('become')}>뮤지션이세요?</button>
<button className="btn kakao sm" onClick={() => onLoginClick && onLoginClick()}>
<Icon name="chat" size={14} /> 카카오 로그인
</button>
Expand All @@ -36,7 +42,8 @@ export default function Topbar({ route, navigate, mode = 'visitor', me, auth = {
<Icon name="plus" size={14} /> 뮤지션 등록
</button>
)}
<div className="user-chip">
{/* 유저 정보 칩 — 모바일에서는 공간이 좁아 숨김 */}
<div className="user-chip hide-sm">
<Avatar name={auth.name || '게'} size="sm" />
<div className="col" style={{ gap: 0 }}>
<b style={{ fontSize: 12, lineHeight: 1.2 }}>{auth.name || '게스트'}</b>
Expand All @@ -47,9 +54,10 @@ export default function Topbar({ route, navigate, mode = 'visitor', me, auth = {
</>
) : auth.role === 'admin' ? (
<>
<span className="pill ink-solid"><Icon name="shield" size={13} /> ADMIN</span>
<span className="pill ink-solid hide-sm"><Icon name="shield" size={13} /> ADMIN</span>
<button className="btn primary sm" onClick={() => navigate('admin-home')}>관리자 대시보드</button>
<button className="btn ghost sm" onClick={() => navigate('directory')}><Icon name="external" size={14} /> 사이트 보기</button>
{/* hide-sm: 모바일에서는 숨김 (드로어 메뉴로 대체) */}
<button className="btn ghost sm hide-sm" onClick={() => navigate('directory')}><Icon name="external" size={14} /> 사이트 보기</button>
<Avatar name="관" ink />
<button className="btn ghost sm" onClick={onLogout}><Icon name="logout" size={14} /></button>
</>
Expand All @@ -58,7 +66,8 @@ export default function Topbar({ route, navigate, mode = 'visitor', me, auth = {
<button className="btn primary sm" onClick={() => navigate('dashboard')}>
마이페이지
</button>
<button className="btn ghost sm" onClick={() => navigate('profile-public')}>
{/* hide-sm: 모바일에서는 숨김 (사이드바 드로어에서 이동 가능) */}
<button className="btn ghost sm hide-sm" onClick={() => navigate('profile-public')}>
<Icon name="external" size={14} /> 공개 프로필
</button>
<Avatar name={auth.name || me?.name || ''} tier={me?.tier} />
Expand Down
61 changes: 61 additions & 0 deletions frontend/src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -859,4 +859,65 @@ button.pill:hover { opacity: 0.85; }
.resume.modern { grid-template-columns: 1fr; }
.rm-sidebar { position: static; }
.concert-meta-grid { grid-template-columns: 1fr; }
}

/* ─── 모바일 반응형 (768px 이하) ─────────────────────────── */
/* 햄버거 버튼·드로어 스크림·모바일 메뉴는 PC에서는 숨겨두고
아래 미디어 쿼리 안에서만 표시한다 */
.menu-btn { display: none; } /* 상단바 햄버거(☰) 버튼 */
.drawer-scrim { display: none; } /* 드로어 뒤의 어두운 배경 */
.sidebar.mobile-nav { display: none; }/* 방문자용 모바일 메뉴 (PC는 topbar 가로 메뉴 사용) */

@media (max-width: 768px) {
/* 모바일에서만 숨길 요소에 붙이는 유틸리티 클래스 */
.hide-sm { display: none !important; }

/* ── 상단바 ── */
.menu-btn { display: inline-flex; } /* 햄버거 버튼 표시 */
.topbar { padding: 0 12px; }
.topbar .nav { display: none; } /* 가로 메뉴 숨김 → 드로어로 대체 */
.topbar .right { gap: 8px; }
.topbar .row { gap: 12px !important; } /* 햄버거↔로고 간격 (인라인 gap:32를 덮어씀) */
.logo .sub { display: none; } /* 좁은 화면에서는 로고 태그라인 생략 */

/* ── 대시보드/관리자 레이아웃: 사이드바를 드로어로 전환 ── */
.app.dashboard { grid-template-columns: 1fr; } /* 사이드바 칸 제거 → 본문이 전체 폭 사용 */
.sidebar {
position: fixed; /* 화면에 고정 (스크롤과 무관) */
top: 56px; bottom: 0; left: 0; /* 상단바 아래부터 화면 끝까지 */
width: min(264px, 80vw); /* 264px, 단 화면의 80%를 넘지 않게 */
transform: translateX(-105%); /* 평소에는 화면 왼쪽 밖에 대기 */
transition: transform 0.25s ease; /* 열고 닫힐 때 슬라이드 애니메이션 */
z-index: 60; /* 스크림(55)보다 위 */
overflow-y: auto; /* 메뉴가 길면 드로어 안에서 스크롤 */
}
.sidebar.open {
transform: translateX(0); /* .open이 붙으면 슬라이드 인 */
box-shadow: 0 12px 40px rgba(20, 14, 12, 0.25);
}
.sidebar.mobile-nav { display: flex; } /* 방문자용 드로어도 같은 스타일 사용 */
.drawer-scrim { /* 드로어 열릴 때 본문을 어둡게 — 탭하면 닫힘 */
display: block; position: fixed; inset: 56px 0 0 0;
background: rgba(20, 14, 12, 0.45); z-index: 55;
}

/* ── 본문 여백·타이포 축소 ── */
.main .pad { padding: 20px 16px; }
.h1 { font-size: 28px; }
.h2 { font-size: 22px; }
.lead { font-size: 15px; }

/* ── 아직 1열로 안 바뀐 그리드들 전환 ── */
.form-grid { grid-template-columns: 1fr; }
.resume-modern .sec-grid { grid-template-columns: 1fr; }
.become-grid { grid-template-columns: 1fr; }

/* ── 관리자 표: 열이 많아 좁은 화면에 다 안 들어가므로 가로 스크롤 허용 ── */
.card.flush { overflow-x: auto; }
.admin-table { min-width: 560px; }

/* ── 기타 ── */
.preset-bar { padding: 10px 14px; }
.modal-overlay { padding: 12px; }
.modal-scrim { padding: 12px; }
}
Loading