Compare commits

...

1 Commits

Author SHA1 Message Date
6fceeaf15b 릴리스: v1.2.6 목록 화면 카드 레이아웃 정리 2026-03-30 16:08:00 +09:00
9 changed files with 348 additions and 153 deletions

View File

@@ -1,5 +1,10 @@
# 의사결정 이력 # 의사결정 이력
## 2026-03-30 v1.2.6
- 홈, 게임 허브, 내 티어표, 즐겨찾기처럼 카드 중심 화면은 한 번에 같은 카드 문법으로 맞춰야 전체 앱이 하나의 제품처럼 보이므로, 목록 화면을 우선 통일하기로 했다.
- 홈 화면은 단순 게임 버튼 모음보다 상태 카드와 CTA가 있는 라이브러리 대시보드 쪽이 피그마 톤에 더 가깝다고 판단했다.
- 게임 허브와 개인 목록도 썸네일/작성자/메타의 비중이 비슷하므로, 화면마다 다른 카드 구조를 유지하기보다 동일한 정보 계층을 반복하는 편이 더 읽기 쉽다고 정리했다.
## 2026-03-30 v1.2.5 ## 2026-03-30 v1.2.5
- 관리자 화면도 에디터와 마찬가지로 공통 우측 패널보다 전용 로컬 운영 패널이 더 중요하므로, `/admin` 역시 화면 내부 `320px` 패널을 사용하는 포커스 화면으로 정리하기로 결정했다. - 관리자 화면도 에디터와 마찬가지로 공통 우측 패널보다 전용 로컬 운영 패널이 더 중요하므로, `/admin` 역시 화면 내부 `320px` 패널을 사용하는 포커스 화면으로 정리하기로 결정했다.
- 관리자 기능은 탭, 검색, 필터, 빠른 액션이 본문에 섞이면 밀도가 너무 높아지므로, 우측 패널에는 제어 요소를 모으고 중앙에는 실제 관리 대상 목록과 상세만 남기는 편이 낫다고 판단했다. - 관리자 기능은 탭, 검색, 필터, 빠른 액션이 본문에 섞이면 밀도가 너무 높아지므로, 우측 패널에는 제어 요소를 모으고 중앙에는 실제 관리 대상 목록과 상세만 남기는 편이 낫다고 판단했다.

View File

@@ -2,12 +2,12 @@
## `/` ## `/`
- 화면 파일: `frontend/src/views/HomeView.vue` - 화면 파일: `frontend/src/views/HomeView.vue`
- 역할: 게임 목록 표시, 게임 카드 클릭 이동, `직접 티어표 만들기` 진입 - 역할: 상단 상태/CTA가 있는 라이브러리 대시보드, 게임 카드 클릭 이동, `직접 티어표 만들기` 진입
- 연동 API: `GET /api/games` - 연동 API: `GET /api/games`
## `/games/:gameId` ## `/games/:gameId`
- 화면 파일: `frontend/src/views/GameHubView.vue` - 화면 파일: `frontend/src/views/GameHubView.vue`
- 역할: 선택한 게임 정보 표시, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 상단 썸네일/작성자 표시, 즐겨찾기 토글, 새 티어표 작성 진입 - 역할: 선택한 게임 정보 표시, 상단 통계/생성 CTA, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 상단 썸네일/작성자 표시, 즐겨찾기 상태 표시, 새 티어표 작성 진입
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite` - 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`
## `/editor/:gameId/new`, `/editor/:gameId/:tierListId` ## `/editor/:gameId/new`, `/editor/:gameId/:tierListId`
@@ -22,12 +22,12 @@
## `/me` ## `/me`
- 화면 파일: `frontend/src/views/MyTierListsView.vue` - 화면 파일: `frontend/src/views/MyTierListsView.vue`
- 역할: 내 티어표 목록 조회, 상단 썸네일 카드 표시, 편집 화면으로 이동, 작성자 본인 티어표 삭제 - 역할: 내 티어표 목록 조회, 라이브러리 카드형 썸네일 표시, 편집 화면으로 이동, 작성자 본인 티어표 삭제
- 연동 API: `GET /api/tierlists/me`, `DELETE /api/tierlists/:id` - 연동 API: `GET /api/tierlists/me`, `DELETE /api/tierlists/:id`
## `/favorites` ## `/favorites`
- 화면 파일: `frontend/src/views/FavoriteTierListsView.vue` - 화면 파일: `frontend/src/views/FavoriteTierListsView.vue`
- 역할: 즐겨찾기한 티어표 목록 조회, 검색/정렬, 편집 화면 이동, 즐겨찾기 해제 - 역할: 즐겨찾기한 티어표 목록 조회, 검색/정렬, 라이브러리 카드형 표시, 편집 화면 이동, 즐겨찾기 상태 확인
- 연동 API: `GET /api/tierlists/favorites/me`, `DELETE /api/tierlists/:id/favorite` - 연동 API: `GET /api/tierlists/favorites/me`, `DELETE /api/tierlists/:id/favorite`
## `/admin` ## `/admin`

