Compare commits

..

2 Commits

11 changed files with 285 additions and 303 deletions

View File

@@ -1,5 +1,15 @@
# 의사결정 이력
## 2026-03-30 v1.2.21
- 티어표 목록 카드는 페이지마다 다른 메타 구성을 두기보다, `제목+좋아요 / 작성자+최종 수정일` 두 줄 문법으로 통일하는 편이 시안과 사용성 모두에 더 맞다고 정리했다.
- `내 즐겨찾기` 화면에서 “즐겨찾기한 날짜”는 컬렉션 내부 정보일 뿐 카드 핵심 정보는 아니므로, 정렬은 유지하되 카드에는 마지막 수정일만 보여주는 편이 더 읽기 쉽다고 판단했다.
- 좌측 `Favorites`는 메인 내비보다 보조 영역이어야 하므로, 같은 공간 안에서도 더 작은 썸네일·더 작은 텍스트·더 약한 대비로 눌러두는 편이 맞다고 정리했다.
## 2026-03-30 v1.2.20
- 전역 검색 입력이 이미 좌측 레일에 고정되어 있으므로, 검색 결과 화면 안에 검색 폼을 또 두는 것은 중복이라고 판단해 `/search` 화면은 결과 표시 자체에만 집중시키기로 했다.
- 중앙 워크스페이스는 셸 여백 위에 다시 큰 카드 테두리와 배경을 씌우면 시안보다 한 겹 더 두꺼워 보이므로, `workspaceBody`는 외곽 카드 없이 단일 여백 레이어만 유지하는 편이 더 맞다고 정리했다.
- 우측 패널 토글은 같은 위치에 서로 다른 상태의 버튼이 번갈아 보여야 인지가 쉬우므로, 패널이 닫혀 있을 때는 중앙 헤더에 열기 버튼을 두고 열려 있을 때는 우측 헤더에 닫기 버튼을 두는 방식으로 정리했다.
## 2026-03-30 v1.2.19
- 사용자 카드에서 프로필/로그아웃 팝업을 또 띄우는 구조는 좌측 `Settings` 메뉴와 역할이 겹치므로, 설정 진입은 메뉴 하나로만 통일하고 로그아웃은 설정 화면 안쪽에서 마무리하는 편이 더 명확하다고 정리했다.
- 좌측 `Favorites`는 단순 링크보다 “내가 최근 좋아요한 실제 티어표 바로가기”를 보여주는 쪽이 시안과 사용성 모두에 더 가깝다고 판단해, 최근 10개만 노출하고 나머지는 `즐겨찾기 더 보기`로 보내기로 했다.

View File

