diff --git a/docs/history.md b/docs/history.md index e89f31b..c5b990f 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,10 @@ # 의사결정 이력 +## 2026-03-30 v1.2.0 +- 피그마 시안은 단순 컴포넌트 교체보다 앱 전체의 정보 구조를 바꾸는 성격이 강하므로, 우선 공통 앱 셸부터 `좌측 내비 / 중앙 워크스페이스 / 우측 컨텍스트 패널`로 올리는 단계적 리디자인이 더 안전하다고 판단했다. +- 홈, 게임 허브, 내 티어표, 즐겨찾기처럼 카드 목록 중심 화면은 시안 톤을 먼저 맞추고, 에디터와 관리자처럼 상호작용이 무거운 화면은 같은 셸 안에서 후속 이관하는 방식이 리스크가 적다고 정리했다. +- 이번 리디자인은 사용자 체감 변화가 큰 편이므로, 버전도 기존 `0.1.x`가 아니라 `v1.2.0`으로 점프해 기록하는 편이 더 자연스럽다고 판단했다. + ## 2026-03-27 v0.1.52 - 관리자 확인용 완성본은 사이트 전체가 아니라 보드만 보여주는 preview 전용 모드가 더 적합하다고 판단했다. - 티어표 썸네일은 비어 있는 것보다 자동 기본값이 있는 편이 낫다고 보고, 사용자가 직접 지정하지 않으면 티어표 아이템 중 대표 이미지를 자동 썸네일로 채우기로 결정했다. diff --git a/docs/map.md b/docs/map.md index 4fc8645..70662b7 100644 --- a/docs/map.md +++ b/docs/map.md @@ -42,7 +42,7 @@ ## 공통 레이아웃 - 앱 셸 파일: `frontend/src/App.vue` -- 역할: 상단 내비게이션, 로그인 상태 반영, 아바타 메뉴, 관리자 메뉴 노출 제어, 전역 우측 상단 토스트 렌더링 +- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 사용자 메뉴, 관리자 메뉴 노출 제어, 전역 우측 상단 토스트 렌더링 ## 백엔드 진입점 - 서버 엔트리: `backend/index.js` diff --git a/docs/spec.md b/docs/spec.md index f0f8eaf..21500c1 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -9,6 +9,7 @@ - 운영 배포: `frontend(Nginx 정적 서빙 + /api,/uploads 프록시) + backend + mariadb` Docker Compose 구조 - NAS HTTPS 리버스 프록시 운영 시 프런트 Nginx는 백엔드로 `X-Forwarded-Proto: https`를 전달하고, Express 세션은 프록시 환경에서 `secure` 쿠키를 허용하도록 설정한다. - 프런트 파비콘은 운영 정적 파일 차단 영향을 줄이기 위해 `index.html`의 인라인 데이터 URL로 제공한다. +- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, preview 모드에서는 이 셸을 숨기고 콘텐츠만 렌더링한다. ## 데이터 저장 구조 - 메인 데이터베이스: MariaDB `tier_cursor` (기본값) @@ -19,6 +20,15 @@ - 커스텀 아이템: `backend/uploads/custom/` - 시드 이미지: `backend/uploads/seeds/` +## 화면 구조 +- 좌측 패널 + - 사용자 요약, 빠른 검색 버튼, 주요 라우트 내비게이션, 즐겨찾기 성격의 빠른 링크, 관리자 진입 버튼을 배치한다. +- 중앙 워크스페이스 + - 현재 라우트의 핵심 콘텐츠를 렌더링하는 영역이며, 홈/목록 계열 화면은 카드형 대시보드 레이아웃을 우선 적용한다. +- 우측 패널 + - 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다. + - 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다. + ## DB 스키마 - `users` - `id`: string diff --git a/docs/todo.md b/docs/todo.md index 71070d4..2ba3530 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,10 +1,11 @@ # 할 일 및 이슈 ## 즉시 확인 필요 +- 피그마 기반 리디자인은 현재 공통 셸과 카드 목록 화면까지만 1차 적용된 상태이므로, 에디터/관리자 우측 옵션 패널을 시안 구조에 맞게 실제 기능 패널로 이관하는 작업이 남아 있다. +- 머티리얼 아이콘 SVG는 아직 임시 문자/배지 스타일로 대체된 부분이 있으므로, 최종 아이콘 에셋을 받아 반영하는 작업이 필요하다. - 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다. - 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다. - 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다. -- 티어표 썸네일은 이제 대표 아이템 기반 자동 생성까지 지원하므로, 필요하면 업로드 이미지 크롭 UX나 자동 후보 선택 개선을 추가 검토한다. - 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다. - 템플릿 등록/업데이트 요청은 현재 승인/반려만 지원하므로, 필요하면 관리자 코멘트나 사용자용 요청 상태 이력 화면을 추가 검토한다. - 관리자 티어표 관리의 템플릿 생성은 현재 `freeform`만 직접 지원하므로, 필요하면 일반 게임 티어표의 전체 아이템을 복제한 파생 템플릿 생성 UX도 검토한다. diff --git a/docs/update.md b/docs/update.md index 44bd976..8744fd5 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-03-30 v1.2.0 +- **피그마 기반 공통 앱 셸 1차 적용**: 상단 헤더 중심 구조를 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 앱 셸로 재구성하고, 데스크톱 기준의 어두운 대시보드형 톤으로 전환 +- **홈/목록 화면 카드 UI 리디자인**: 홈, 게임 허브, 내 티어표, 즐겨찾기 화면의 카드 그리드와 툴바를 시안에 맞춰 더 조밀한 대시보드 형태로 재배치 +- **전역 스타일 리셋 정리**: 기존 Vite 기본 스타일 흔적을 제거하고, 서비스 전용 다크 테마와 입력/셀렉트/버튼 기본값을 새 레이아웃 기준으로 통일 + ## 2026-03-27 v0.1.52 - **관리자 완성본 프리뷰 전용화**: 관리자 모달의 완성본 확인은 이제 전용 preview 모드로 열려 전역 헤더와 편집/탐색 UI 없이 보드만 깔끔하게 확인할 수 있도록 정리 - **티어표 기본 썸네일 자동 생성**: 사용자가 별도 썸네일을 지정하지 않아도 저장 시 티어표에 포함된 아이템 중 대표 이미지를 골라 기본 썸네일을 자동으로 채우도록 보강 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 35ee9bc..0b43f5d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -9,15 +9,123 @@ const route = useRoute() const router = useRouter() const auth = useAuthStore() const { toasts, dismissToast } = useToast() -const isAdmin = computed(() => !!auth.user?.isAdmin) -const isPreviewMode = computed(() => route.query.preview === '1') -const avatarUrl = computed(() => { - if (!auth.user?.avatarSrc) return '' - return toApiUrl(auth.user.avatarSrc) -}) const menuOpen = ref(false) +const isAdmin = computed(() => !!auth.user?.isAdmin) +const isPreviewMode = computed(() => route.query.preview === '1') +const avatarUrl = computed(() => (auth.user?.avatarSrc ? toApiUrl(auth.user.avatarSrc) : '')) +const accountName = computed(() => { + const nickname = (auth.user?.nickname || '').trim() + if (nickname) return nickname + const email = (auth.user?.email || '').trim() + if (email) return email.split('@')[0] || email + return 'Guest' +}) +const accountEmail = computed(() => (auth.user?.email || '').trim() || '로그인 후 개인 메뉴를 사용할 수 있어요.') +const leftNavItems = computed(() => { + const items = [ + { key: 'home', label: 'Games', path: '/', initials: 'GM' }, + { key: 'me', label: '내 리스트', path: '/me', initials: 'ME', requiresAuth: true }, + { key: 'favorites', label: '즐겨찾기', path: '/favorites', initials: 'FV', requiresAuth: true }, + { key: 'profile', label: 'Settings', path: '/profile', initials: 'ST', requiresAuth: true }, + ] + if (isAdmin.value) { + items.push({ key: 'admin', label: 'Admin', path: '/admin', initials: 'AD' }) + } + return items.filter((item) => !item.requiresAuth || auth.user) +}) +const routeMeta = computed(() => { + if (route.name === 'home') { + return { + title: 'Main Title', + subtitle: '게임 선택 및 커스텀 티어표 진입', + contextTitle: '빠른 시작', + contextText: auth.user ? '커스텀 티어표를 만들거나 원하는 게임을 바로 선택할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.', + actionLabel: auth.user ? '커스텀 티어표 만들기' : '로그인하러 가기', + action: () => { + router.push(auth.user ? '/editor/freeform/new' : '/login') + }, + } + } + if (route.name === 'gameHub') { + return { + title: 'Tier Lists', + subtitle: '게임별 공개 티어표 목록', + contextTitle: '작성 작업', + contextText: auth.user ? '이 게임의 새 티어표를 만들거나 기존 공개 티어표를 확인할 수 있어요.' : '로그인 후 새 티어표를 만들 수 있어요.', + actionLabel: auth.user ? '새 티어표 만들기' : '로그인하러 가기', + action: () => { + const target = `/editor/${route.params.gameId}/new` + router.push(auth.user ? target : `/login?redirect=${target}`) + }, + } + } + if (route.name === 'editEditor' || route.name === 'newEditor') { + return { + title: 'Deck Builder', + subtitle: '티어표 편집 및 공유', + contextTitle: '편집 패널', + contextText: '현재 편집 옵션은 중앙 화면 안에 유지되어 있습니다. 다음 단계에서 우측 패널로 정리해갈게요.', + actionLabel: '게임 목록으로', + action: () => router.push('/'), + } + } + if (route.name === 'admin') { + return { + title: 'Admin Workspace', + subtitle: '게임·아이템·회원 관리', + contextTitle: '운영 노트', + contextText: '관리자 화면은 기능이 많아 우선 공통 셸 톤을 맞췄고, 세부 패널은 다음 단계에서 시안 방식으로 더 세밀하게 나눌 예정입니다.', + actionLabel: '게임 목록으로', + action: () => router.push('/'), + } + } + if (route.name === 'me') { + return { + title: 'My Lists', + subtitle: '내가 저장한 티어표', + contextTitle: '작성 이력', + contextText: '최근 저장한 티어표를 열람하고 정리할 수 있어요.', + actionLabel: '즐겨찾기 보기', + action: () => router.push('/favorites'), + } + } + if (route.name === 'favorites') { + return { + title: 'Favorites', + subtitle: '마음에 드는 티어표 모음', + contextTitle: '정리 도구', + contextText: '정렬과 검색으로 원하는 티어표를 빠르게 다시 찾을 수 있어요.', + actionLabel: '내 티어표 보기', + action: () => router.push('/me'), + } + } + if (route.name === 'profile') { + return { + title: 'Profile', + subtitle: '프로필 및 계정 설정', + contextTitle: '계정 관리', + contextText: '아바타와 닉네임을 관리하고 표시 정보를 확인할 수 있어요.', + actionLabel: '내 티어표 보기', + action: () => router.push('/me'), + } + } + return { + title: 'Tier Maker', + subtitle: 'by zenn', + contextTitle: 'Workspace', + contextText: '현재 화면에 맞는 도구와 안내를 여기에 배치할 수 있습니다.', + actionLabel: '홈으로', + action: () => router.push('/'), + } +}) +const favoriteLinks = computed(() => [ + { label: 'Games', path: '/' }, + ...(auth.user ? [{ label: 'Favorites', path: '/favorites' }] : []), + ...(auth.user ? [{ label: 'My Lists', path: '/me' }] : []), +]) + onMounted(async () => { await auth.refresh() document.addEventListener('click', onDocumentClick) @@ -34,16 +142,21 @@ watch( } ) -function toggleMenu() { - menuOpen.value = !menuOpen.value -} - function onDocumentClick(event) { - if (!event.target.closest('.user')) { + if (!event.target.closest('.appUserCard')) { menuOpen.value = false } } +function isRouteActive(path) { + if (path === '/') return route.path === '/' + return route.path.startsWith(path) +} + +function toggleMenu() { + menuOpen.value = !menuOpen.value +} + function goProfile() { menuOpen.value = false router.push('/profile') @@ -57,176 +170,496 @@ async function logout() { diff --git a/frontend/src/style.css b/frontend/src/style.css index 5691b68..b65b9d4 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1,57 +1,49 @@ :root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; - - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; - - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; - color: var(--text); - background: var(--bg); + font-family: 'Pretendard', 'Inter', 'Segoe UI', sans-serif; + line-height: 1.5; + font-weight: 400; + color: rgba(255, 255, 255, 0.92); + background: #121212; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } } -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } +* { + box-sizing: border-box; +} - #social .button-icon { - filter: invert(1) brightness(2); - } +html, +body, +#app { + min-height: 100vh; } body { margin: 0; + background: #121212; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button { + appearance: none; +} + +a { + color: inherit; +} + +input, +select, +textarea { + color: rgba(255, 255, 255, 0.92); } select { @@ -59,253 +51,24 @@ select { -webkit-appearance: none; -moz-appearance: none; background-image: - linear-gradient(45deg, transparent 50%, rgba(255, 255, 255, 0.78) 50%), - linear-gradient(135deg, rgba(255, 255, 255, 0.78) 50%, transparent 50%); + linear-gradient(45deg, transparent 50%, rgba(255, 255, 255, 0.72) 50%), + linear-gradient(135deg, rgba(255, 255, 255, 0.72) 50%, transparent 50%); background-position: - calc(100% - 18px) calc(50% - 2px), - calc(100% - 12px) calc(50% - 2px); + calc(100% - 20px) calc(50% - 2px), + calc(100% - 14px) calc(50% - 2px); background-size: 6px 6px, 6px 6px; background-repeat: no-repeat; - padding-right: 36px; + padding-right: 40px; } h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); -} - -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } -} -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } -} +h2, +h3, +h4, p { margin: 0; } -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); -} - -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); -} - -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - #app { - /* width: 1126px; */ - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; - display: flex; - flex-direction: column; - box-sizing: border-box; -} - -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } -} - -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } -} - -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } -} - -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { - position: relative; width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } } diff --git a/frontend/src/views/FavoriteTierListsView.vue b/frontend/src/views/FavoriteTierListsView.vue index 3744c58..970975f 100644 --- a/frontend/src/views/FavoriteTierListsView.vue +++ b/frontend/src/views/FavoriteTierListsView.vue @@ -108,7 +108,7 @@ onMounted(loadFavorites) diff --git a/frontend/src/views/MyTierListsView.vue b/frontend/src/views/MyTierListsView.vue index be87ad8..3b2b15b 100644 --- a/frontend/src/views/MyTierListsView.vue +++ b/frontend/src/views/MyTierListsView.vue @@ -101,18 +101,19 @@ async function removeList(t) {