View File

@@ -27,6 +27,7 @@
- 사용자 요약, 빠른 검색 버튼, 주요 라우트 내비게이션, 즐겨찾기 성격의 빠른 링크, 관리자 진입 버튼을 배치한다. - 사용자 요약, 빠른 검색 버튼, 주요 라우트 내비게이션, 즐겨찾기 성격의 빠른 링크, 관리자 진입 버튼을 배치한다.
- 중앙 워크스페이스 - 중앙 워크스페이스
- 현재 라우트의 핵심 콘텐츠를 렌더링하는 영역이며, 홈/목록 계열 화면은 카드형 대시보드 레이아웃을 우선 적용한다. - 현재 라우트의 핵심 콘텐츠를 렌더링하는 영역이며, 홈/목록 계열 화면은 카드형 대시보드 레이아웃을 우선 적용한다.
- 홈, 게임 허브, 내 티어표, 즐겨찾기 화면은 같은 카드 문법(상단 16:9 썸네일, 제목, 작성자/보조 메타, 하단 상태 영역)을 공유하도록 정리한다.
- 우측 패널 - 우측 패널
- 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다. - 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다.
- 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다. - 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다.

View File

@@ -2,6 +2,7 @@
## 즉시 확인 필요 ## 즉시 확인 필요
- 피그마 기반 리디자인은 현재 공통 셸과 카드 목록 화면, 포커스 화면 안정화까지만 반영된 상태이므로, 에디터/관리자 우측 옵션 패널을 시안 구조에 맞게 실제 기능 패널로 이관하는 작업이 남아 있다. - 피그마 기반 리디자인은 현재 공통 셸과 카드 목록 화면, 포커스 화면 안정화까지만 반영된 상태이므로, 에디터/관리자 우측 옵션 패널을 시안 구조에 맞게 실제 기능 패널로 이관하는 작업이 남아 있다.
- 홈/게임 허브/내 티어표/즐겨찾기 카드 문법은 어느 정도 통일됐지만, 아직 실제 SVG 아이콘, 미세 간격, hover/selection 상태 같은 디테일은 더 다듬을 필요가 있다.
- 공통 우측 패널의 토글과 독립 컬럼 구조는 반영되었지만, 현재는 안내 카드 중심이므로 실제 화면별 기능 컨트롤을 이 패널로 옮기는 작업이 남아 있다. - 공통 우측 패널의 토글과 독립 컬럼 구조는 반영되었지만, 현재는 안내 카드 중심이므로 실제 화면별 기능 컨트롤을 이 패널로 옮기는 작업이 남아 있다.
- 티어표 편집 화면과 관리자 화면 모두 로컬 우측 패널 구조로 옮겼지만, 아직 세부 카드 밀도와 아이콘/모션 디테일은 피그마 시안 수준으로 더 다듬을 필요가 있다. - 티어표 편집 화면과 관리자 화면 모두 로컬 우측 패널 구조로 옮겼지만, 아직 세부 카드 밀도와 아이콘/모션 디테일은 피그마 시안 수준으로 더 다듬을 필요가 있다.
- 머티리얼 아이콘 SVG는 아직 임시 문자/배지 스타일로 대체된 부분이 있으므로, 최종 아이콘 에셋을 받아 반영하는 작업이 필요하다. - 머티리얼 아이콘 SVG는 아직 임시 문자/배지 스타일로 대체된 부분이 있으므로, 최종 아이콘 에셋을 받아 반영하는 작업이 필요하다.

View File

@@ -1,5 +1,10 @@
# 업데이트 로그 # 업데이트 로그
## 2026-03-30 v1.2.6
- **목록형 화면 카드 문법 통일**: 홈, 게임 허브, 내 티어표, 즐겨찾기 화면의 카드형 목록을 동일한 썸네일/제목/작성자/메타 구조로 정리해 대시보드 톤을 맞춤
- **홈 화면 대시보드 재정렬**: 메인 게임 라이브러리 화면에 상단 상태 카드와 CTA를 추가하고, 게임 카드는 `16:9` 썸네일 + ID 메타를 갖는 라이브러리 카드 형태로 재배치
- **게임 허브 헤더/검색 정리**: 게임 허브는 상단 통계와 생성 버튼, 보조 설명을 포함한 헤더로 재구성하고, 공개 티어표 카드도 같은 카드 밀도로 재정리
## 2026-03-30 v1.2.5 ## 2026-03-30 v1.2.5
- **관리자 로컬 우측 패널 이관**: 관리자 화면도 공통 우측 패널 대신 화면 내부의 `320px` 전용 운영 패널을 사용하도록 정리하고, 탭·검색·필터·빠른 액션을 우측으로 이동 - **관리자 로컬 우측 패널 이관**: 관리자 화면도 공통 우측 패널 대신 화면 내부의 `320px` 전용 운영 패널을 사용하도록 정리하고, 탭·검색·필터·빠른 액션을 우측으로 이동
- **관리 화면 본문 집중도 개선**: 중앙 영역은 상단 고정 게임 순서, 선택된 게임 상세, 커스텀 아이템 카드, 템플릿 요청/전체 티어표, 회원 카드 같은 실제 관리 대상만 남기고 빈 상태 안내도 별도 패널로 정리 - **관리 화면 본문 집중도 개선**: 중앙 영역은 상단 고정 게임 순서, 선택된 게임 상세, 커스텀 아이템 카드, 템플릿 요청/전체 티어표, 회원 카드 같은 실제 관리 대상만 남기고 빈 상태 안내도 별도 패널로 정리