@@ -7,7 +7,7 @@
## `/games/:gameId`
- 화면 파일: `frontend/src/views/GameHubView.vue`
- 역할: 선택한 게임 정보 표시, 상단 통계/생성 CTA, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 상단 썸네일/작성자 표시, 즐겨찾기 상태 표시, 새 티어표 작성 진입
- 역할: 선택한 게임 정보 표시, 상단 생성 CTA, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성 진입
- 연동 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`
@@ -22,7 +22,7 @@
## `/me`
- 화면 파일: `frontend/src/views/MyTierListsView.vue`
- 역할: 내 티어표 목록 조회, 라이브러리 카드형 썸네일 표시, 편집 화면으로 이동, 작성자 본인 티어표 삭제
- 역할: 내 티어표 목록 조회, 4열 라이브러리 카드형 썸네일 표시, 편집 화면으로 이동, 작성자 본인 티어표 삭제
- 연동 API: `GET /api/tierlists/me`, `DELETE /api/tierlists/:id`
## `/favorites`
@@ -32,7 +32,7 @@
## `/search`
- 화면 파일: `frontend/src/views/SearchResultsView.vue`
- 역할: 좌측 전역 검색 입력에서 넘긴 키워드로 공개 티어표 전체를 검색하고, 제목/작성자/게임 ID 기준 메타를 카드 목록으로 표시
- 역할: 좌측 전역 검색 입력에서 넘긴 키워드로 공개 티어표 전체를 검색하고, 자체 검색 툴바 없이 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 목록으로 표시
- 연동 API: `GET /api/tierlists/public?q=...`
## `/admin`
@@ -47,8 +47,8 @@
## 공통 레이아웃
- 앱 셸 파일: `frontend/src/App.vue`
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 일부 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
- 세부: 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker by zenn`이 표시되고, 상단 토글 버튼으로 우측 패널을 접고 펼칠 수 있다.
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
- 세부: 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker by zenn`이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다.
## 백엔드 진입점
- 서버 엔트리: `backend/index.js`

View File

@@ -28,15 +28,16 @@
- 좌측 패널
- 사용자 요약, 전체 공개 티어표 검색 입력, 주요 라우트 내비게이션, 최근 즐겨찾기 티어표 바로가기, 관리자 진입 버튼을 배치한다.
- `Settings`는 별도 메뉴 항목으로만 진입하며, 사용자 카드 자체는 정보 표시 용도로만 사용한다.
- `Favorites` 영역은 최근 즐겨찾기 티어표 최대 10개를 바로 보여주고, 하단 `즐겨찾기 더 보기` 링크로 전체 즐겨찾기 화면에 연결한다.
- 사용자 아바타는 원형 보더 스타일을 유지하고, `Favorites` 영역은 최근 즐겨찾기 티어표 최대 10개를 메인 메뉴보다 작은 밀도의 바로가기 목록으로 보여준 뒤 하단 `즐겨찾기 더 보기` 링크로 전체 즐겨찾기 화면에 연결한다.
- 중앙 워크스페이스
- 현재 라우트의 핵심 콘텐츠를 렌더링하는 영역이며, 홈/목록 계열 화면은 카드형 대시보드 레이아웃을 우선 적용한다.
- 홈, 게임 허브, 내 티어표, 즐겨찾기 화면은 같은 카드 문법(상단 16:9 썸네일, 제목, 작성자/보조 메타, 하단 상태 영역)을 공유하도록 정리한다.
- 공통 `workspaceBody`는 별도 외곽 카드 테두리 없이 셸 여백만 제공하고, 실제 카드/패널 레이어는 각 화면 내부에서만 구성한다.
- 홈, 게임 허브, 내 티어표, 즐겨찾기, 검색 결과 화면은 같은 카드 문법(상단 16:9 썸네일, `제목+좋아요` 1행, `작성자+최종 수정일` 1행)을 공유하며, 데스크톱 기준 기본 4열 카드 그리드를 사용한다.
- 목록 계열 화면의 상단 도구 영역은 통계 카드와 액션 버튼을 공통 높이/반경으로 맞춰, 같은 라이브러리 대시보드로 읽히도록 정리한다.
- 우측 패널
- 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다.
- 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다.
- 공통 토글 버튼으로 패널을 접으면 중앙 워크스페이스가 남는 공간을 확장 사용한다.
- 공통 토글 버튼 패널이 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 각각 아이콘만 표시하는 방식으로 동작한다.
- 홈 화면 기준 우측 패널은 임시 정보 카드 여러 개보다 핵심 CTA 하나만 남겨, 시안처럼 단순한 보조 레일 역할을 우선 유지한다.
- 티어표 편집 화면
- 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다.

View File

@@ -14,6 +14,8 @@
- 에디터 우측 패널 외곽 래퍼는 제거했으므로, 다음 단계는 공통 오른쪽 컬럼 안에서 입력/버튼/구분선 간격을 시안처럼 더 정교하게 다듬는 작업이다.
- 공통 56px 셸 헤더는 반영했으므로, 다음 단계는 좌/중앙/우 헤더 안에 실제 아이콘/상태 요소를 시안 순서에 맞게 하나씩 채워 넣는 작업이다.
- 좌측 레일은 최근 즐겨찾기와 전역 검색까지 붙었으므로, 다음 단계는 검색 자동완성이나 즐겨찾기 썸네일 품질 같은 디테일을 더 다듬는 작업이다.
- 카드 목록은 4열 기준과 메타 줄 구성까지 통일했으므로, 다음 단계는 필터 상태 배지나 hover·selection 강조 같은 상호작용 디테일을 더 다듬는 작업이다.
- 검색 결과 화면은 좌측 전역 검색 입력만 쓰도록 정리됐으므로, 다음 단계는 결과 필터/정렬 여부를 검토하는 식으로 확장하면 된다.
- 공통 3단 셸 구조는 고정했지만, 관리자/에디터 우측 패널 내부에 아직 바디에 남아 있는 제어 요소를 더 옮겨야 한다.
- 홈 화면 우측 사이드는 CTA 하나만 남긴 상태이므로, 이후 필요할 때도 임시 정보 카드 다수를 다시 넣기보다 실제 필요한 기능만 선별해 추가해야 한다.
- 관리자 화면은 헤더 요약 통계와 카드 계층까지 정리됐지만, 아직 표준 SVG 아이콘 교체와 더 세밀한 상태 색상/선택 상태 표현은 남아 있다.

View File

@@ -1,5 +1,17 @@
# 업데이트 로그
## 2026-03-30 v1.2.21
- **티어표 카드 문법 통일**: 게임 허브, 검색 결과, 내 티어표, 즐겨찾기 목록의 카드 레이아웃을 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 2줄 메타 구조로 통일하고, 데스크톱 기준 한 줄 4개 카드가 보이도록 재배치
- **즐겨찾기 화면 날짜 기준 단순화**: `내 즐겨찾기` 화면은 더 이상 즐겨찾기한 시각을 표시하지 않고, 정렬 기준과 무관하게 덱의 마지막 수정일만 카드에 노출하도록 정리
- **좌측 사용자 카드/즐겨찾기 밀도 보정**: 좌측 사용자 아바타를 원형 보더 스타일로 통일하고, `Favorites` 바로가기 섹션은 메인 메뉴보다 덜 강조되도록 썸네일·텍스트·간격을 한 단계 축소
## 2026-03-30 v1.2.20
- **검색 결과 상단 툴바 제거**: `/search` 화면의 중복 검색 폼을 제거하고, 좌측 전역 검색 입력만 검색 진입점으로 사용하도록 단순화
- **왼쪽 즐겨찾기 더보기 아이콘 교체**: 사용자가 추가한 `more.svg`를 좌측 `즐겨찾기 더 보기` 링크 아이콘에 연결
- **중앙 본문 외곽 레이어 제거**: `workspaceBody`의 추가 패딩, 테두리, 둥근 카드 배경을 제거해 중앙 콘텐츠가 한 겹만 안쪽으로 들어온 것처럼 보이도록 셸 여백을 단순화
- **게임 허브 상단 통계 제거**: 게임별 티어표 목록 화면의 `dashboardStat` 카드를 제거해 상단 헤더를 CTA 중심으로 정리
- **우측 패널 토글 동작 정리**: 중앙 헤더에는 패널이 닫혀 있을 때만 열기 아이콘 버튼을, 우측 헤더에는 패널이 열려 있을 때만 닫기 아이콘 버튼을 표시하도록 토글 흐름을 재구성
## 2026-03-30 v1.2.19
- **왼쪽 레일 설정 흐름 단순화**: 사용자 카드 클릭 팝업을 제거하고, 설정은 좌측 `Settings` 메뉴에서만 진입하도록 정리했으며 프로필 화면 하단에 로그아웃 버튼을 추가
- **좌측 즐겨찾기 바로가기 추가**: 좌측 `Favorites` 영역에 최근 즐겨찾기 티어표 최대 10개를 바로가기 형태로 표시하고, 하단 `즐겨찾기 더 보기` 링크로 전체 즐겨찾기 화면에 연결

View File

@@ -9,6 +9,7 @@ import iconDockToLeft from './assets/icons/dock_to_left.svg'
import iconDockToRight from './assets/icons/dock_to_right.svg'
import iconGridView from './assets/icons/grid_view.svg'
import iconLists from './assets/icons/lists.svg'
import iconMore from './assets/icons/more.svg'
import iconSettings from './assets/icons/settings.svg'
const route = useRoute()
@@ -284,7 +285,7 @@ watch(
</button>
<RouterLink to="/favorites" class="favoriteMoreLink">
<span class="favoriteMoreLink__icon">
<img :src="iconSettings" alt="" />
<img :src="iconMore" alt="" />
</span>
<span>즐겨찾기 보기</span>
<span class="favoriteMoreLink__arrow"></span>
@@ -308,9 +309,8 @@ watch(
<span class="workspaceHead__brandSub">by zenn</span>
</div>
<div class="workspaceHead__actions">
<button class="ghostIcon ghostIcon--workspace" type="button" :aria-pressed="rightRailOpen" @click="toggleRightRail">
<img :src="rightRailOpen ? iconDockToRight : iconDockToLeft" alt="" />
<span>{{ rightRailOpen ? '패널 숨기기' : '패널 보기' }}</span>
<button v-if="!rightRailOpen" class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="패널 열기" @click="toggleRightRail">
<img :src="iconDockToLeft" alt="" />
</button>
</div>
</header>
@@ -321,7 +321,11 @@ watch(
</main>
<aside class="rightRail" :class="{ 'rightRail--closed': !rightRailOpen }" :aria-hidden="!rightRailOpen">
<div class="rightRail__top railHeader"></div>
<div class="rightRail__top railHeader">
<button v-if="rightRailOpen" class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="패널 닫기" @click="toggleRightRail">
<img :src="iconDockToRight" alt="" />
</button>
</div>
<div class="rightRail__body">
<template v-if="!usesLocalRightRail">
<section class="rightRailAction">
@@ -413,6 +417,10 @@ watch(
gap: 12px;
}
.rightRail__top {
justify-content: flex-end;
}
.leftRail__body,
.rightRail__body {
flex: 1;
@@ -450,8 +458,7 @@ watch(
}
.ghostIcon img,
.leftNav__glyph img,
.ghostIcon--workspace img {
.leftNav__glyph img {
width: 16px;
height: 16px;
display: block;
@@ -464,17 +471,6 @@ watch(
padding: 0;
}
.ghostIcon--workspace {
min-width: 132px;
height: 38px;
padding: 0 14px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.88);
font-size: 12px;
font-weight: 800;
}
.appUserCard {
position: relative;
margin-bottom: 14px;
@@ -500,9 +496,11 @@ watch(
.appUserCard__avatar {
width: 42px;
height: 42px;
border-radius: 12px;
border-radius: 999px;
object-fit: cover;
flex: 0 0 auto;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.08);
}
.appUserCard__avatar--fallback {
@@ -600,18 +598,18 @@ watch(
}
.leftRail__section {
margin-top: 24px;
margin-top: 22px;
display: grid;
gap: 10px;
gap: 8px;
}
.leftRail__sectionTitle {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: rgba(255, 255, 255, 0.62);
font-weight: 700;
font-size: 11px;
color: rgba(255, 255, 255, 0.38);
font-weight: 600;
}
.favoriteEmpty {
@@ -622,21 +620,21 @@ watch(
.favoriteShortcut {
display: grid;
grid-template-columns: 36px minmax(0, 1fr);
gap: 12px;
grid-template-columns: 24px minmax(0, 1fr);
gap: 8px;
align-items: center;
padding: 6px 0;
padding: 3px 0;
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.9);
color: rgba(255, 255, 255, 0.72);
text-align: left;
cursor: pointer;
}
.favoriteShortcut__thumb {
width: 36px;
height: 36px;
border-radius: 8px;
width: 24px;
height: 24px;
border-radius: 6px;
object-fit: cover;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.08);
@@ -654,23 +652,23 @@ watch(
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-size: 12px;
}
.favoriteMoreLink {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
margin-top: 4px;
color: rgba(255, 255, 255, 0.76);
color: rgba(255, 255, 255, 0.62);
text-decoration: none;
font-size: 14px;
font-size: 12px;
}
.favoriteMoreLink__icon {
width: 28px;
height: 28px;
border-radius: 10px;
width: 22px;
height: 22px;
border-radius: 8px;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.06);
@@ -678,8 +676,8 @@ watch(
}
.favoriteMoreLink__icon img {
width: 16px;
height: 16px;
width: 12px;
height: 12px;
display: block;
filter: brightness(0) saturate(100%) invert(94%) sepia(6%) saturate(207%) hue-rotate(186deg) brightness(96%) contrast(92%);
}
@@ -763,12 +761,12 @@ watch(
.workspaceBody {
min-height: calc(100vh - 56px);
padding: 18px;
border-radius: 26px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: linear-gradient(180deg, #2d2d2d 0%, #2a2a2a 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
margin: 18px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
margin: 18px 18px 0;
}
.workspaceBody--localRail {
@@ -778,7 +776,7 @@ watch(
border-radius: 0;
background: transparent;
box-shadow: none;
margin: 18px;
margin: 18px 18px 0;
}
.rightRail {
@@ -895,15 +893,15 @@ watch(
}
.workspaceBody {
padding: 14px;
border-radius: 20px;
margin: 14px;
padding: 0;
border-radius: 0;
margin: 14px 14px 0;
}
.workspaceBody--localRail {
padding: 0;
border-radius: 0;
margin: 14px;
margin: 14px 14px 0;
}
}
</style>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M360-160q-19 0-36-8.5T296-192L80-480l216-288q11-15 28-23.5t36-8.5h440q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H360ZM180-480l180 240h440v-480H360L180-480Zm248.5 28.5Q440-463 440-480t-11.5-28.5Q417-520 400-520t-28.5 11.5Q360-497 360-480t11.5 28.5Q383-440 400-440t28.5-11.5Zm140 0Q580-463 580-480t-11.5-28.5Q557-520 540-520t-28.5 11.5Q500-497 500-480t11.5 28.5Q523-440 540-440t28.5-11.5Zm140 0Q720-463 720-480t-11.5-28.5Q697-520 680-520t-28.5 11.5Q640-497 640-480t11.5 28.5Q663-440 680-440t28.5-11.5ZM580-480Z"/></svg>

After

Width:  |  Height:  |  Size: 639 B

View File

@@ -11,17 +11,12 @@ const toast = useToast()
const favorites = ref([])
const query = ref('')
const sort = ref('favorited')
const sortLabel = computed(() =>
sort.value === 'favorited' ? '즐겨찾기한 날짜' : sort.value === 'updated' ? '최종 업데이트' : '즐겨찾기 수'
)
function fmt(ts) {
return new Date(ts).toLocaleString(undefined, {
return new Date(ts).toLocaleDateString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
@@ -86,21 +81,20 @@ onMounted(loadFavorites)
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div>
<div class="boardCard__head">
<div class="boardCard__title">{{ tierList.title }}</div>
<div class="boardCard__author">
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" />
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
<span>by {{ displayNameOf(tierList) }}</span>
<div class="boardCard__titleRow">
<div class="boardCard__title">{{ tierList.title }}</div>
<div class="favoriteStat"> {{ tierList.favoriteCount || 0 }}</div>
</div>
<div class="boardCard__metaRow">
<div class="boardCard__author">
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" />
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
<span class="boardCard__authorName">{{ displayNameOf(tierList) }}</span>
</div>
<div class="boardCard__date">{{ fmt(tierList.updatedAt) }}</div>
</div>
</div>
</button>
<div class="boardCard__foot">
<div class="boardCard__meta">
<div>{{ tierList.gameName || tierList.gameId }}</div>
<div>{{ sortLabel }}: {{ fmt(sort === 'favorited' ? tierList.favoritedAt : tierList.updatedAt) }}</div>
</div>
<div class="favoriteStat"> {{ tierList.favoriteCount || 0 }}</div>
</div>
</article>
</div>
</section>
@@ -162,7 +156,7 @@ onMounted(loadFavorites)
}
.list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.boardCard {
@@ -171,7 +165,6 @@ onMounted(loadFavorites)
background: rgba(62, 62, 62, 0.82);
overflow: hidden;
display: grid;
gap: 10px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
transition:
transform 0.16s ease,
@@ -189,20 +182,19 @@ onMounted(loadFavorites)
text-align: left;
cursor: pointer;
display: grid;
gap: 10px;
}
.boardCard__thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
background: #555;
display: grid;
place-items: center;
padding: 14px 14px 0;
box-sizing: border-box;
}
.boardCard__thumb,
.boardCard__thumbPlaceholder {
width: 100%;
height: 100%;
display: block;
border-radius: 18px;
}
.boardCard__thumb {
object-fit: cover;
@@ -216,63 +208,68 @@ onMounted(loadFavorites)
font-weight: 700;
}
.boardCard__head {
padding: 14px 14px 0;
padding: 16px 18px 18px;
display: grid;
gap: 10px;
}
.boardCard__titleRow,
.boardCard__metaRow {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.boardCard__title {
min-width: 0;
font-weight: 800;
font-size: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.boardCard__author {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
opacity: 0.86;
}
.boardCard__authorName {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.boardCard__avatar {
width: 28px;
height: 28px;
border-radius: 999px;
width: 22px;
height: 22px;
border-radius: 6px;
object-fit: cover;
background: rgba(255, 255, 255, 0.08);
flex: 0 0 auto;
}
.boardCard__avatar--fallback {
display: grid;
place-items: center;
font-size: 12px;
font-size: 11px;
font-weight: 900;
}
.boardCard__foot {
padding: 0 14px 14px;
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.boardCard__meta {
display: grid;
gap: 4px;
opacity: 0.78;
font-size: 13px;
}
.boardCard__date,
.favoriteStat {
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.92);
border-radius: 999px;
padding: 7px 10px;
font-weight: 800;
flex: 0 0 auto;
font-size: 13px;
color: rgba(255, 255, 255, 0.64);
white-space: nowrap;
}
@media (max-width: 1100px) {
@media (max-width: 1400px) {
.list {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 960px) {
@media (max-width: 1024px) {
.list {
grid-template-columns: 1fr;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {

View File

@@ -17,12 +17,10 @@ const error = ref('')
const query = ref('')
function fmt(ts) {
return new Date(ts).toLocaleString(undefined, {
return new Date(ts).toLocaleDateString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
@@ -84,10 +82,6 @@ function submitSearch() {
<p class="dashboardHero__desc"> 게임의 공개 티어표를 탐색하고, 바로 보드를 만들어 같은 흐름으로 이어갈 있어요.</p>
</div>
<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>
</div>
</section>
@@ -113,20 +107,22 @@ function submitSearch() {
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div>
<div class="boardCard__head">
<div class="boardCard__title">{{ t.title }}</div>
<div class="boardCard__author">
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<span>by {{ displayNameOf(t) }}</span>
<div class="boardCard__titleRow">
<div class="boardCard__title">{{ t.title }}</div>
<div class="favoriteStat" :title="t.isFavorited ? '이미 즐겨찾기한 티어표' : '즐겨찾기 수'">
{{ t.isFavorited ? '♥' : '♡' }} {{ t.favoriteCount || 0 }}
</div>
</div>
<div class="boardCard__metaRow">
<div class="boardCard__author">
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
</div>
<div class="boardCard__date">{{ fmt(t.updatedAt) }}</div>
</div>
</div>
</button>
<div class="boardCard__foot">
<div class="boardCard__meta">{{ fmt(t.updatedAt) }}</div>
<div class="favoriteStat" :title="t.isFavorited ? '이미 즐겨찾기한 티어표' : '즐겨찾기 수'">
{{ t.isFavorited ? '★' : '☆' }} {{ t.favoriteCount || 0 }}
</div>
</div>
</article>
</div>
</section>
@@ -168,25 +164,6 @@ function submitSearch() {
color: rgba(255, 255, 255, 0.58);
max-width: 720px;
}
.dashboardStat {
display: grid;
gap: 2px;
min-width: 112px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.045);
}
.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 {
padding: 12px 16px;
border-radius: 14px;
@@ -261,7 +238,7 @@ function submitSearch() {
}
.list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.boardCard {
@@ -270,9 +247,6 @@ function submitSearch() {
background: rgba(62, 62, 62, 0.82);
color: rgba(255, 255, 255, 0.92);
display: grid;
gap: 10px;
align-content: start;
min-height: 168px;
overflow: hidden;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
transition:
@@ -292,20 +266,19 @@ function submitSearch() {
cursor: pointer;
width: 100%;
display: grid;
gap: 10px;
}
.boardCard__thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
background: #555;
display: grid;
place-items: center;
padding: 14px 14px 0;
box-sizing: border-box;
}
.boardCard__thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
border-radius: 18px;
}
.boardCard__thumbPlaceholder {
width: 100%;
@@ -316,76 +289,79 @@ function submitSearch() {
color: rgba(255, 255, 255, 0.4);
font-size: 13px;
font-weight: 700;
border-radius: 18px;
}
.boardCard__title {
font-weight: 800;
min-width: 0;
font-size: 18px;
line-height: 1.35;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.boardCard__head {
padding: 14px 14px 0;
padding: 16px 18px 18px;
display: grid;
gap: 10px;
}
.boardCard__titleRow,
.boardCard__metaRow {
display: flex;
gap: 12px;
align-content: start;
align-items: center;
justify-content: space-between;
}
.boardCard__author {
min-width: 0;
display: inline-flex;
gap: 8px;
align-items: center;
font-size: 13px;
opacity: 0.86;
flex: 0 0 auto;
}
.boardCard__authorName {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.boardCard__avatar {
width: 28px;
height: 28px;
border-radius: 999px;
width: 22px;
height: 22px;
border-radius: 6px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
flex: 0 0 auto;
}
.boardCard__avatar--fallback {
display: grid;
place-items: center;
font-size: 12px;
font-size: 11px;
font-weight: 900;
}
.boardCard__meta {
opacity: 0.78;
font-size: 13px;
}
.boardCard__foot {
padding: 0 14px 14px;
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-top: auto;
}
.boardCard__date,
.favoriteStat {
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.92);
border-radius: 999px;
padding: 7px 10px;
font-weight: 800;
flex: 0 0 auto;
font-size: 13px;
color: rgba(255, 255, 255, 0.64);
white-space: nowrap;
}
@media (max-width: 1280px) {
@media (max-width: 1400px) {
.list {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1100px) {
@media (max-width: 1024px) {
.list {
grid-template-columns: 1fr;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.dashboardHero__right {
width: 100%;
}
.dashboardStat,
.primary {
width: 100%;
}

View File

@@ -17,12 +17,10 @@ watch(error, (message) => {
})
function fmt(ts) {
return new Date(ts).toLocaleString(undefined, {
return new Date(ts).toLocaleDateString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
@@ -93,14 +91,19 @@ async function removeList(t) {
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div>
<div class="boardCard__head">
<div class="boardCard__title">{{ t.title }}</div>
<div class="boardCard__author">
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<span>by {{ displayNameOf(t) }}</span>
<div class="boardCard__titleRow">
<div class="boardCard__title">{{ t.title }}</div>
<div class="favoriteStat"> {{ t.favoriteCount || 0 }}</div>
</div>
<div class="boardCard__metaRow">
<div class="boardCard__author">
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
</div>
<div class="boardCard__date">{{ fmt(t.updatedAt) }}</div>
</div>
</div>
<div class="boardCard__meta">{{ fmt(t.updatedAt) }}</div>
</button>
<button class="link link--danger" @click="removeList(t)">삭제</button>
</article>
@@ -176,12 +179,11 @@ async function removeList(t) {
}
.list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.boardCard {
display: grid;
gap: 10px;
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82);
@@ -206,20 +208,19 @@ async function removeList(t) {
color: inherit;
padding: 0;
display: grid;
gap: 10px;
}
.boardCard__thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
background: #555;
display: grid;
place-items: center;
padding: 14px 14px 0;
box-sizing: border-box;
}
.boardCard__thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
border-radius: 18px;
}
.boardCard__thumbPlaceholder {
width: 100%;
@@ -230,59 +231,77 @@ async function removeList(t) {
color: rgba(255, 255, 255, 0.4);
font-size: 13px;
font-weight: 700;
border-radius: 18px;
}
.boardCard__title {
font-weight: 900;
min-width: 0;
font-size: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.boardCard__head {
padding: 0 14px;
padding: 16px 18px 18px;
display: grid;
gap: 10px;
}
.boardCard__titleRow,
.boardCard__metaRow {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.boardCard__author {
min-width: 0;
display: inline-flex;
gap: 8px;
align-items: center;
font-size: 13px;
opacity: 0.84;
}
.boardCard__authorName {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.boardCard__avatar {
width: 28px;
height: 28px;
border-radius: 999px;
width: 22px;
height: 22px;
border-radius: 6px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
flex: 0 0 auto;
}
.boardCard__avatar--fallback {
display: grid;
place-items: center;
font-size: 12px;
font-size: 11px;
font-weight: 900;
}
.boardCard__meta {
padding: 0 14px;
margin-top: 6px;
opacity: 0.76;
.boardCard__date,
.favoriteStat {
flex: 0 0 auto;
font-size: 13px;
color: rgba(255, 255, 255, 0.64);
white-space: nowrap;
}
.link--danger {
background: rgba(239, 68, 68, 0.14);
border-color: rgba(239, 68, 68, 0.28);
margin: 0 14px 14px;
margin: 0 18px 18px;
}
@media (max-width: 1100px) {
@media (max-width: 1400px) {
.list {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 960px) {
@media (max-width: 1024px) {
.list {
grid-template-columns: 1fr;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
@@ -12,15 +12,11 @@ const loading = ref(false)
const error = ref('')
const query = ref('')
const normalizedQuery = computed(() => (query.value || '').trim())
function fmt(ts) {
return new Date(ts).toLocaleString(undefined, {
return new Date(ts).toLocaleDateString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
@@ -57,10 +53,6 @@ async function loadResults() {
}
}
function submitSearch() {
router.push(normalizedQuery.value ? `/search?q=${encodeURIComponent(normalizedQuery.value)}` : '/search')
}
watch(
() => route.query.q,
async (nextQuery) => {
@@ -69,10 +61,6 @@ watch(
},
{ immediate: true }
)
onMounted(() => {
query.value = typeof route.query.q === 'string' ? route.query.q : ''
})
</script>
<template>
@@ -83,10 +71,6 @@ onMounted(() => {
<h2 class="title">전체 티어표 검색</h2>
<div class="desc">공개된 모든 티어표를 제목과 작성자 기준으로 찾아볼 있어요.</div>
</div>
<!-- <form class="toolbar" @submit.prevent="submitSearch">
<input v-model="query" class="input" placeholder="제목 또는 작성자 검색" />
<button class="btn" type="submit">검색</button>
</form> -->
</div>
<div v-if="error" class="error">{{ error }}</div>
@@ -100,23 +84,22 @@ onMounted(() => {
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div>
<div class="boardCard__head">
<div class="boardCard__title">{{ tierList.title }}</div>
<div class="boardCard__author">
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" />
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
<span>by {{ displayNameOf(tierList) }}</span>
<div class="boardCard__titleRow">
<div class="boardCard__title">{{ tierList.title }}</div>
<div class="favoriteStat" :title="tierList.isFavorited ? '이미 즐겨찾기한 티어표' : '즐겨찾기 수'">
{{ tierList.isFavorited ? '♥' : '♡' }} {{ tierList.favoriteCount || 0 }}
</div>
</div>
<div class="boardCard__metaRow">
<div class="boardCard__author">
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" />
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
<span class="boardCard__authorName">{{ displayNameOf(tierList) }}</span>
</div>
<div class="boardCard__date">{{ fmt(tierList.updatedAt) }}</div>
</div>
</div>
</button>
<div class="boardCard__foot">
<div class="boardCard__meta">
<div>{{ tierList.gameId }}</div>
<div>{{ fmt(tierList.updatedAt) }}</div>
</div>
<div class="favoriteStat" :title="tierList.isFavorited ? '이미 즐겨찾기한 티어표' : '즐겨찾기 수'">
{{ tierList.isFavorited ? '★' : '☆' }} {{ tierList.favoriteCount || 0 }}
</div>
</div>
</article>
</div>
</section>
@@ -151,28 +134,6 @@ onMounted(() => {
margin-top: 6px;
color: rgba(255, 255, 255, 0.58);
}
.toolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.input {
min-width: 280px;
padding: 11px 13px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.92);
}
.btn {
padding: 11px 13px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.92);
font-weight: 800;
cursor: pointer;
}
.error {
margin: 0 0 8px;
padding: 10px 12px;
@@ -185,7 +146,7 @@ onMounted(() => {
}
.list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.boardCard {
@@ -194,7 +155,6 @@ onMounted(() => {
background: rgba(62, 62, 62, 0.82);
overflow: hidden;
display: grid;
gap: 10px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
transition: transform 0.16s ease, background 0.16s ease;
}
@@ -210,20 +170,19 @@ onMounted(() => {
text-align: left;
cursor: pointer;
display: grid;
gap: 10px;
}
.boardCard__thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
background: #555;
display: grid;
place-items: center;
padding: 14px 14px 0;
box-sizing: border-box;
}
.boardCard__thumb,
.boardCard__thumbPlaceholder {
width: 100%;
height: 100%;
display: block;
border-radius: 18px;
}
.boardCard__thumb {
object-fit: cover;
@@ -237,64 +196,71 @@ onMounted(() => {
font-weight: 700;
}
.boardCard__head {
padding: 14px 14px 0;
padding: 16px 18px 18px;
display: grid;
gap: 10px;
}
.boardCard__titleRow,
.boardCard__metaRow {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.boardCard__title {
min-width: 0;
font-weight: 800;
font-size: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.boardCard__author {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
opacity: 0.86;
}
.boardCard__authorName {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.boardCard__avatar {
width: 28px;
height: 28px;
border-radius: 999px;
width: 22px;
height: 22px;
border-radius: 6px;
object-fit: cover;
background: rgba(255, 255, 255, 0.08);
flex: 0 0 auto;
}
.boardCard__avatar--fallback {
display: grid;
place-items: center;
font-size: 12px;
font-size: 11px;
font-weight: 900;
}
.boardCard__foot {
padding: 0 14px 14px;
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.boardCard__meta {
display: grid;
gap: 4px;
opacity: 0.78;
font-size: 13px;
}
.boardCard__date,
.favoriteStat {
flex: 0 0 auto;
font-size: 13px;
color: rgba(255, 255, 255, 0.74);
color: rgba(255, 255, 255, 0.64);
white-space: nowrap;
}
@media (max-width: 1200px) {
@media (max-width: 1400px) {
.list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1024px) {
.list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.input {
min-width: 0;
width: 100%;
}
.toolbar {
width: 100%;
}
.list {
grid-template-columns: 1fr;
}