View File

@@ -62,6 +62,7 @@ onMounted(loadFavorites)
<section class="wrap"> <section class="wrap">
<div class="head"> <div class="head">
<div> <div>
<div class="head__eyebrow">Collection</div>
<h2 class="title"> 즐겨찾기</h2> <h2 class="title"> 즐겨찾기</h2>
<div class="desc">마음에 드는 티어표를 모아보고, 원하는 기준으로 정렬할 있어요.</div> <div class="desc">마음에 드는 티어표를 모아보고, 원하는 기준으로 정렬할 있어요.</div>
</div> </div>
@@ -78,23 +79,23 @@ onMounted(loadFavorites)
<div v-if="favorites.length === 0" class="empty">즐겨찾기한 티어표가 없어요.</div> <div v-if="favorites.length === 0" class="empty">즐겨찾기한 티어표가 없어요.</div>
<div v-else class="list"> <div v-else class="list">
<article v-for="tierList in favorites" :key="tierList.id" class="row"> <article v-for="tierList in favorites" :key="tierList.id" class="boardCard">
<button class="row__body" @click="openTierList(tierList)"> <button class="boardCard__body" @click="openTierList(tierList)">
<div class="row__thumbWrap"> <div class="boardCard__thumbWrap">
<img v-if="tierListThumbnailUrl(tierList)" class="row__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" /> <img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" />
<div v-else class="row__thumbPlaceholder"></div> <div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div> </div>
<div class="row__head"> <div class="boardCard__head">
<div class="row__title">{{ tierList.title }}</div> <div class="boardCard__title">{{ tierList.title }}</div>
<div class="row__author"> <div class="boardCard__author">
<img v-if="avatarSrcOf(tierList)" class="row__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" /> <img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" />
<div v-else class="row__avatar row__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div> <div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
<span>by {{ displayNameOf(tierList) }}</span> <span>by {{ displayNameOf(tierList) }}</span>
</div> </div>
</div> </div>
</button> </button>
<div class="row__foot"> <div class="boardCard__foot">
<div class="row__meta"> <div class="boardCard__meta">
<div>{{ tierList.gameName || tierList.gameId }}</div> <div>{{ tierList.gameName || tierList.gameId }}</div>
<div>{{ sortLabel }}: {{ fmt(sort === 'favorited' ? tierList.favoritedAt : tierList.updatedAt) }}</div> <div>{{ sortLabel }}: {{ fmt(sort === 'favorited' ? tierList.favoritedAt : tierList.updatedAt) }}</div>
</div> </div>
@@ -117,9 +118,15 @@ onMounted(loadFavorites)
align-items: flex-end; align-items: flex-end;
flex-wrap: wrap; flex-wrap: wrap;
} }
.head__eyebrow {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.42);
}
.title { .title {
margin: 0; margin: 4px 0 0;
font-size: 30px; font-size: 32px;
color: rgba(255, 255, 255, 0.96); color: rgba(255, 255, 255, 0.96);
letter-spacing: -0.04em; letter-spacing: -0.04em;
} }
@@ -157,15 +164,16 @@ onMounted(loadFavorites)
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px; gap: 18px;
} }
.row { .boardCard {
border-radius: 14px; border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.16); border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82); background: rgba(62, 62, 62, 0.82);
overflow: hidden; overflow: hidden;
display: grid; display: grid;
gap: 10px; gap: 10px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
} }
.row__body { .boardCard__body {
border: 0; border: 0;
background: transparent; background: transparent;
color: inherit; color: inherit;
@@ -175,60 +183,67 @@ onMounted(loadFavorites)
display: grid; display: grid;
gap: 10px; gap: 10px;
} }
.row__thumbWrap { .boardCard__thumbWrap {
width: 100%; width: 100%;
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
background: #555; background: #555;
display: grid;
place-items: center;
} }
.row__thumb, .boardCard__thumb,
.row__thumbPlaceholder { .boardCard__thumbPlaceholder {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: block; display: block;
} }
.row__thumb { .boardCard__thumb {
object-fit: cover; object-fit: cover;
} }
.row__thumbPlaceholder { .boardCard__thumbPlaceholder {
background: #555; background: #555;
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.4);
font-size: 13px;
font-weight: 700;
} }
.row__head { .boardCard__head {
padding: 14px 14px 0; padding: 14px 14px 0;
display: grid; display: grid;
gap: 10px; gap: 10px;
} }
.row__title { .boardCard__title {
font-weight: 800; font-weight: 800;
font-size: 18px; font-size: 18px;
} }
.row__author { .boardCard__author {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
font-size: 13px; font-size: 13px;
opacity: 0.86; opacity: 0.86;
} }
.row__avatar { .boardCard__avatar {
width: 28px; width: 28px;
height: 28px; height: 28px;
border-radius: 999px; border-radius: 999px;
object-fit: cover; object-fit: cover;
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
} }
.row__avatar--fallback { .boardCard__avatar--fallback {
display: grid; display: grid;
place-items: center; place-items: center;
font-size: 12px; font-size: 12px;
font-weight: 900; font-weight: 900;
} }
.row__foot { .boardCard__foot {
padding: 0 14px 14px; padding: 0 14px 14px;
display: flex; display: flex;
gap: 12px; gap: 12px;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
} }
.row__meta { .boardCard__meta {
display: grid; display: grid;
gap: 4px; gap: 4px;
opacity: 0.78; opacity: 0.78;

View File

@@ -77,13 +77,17 @@ function submitSearch() {
</script> </script>
<template> <template>
<section class="head"> <section class="dashboardHero">
<div class="head__left"> <div class="dashboardHero__left">
<div class="kicker">Collection</div> <div class="dashboardHero__eyebrow">Collection</div>
<h2 class="title">{{ gameName || gameId }}</h2> <h2 class="dashboardHero__title">{{ gameName || gameId }}</h2>
<p class="desc"> 티어표를 만들거나, 다른 사람들이 올린 티어표를 카드형 목록으로 탐색할 있어요.</p> <p class="dashboardHero__desc"> 게임의 공개 티어표를 탐색하고, 바로 보드를 만들어 같은 흐름으로 이어갈 있어요.</p>
</div> </div>
<div class="head__right"> <div class="dashboardHero__right">
<div class="dashboardStat">
<span class="dashboardStat__label">Visible Lists</span>
<strong class="dashboardStat__value">{{ tierLists.length }}</strong>
</div>
<button class="primary" @click="createNew">{{ auth.user ? '새로운 티어표 만들기' : '로그인 티어표 만들기' }}</button> <button class="primary" @click="createNew">{{ auth.user ? '새로운 티어표 만들기' : '로그인 티어표 만들기' }}</button>
</div> </div>
</section> </section>
@@ -91,7 +95,10 @@ function submitSearch() {
<div v-if="error" class="error">{{ error }}</div> <div v-if="error" class="error">{{ error }}</div>
<section class="panel"> <section class="panel">
<div class="panel__head"> <div class="panel__head">
<div class="panel__title">공개 티어표</div> <div>
<div class="panel__title">공개 티어표</div>
<div class="panel__sub">제목이나 작성자로 빠르게 좁혀볼 있어요.</div>
</div>
<div class="searchBar"> <div class="searchBar">
<input v-model="query" class="searchBar__input" placeholder="제목 또는 작성자 검색" @keydown.enter.prevent="submitSearch" /> <input v-model="query" class="searchBar__input" placeholder="제목 또는 작성자 검색" @keydown.enter.prevent="submitSearch" />
<button class="searchBar__button" @click="submitSearch">검색</button> <button class="searchBar__button" @click="submitSearch">검색</button>
@@ -99,23 +106,23 @@ function submitSearch() {
</div> </div>
<div v-if="tierLists.length === 0" class="empty">아직 공개 티어표가 없어요.</div> <div v-if="tierLists.length === 0" class="empty">아직 공개 티어표가 없어요.</div>
<div v-else class="list"> <div v-else class="list">
<article v-for="t in tierLists" :key="t.id" class="row"> <article v-for="t in tierLists" :key="t.id" class="boardCard">
<button class="row__body" @click="openTierList(t.id)"> <button class="boardCard__body" @click="openTierList(t.id)">
<div class="row__thumbWrap"> <div class="boardCard__thumbWrap">
<img v-if="tierListThumbnailUrl(t)" class="row__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" /> <img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
<div v-else class="row__thumbPlaceholder"></div> <div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div> </div>
<div class="row__head"> <div class="boardCard__head">
<div class="row__title">{{ t.title }}</div> <div class="boardCard__title">{{ t.title }}</div>
<div class="row__author"> <div class="boardCard__author">
<img v-if="avatarSrcOf(t)" class="row__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" /> <img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<div v-else class="row__avatar row__avatar--fallback">{{ avatarFallbackOf(t) }}</div> <div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<span>by {{ displayNameOf(t) }}</span> <span>by {{ displayNameOf(t) }}</span>
</div> </div>
</div> </div>
</button> </button>
<div class="row__foot"> <div class="boardCard__foot">
<div class="row__meta">{{ fmt(t.updatedAt) }}</div> <div class="boardCard__meta">{{ fmt(t.updatedAt) }}</div>
<div class="favoriteStat" :title="t.isFavorited ? '이미 즐겨찾기한 티어표' : '즐겨찾기 수'"> <div class="favoriteStat" :title="t.isFavorited ? '이미 즐겨찾기한 티어표' : '즐겨찾기 수'">
{{ t.isFavorited ? '★' : '☆' }} {{ t.favoriteCount || 0 }} {{ t.isFavorited ? '★' : '☆' }} {{ t.favoriteCount || 0 }}
</div> </div>
@@ -126,44 +133,71 @@ function submitSearch() {
</template> </template>
<style scoped> <style scoped>
.head { .dashboardHero {
display: flex; display: flex;
gap: 18px; gap: 18px;
align-items: flex-end; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
flex-wrap: wrap; flex-wrap: wrap;
padding: 4px 2px 18px; padding: 4px 2px 18px;
} }
.kicker { .dashboardHero__left {
display: grid;
gap: 8px;
}
.dashboardHero__right {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.dashboardHero__eyebrow {
font-size: 12px; font-size: 12px;
color: rgba(255, 255, 255, 0.42); color: rgba(255, 255, 255, 0.42);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
.title { .dashboardHero__title {
margin: 4px 0 6px; margin: 4px 0 6px;
font-size: 30px; font-size: 32px;
letter-spacing: -0.04em; letter-spacing: -0.04em;
color: rgba(255, 255, 255, 0.96); color: rgba(255, 255, 255, 0.96);
} }
.desc { .dashboardHero__desc {
margin: 0; margin: 0;
color: rgba(255, 255, 255, 0.58); color: rgba(255, 255, 255, 0.58);
max-width: 720px;
}
.dashboardStat {
display: grid;
gap: 2px;
min-width: 112px;
padding: 10px 14px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
}
.dashboardStat__label {
font-size: 11px;
color: rgba(255, 255, 255, 0.48);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.dashboardStat__value {
font-size: 18px;
font-weight: 900;
} }
.primary { .primary {
padding: 10px 14px; padding: 12px 16px;
border-radius: 10px; border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(77, 127, 233, 0.96);
background: rgba(255, 255, 255, 0.08); background: rgba(77, 127, 233, 0.88);
color: rgba(255, 255, 255, 0.92); color: #fff;
cursor: pointer; cursor: pointer;
font-weight: 700; font-weight: 800;
}
.primary:hover {
background: rgba(255, 255, 255, 0.12);
} }
.panel { .panel {
border: 1px solid rgba(255, 255, 255, 0.08); /* border: 1px solid rgba(255, 255, 255, 0.08); */
background: transparent; background: transparent;
border-radius: 0; border-radius: 0;
padding: 0; padding: 0;
@@ -177,6 +211,12 @@ function submitSearch() {
} }
.panel__title { .panel__title {
font-weight: 800; font-weight: 800;
font-size: 18px;
}
.panel__sub {
margin-top: 6px;
color: rgba(255, 255, 255, 0.56);
font-size: 13px;
} }
.panel__head { .panel__head {
display: flex; display: flex;
@@ -201,8 +241,8 @@ function submitSearch() {
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.92);
} }
.searchBar__button { .searchBar__button {
padding: 10px 12px; padding: 10px 14px;
border-radius: 10px; border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.92);
@@ -217,8 +257,8 @@ function submitSearch() {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px; gap: 18px;
} }
.row { .boardCard {
border-radius: 14px; border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.16); border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82); background: rgba(62, 62, 62, 0.82);
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.92);
@@ -227,11 +267,12 @@ function submitSearch() {
align-content: start; align-content: start;
min-height: 168px; min-height: 168px;
overflow: hidden; overflow: hidden;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
} }
.row:hover { .boardCard:hover {
background: rgba(255, 255, 255, 0.05); background: rgba(70, 70, 70, 0.96);
} }
.row__body { .boardCard__body {
text-align: left; text-align: left;
padding: 0; padding: 0;
border: 0; border: 0;
@@ -242,35 +283,42 @@ function submitSearch() {
display: grid; display: grid;
gap: 10px; gap: 10px;
} }
.row__thumbWrap { .boardCard__thumbWrap {
width: 100%; width: 100%;
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
background: #555; background: #555;
display: grid;
place-items: center;
} }
.row__thumb { .boardCard__thumb {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
display: block; display: block;
} }
.row__thumbPlaceholder { .boardCard__thumbPlaceholder {
width: 100%; width: 100%;
height: 100%; height: 100%;
background: #555; background: #555;
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.4);
font-size: 13px;
font-weight: 700;
} }
.row__title { .boardCard__title {
font-weight: 800; font-weight: 800;
min-width: 0; min-width: 0;
font-size: 18px; font-size: 18px;
line-height: 1.35; line-height: 1.35;
} }
.row__head { .boardCard__head {
padding: 14px 14px 0; padding: 14px 14px 0;
display: grid; display: grid;
gap: 12px; gap: 12px;
align-content: start; align-content: start;
} }
.row__author { .boardCard__author {
display: inline-flex; display: inline-flex;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
@@ -278,7 +326,7 @@ function submitSearch() {
opacity: 0.86; opacity: 0.86;
flex: 0 0 auto; flex: 0 0 auto;
} }
.row__avatar { .boardCard__avatar {
width: 28px; width: 28px;
height: 28px; height: 28px;
border-radius: 999px; border-radius: 999px;
@@ -286,17 +334,17 @@ function submitSearch() {
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
} }
.row__avatar--fallback { .boardCard__avatar--fallback {
display: grid; display: grid;
place-items: center; place-items: center;
font-size: 12px; font-size: 12px;
font-weight: 900; font-weight: 900;
} }
.row__meta { .boardCard__meta {
opacity: 0.78; opacity: 0.78;
font-size: 13px; font-size: 13px;
} }
.row__foot { .boardCard__foot {
padding: 0 14px 14px; padding: 0 14px 14px;
display: flex; display: flex;
gap: 12px; gap: 12px;
@@ -323,6 +371,13 @@ function submitSearch() {
} }
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.dashboardHero__right {
width: 100%;
}
.dashboardStat,
.primary {
width: 100%;
}
.list { .list {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@@ -41,32 +41,43 @@ function thumbUrl(g) {
</script> </script>
<template> <template>
<section class="topBar"> <section class="dashboardHero">
<div class="topBar__copy"> <div class="dashboardHero__copy">
<h1 class="topBar__title">Main Title</h1> <div class="dashboardHero__eyebrow">Workspace</div>
<p class="topBar__desc">게임 선택과 커스텀 티어표 진입을 하나의 대시보드처럼 정리했습니다.</p> <h1 class="dashboardHero__title">Game Library</h1>
<p class="dashboardHero__desc">자주 쓰는 게임 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 있어요.</p>
</div> </div>
<div class="toolbar"> <div class="dashboardToolbar">
<button class="toolbar__ghost" @click="goFreeform">Toggle Filter</button> <div class="dashboardToolbar__stat">
<button class="toolbar__select" @click="goFreeform">Select Filter</button> <span class="dashboardToolbar__label">Visible Games</span>
<strong class="dashboardToolbar__value">{{ games.length }}</strong>
</div>
<button class="dashboardToolbar__ghost" @click="goFreeform">Quick Start</button>
<button class="dashboardToolbar__ghost" @click="goFreeform">Browse All</button>
<button class="customTierBtn" @click="goFreeform">{{ auth.user ? '+ 커스텀 티어표 만들기' : '+ 로그인 커스텀 티어표 만들기' }}</button> <button class="customTierBtn" @click="goFreeform">{{ auth.user ? '+ 커스텀 티어표 만들기' : '+ 로그인 커스텀 티어표 만들기' }}</button>
</div> </div>
</section> </section>
<div v-if="error" class="error">{{ error }}</div> <div v-if="error" class="error">{{ error }}</div>
<section class="grid"> <section class="libraryGrid">
<button v-for="g in games" :key="g.id" class="card" @click="goGame(g.id)"> <button v-for="g in games" :key="g.id" class="libraryCard" @click="goGame(g.id)">
<div class="thumbWrap"> <div class="libraryCard__thumbWrap">
<img v-if="thumbUrl(g)" class="thumb" :src="thumbUrl(g)" :alt="g.name" /> <img v-if="thumbUrl(g)" class="libraryCard__thumb" :src="thumbUrl(g)" :alt="g.name" />
<div v-else class="thumbFallback">{{ g.name[0] }}</div> <div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
</div>
<div class="libraryCard__body">
<div class="libraryCard__title">{{ g.name }}</div>
<div class="libraryCard__meta">
<span class="libraryCard__metaDot"></span>
<span>{{ g.id }}</span>
</div>
</div> </div>
<div class="card__title">{{ g.name }}</div>
</button> </button>
</section> </section>
</template> </template>
<style scoped> <style scoped>
.topBar { .dashboardHero {
display: flex; display: flex;
gap: 18px; gap: 18px;
justify-content: space-between; justify-content: space-between;
@@ -75,32 +86,57 @@ function thumbUrl(g) {
margin-top: 2px; margin-top: 2px;
margin-bottom: 18px; margin-bottom: 18px;
} }
.topBar__copy { .dashboardHero__copy {
display: grid; display: grid;
gap: 8px; gap: 8px;
} }
.topBar__title { .dashboardHero__eyebrow {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.42);
}
.dashboardHero__title {
margin: 0; margin: 0;
font-size: 32px; font-size: 34px;
letter-spacing: -0.04em; letter-spacing: -0.04em;
color: rgba(255, 255, 255, 0.96); color: rgba(255, 255, 255, 0.96);
} }
.topBar__desc { .dashboardHero__desc {
margin: 0; margin: 0;
color: rgba(255, 255, 255, 0.58); color: rgba(255, 255, 255, 0.58);
line-height: 1.5; line-height: 1.5;
max-width: 720px;
} }
.toolbar { .dashboardToolbar {
display: flex; display: flex;
gap: 10px; gap: 10px;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
} }
.toolbar__ghost, .dashboardToolbar__stat {
.toolbar__select, display: grid;
gap: 2px;
min-width: 112px;
padding: 10px 14px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
}
.dashboardToolbar__label {
font-size: 11px;
color: rgba(255, 255, 255, 0.48);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.dashboardToolbar__value {
font-size: 18px;
font-weight: 900;
}
.dashboardToolbar__ghost,
.customTierBtn { .customTierBtn {
padding: 10px 14px; padding: 10px 14px;
border-radius: 10px; border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.84); color: rgba(255, 255, 255, 0.84);
@@ -108,9 +144,11 @@ function thumbUrl(g) {
cursor: pointer; cursor: pointer;
} }
.customTierBtn { .customTierBtn {
background: rgba(255, 255, 255, 0.08); background: rgba(77, 127, 233, 0.88);
border-color: rgba(77, 127, 233, 0.96);
color: #fff;
} }
.grid { .libraryGrid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px; gap: 18px;
@@ -123,69 +161,87 @@ function thumbUrl(g) {
background: rgba(239, 68, 68, 0.1); background: rgba(239, 68, 68, 0.1);
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.92);
} }
.card { .libraryCard {
text-align: left; text-align: left;
padding: 12px; padding: 12px;
border-radius: 14px; border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.16); border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82); background: rgba(62, 62, 62, 0.82);
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.92);
cursor: pointer; cursor: pointer;
display: grid; display: grid;
gap: 12px; gap: 12px;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
} }
.card:hover { .libraryCard:hover {
background: rgba(72, 72, 72, 0.92); background: rgba(70, 70, 70, 0.96);
} }
.thumbWrap { .libraryCard__thumbWrap {
width: 100%; width: 100%;
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
border-radius: 10px; border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.06);
background: #555; background: #555;
overflow: hidden; overflow: hidden;
display: grid; display: grid;
place-items: center; place-items: center;
} }
.thumb { .libraryCard__thumb {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
.thumbFallback { .libraryCard__thumbFallback {
font-size: 13px;
font-weight: 700; font-weight: 700;
font-size: 14px; font-size: 14px;
color: rgba(255, 255, 255, 0.4); color: rgba(255, 255, 255, 0.4);
} }
.card__title { .libraryCard__body {
display: grid;
gap: 8px;
}
.libraryCard__title {
font-weight: 800; font-weight: 800;
letter-spacing: -0.02em; letter-spacing: -0.02em;
font-size: 15px; font-size: 16px;
}
.libraryCard__meta {
display: inline-flex;
gap: 8px;
align-items: center;
color: rgba(255, 255, 255, 0.6);
font-size: 13px;
}
.libraryCard__metaDot {
width: 10px;
height: 10px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.9);
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {
.grid { .libraryGrid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.grid { .libraryGrid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.topBar { .dashboardHero {
align-items: stretch; align-items: stretch;
} }
.toolbar { .dashboardToolbar {
width: 100%; width: 100%;
} }
.toolbar__ghost, .dashboardToolbar__ghost,
.toolbar__select, .dashboardToolbar__stat,
.customTierBtn { .customTierBtn {
flex: 1 1 100%; flex: 1 1 100%;
} }
.grid { .libraryGrid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }

View File

@@ -72,25 +72,35 @@ async function removeList(t) {
<template> <template>
<section class="wrap"> <section class="wrap">
<h2 class="title"> 티어표</h2> <header class="head">
<div>
<div class="head__eyebrow">Library</div>
<h2 class="title"> 티어표</h2>
<div class="desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 있어요.</div>
</div>
<div class="head__stat">
<span class="head__statLabel">Saved Lists</span>
<strong class="head__statValue">{{ myLists.length }}</strong>
</div>
</header>
<div class="card"> <div class="card">
<div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div> <div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div>
<div v-else class="list"> <div v-else class="list">
<article v-for="t in myLists" :key="t.id" class="row"> <article v-for="t in myLists" :key="t.id" class="boardCard">
<button class="row__body" @click="openList(t)"> <button class="boardCard__body" @click="openList(t)">
<div class="row__thumbWrap"> <div class="boardCard__thumbWrap">
<img v-if="tierListThumbnailUrl(t)" class="row__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" /> <img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
<div v-else class="row__thumbPlaceholder"></div> <div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div> </div>
<div class="row__head"> <div class="boardCard__head">
<div class="row__title">{{ t.title }}</div> <div class="boardCard__title">{{ t.title }}</div>
<div class="row__author"> <div class="boardCard__author">
<img v-if="avatarSrcOf(t)" class="row__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" /> <img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<div v-else class="row__avatar row__avatar--fallback">{{ avatarFallbackOf(t) }}</div> <div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<span>by {{ displayNameOf(t) }}</span> <span>by {{ displayNameOf(t) }}</span>
</div> </div>
</div> </div>
<div class="row__meta">{{ fmt(t.updatedAt) }}</div> <div class="boardCard__meta">{{ fmt(t.updatedAt) }}</div>
</button> </button>
<button class="link link--danger" @click="removeList(t)">삭제</button> <button class="link link--danger" @click="removeList(t)">삭제</button>
</article> </article>
@@ -103,12 +113,48 @@ async function removeList(t) {
.wrap { .wrap {
padding: 4px 2px; padding: 4px 2px;
} }
.head {
display: flex;
gap: 16px;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
margin-bottom: 18px;
}
.head__eyebrow {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.42);
}
.title { .title {
margin: 0 0 18px; margin: 4px 0 6px;
font-size: 30px; font-size: 32px;
letter-spacing: -0.04em; letter-spacing: -0.04em;
color: rgba(255, 255, 255, 0.96); color: rgba(255, 255, 255, 0.96);
} }
.desc {
color: rgba(255, 255, 255, 0.58);
}
.head__stat {
display: grid;
gap: 2px;
min-width: 112px;
padding: 10px 14px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
}
.head__statLabel {
font-size: 11px;
color: rgba(255, 255, 255, 0.48);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.head__statValue {
font-size: 18px;
font-weight: 900;
}
.card { .card {
border: 0; border: 0;
background: transparent; background: transparent;
@@ -132,16 +178,17 @@ async function removeList(t) {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px; gap: 18px;
} }
.row { .boardCard {
display: grid; display: grid;
gap: 10px; gap: 10px;
border-radius: 14px; border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.16); border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82); background: rgba(62, 62, 62, 0.82);
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.92);
overflow: hidden; overflow: hidden;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
} }
.row__body { .boardCard__body {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
text-align: left; text-align: left;
@@ -153,27 +200,34 @@ async function removeList(t) {
display: grid; display: grid;
gap: 10px; gap: 10px;
} }
.row__thumbWrap { .boardCard__thumbWrap {
width: 100%; width: 100%;
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
background: #555; background: #555;
display: grid;
place-items: center;
} }
.row__thumb { .boardCard__thumb {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
display: block; display: block;
} }
.row__thumbPlaceholder { .boardCard__thumbPlaceholder {
width: 100%; width: 100%;
height: 100%; height: 100%;
background: #555; background: #555;
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.4);
font-size: 13px;
font-weight: 700;
} }
.row__title { .boardCard__title {
font-weight: 900; font-weight: 900;
min-width: 0; min-width: 0;
} }
.row__head { .boardCard__head {
padding: 0 14px; padding: 0 14px;
display: flex; display: flex;
gap: 12px; gap: 12px;
@@ -181,14 +235,14 @@ async function removeList(t) {
justify-content: space-between; justify-content: space-between;
flex-wrap: wrap; flex-wrap: wrap;
} }
.row__author { .boardCard__author {
display: inline-flex; display: inline-flex;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
font-size: 13px; font-size: 13px;
opacity: 0.84; opacity: 0.84;
} }
.row__avatar { .boardCard__avatar {
width: 28px; width: 28px;
height: 28px; height: 28px;
border-radius: 999px; border-radius: 999px;
@@ -196,13 +250,13 @@ async function removeList(t) {
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
} }
.row__avatar--fallback { .boardCard__avatar--fallback {
display: grid; display: grid;
place-items: center; place-items: center;
font-size: 12px; font-size: 12px;
font-weight: 900; font-weight: 900;
} }
.row__meta { .boardCard__meta {
padding: 0 14px; padding: 0 14px;
margin-top: 6px; margin-top: 6px;
opacity: 0.76; opacity: 0.76;
@@ -224,6 +278,9 @@ async function removeList(t) {
} }
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.head__stat {
width: 100%;
}
.list { .list {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }