Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0812640ec1 |
@@ -1,5 +1,10 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-03-30 v1.2.19
|
||||
- 사용자 카드에서 프로필/로그아웃 팝업을 또 띄우는 구조는 좌측 `Settings` 메뉴와 역할이 겹치므로, 설정 진입은 메뉴 하나로만 통일하고 로그아웃은 설정 화면 안쪽에서 마무리하는 편이 더 명확하다고 정리했다.
|
||||
- 좌측 `Favorites`는 단순 링크보다 “내가 최근 좋아요한 실제 티어표 바로가기”를 보여주는 쪽이 시안과 사용성 모두에 더 가깝다고 판단해, 최근 10개만 노출하고 나머지는 `즐겨찾기 더 보기`로 보내기로 했다.
|
||||
- 좌측 검색은 페이지 내부 국소 검색보다 서비스 전체 공개 티어표 검색 진입점으로 쓰는 편이 더 자연스럽다고 판단해, 별도 `/search` 결과 화면을 두는 방향으로 정리했다.
|
||||
|
||||
## 2026-03-30 v1.2.18
|
||||
- 피그마 기준 상단 구조는 페이지마다 다르게 보이면 안 되므로, 좌/중앙/우 컬럼 모두 `56px` 헤더를 고정으로 두고 내용이 없을 때도 빈 헤더 공간을 유지하는 편이 맞다고 정리했다.
|
||||
- 사이트 브랜드는 좌측 레일 안쪽 카드가 아니라 중앙 워크스페이스 상단의 고정 헤더에 두는 쪽이 시안과 더 가깝고, 페이지 이동 시에도 더 일관되게 읽힌다고 판단했다.
|
||||
|
||||
@@ -30,6 +30,11 @@
|
||||
- 역할: 즐겨찾기한 티어표 목록 조회, 검색/정렬, 라이브러리 카드형 표시, 편집 화면 이동, 즐겨찾기 상태 확인
|
||||
- 연동 API: `GET /api/tierlists/favorites/me`, `DELETE /api/tierlists/:id/favorite`
|
||||
|
||||
## `/search`
|
||||
- 화면 파일: `frontend/src/views/SearchResultsView.vue`
|
||||
- 역할: 좌측 전역 검색 입력에서 넘긴 키워드로 공개 티어표 전체를 검색하고, 제목/작성자/게임 ID 기준 메타를 카드 목록으로 표시
|
||||
- 연동 API: `GET /api/tierlists/public?q=...`
|
||||
|
||||
## `/admin`
|
||||
- 화면 파일: `frontend/src/views/AdminView.vue`
|
||||
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 게임 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/완성본 이동, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 게임 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
|
||||
@@ -37,12 +42,12 @@
|
||||
|
||||
## `/profile`
|
||||
- 화면 파일: `frontend/src/views/ProfileView.vue`
|
||||
- 역할: 프로필 표시, 작성자 닉네임 수정, 아바타 미리보기 후 저장
|
||||
- 역할: 프로필 표시, 작성자 닉네임 수정, 아바타 미리보기 후 저장, 설정 화면 하단 로그아웃 처리
|
||||
- 연동 API: `GET /api/auth/me`, `POST /api/auth/profile`
|
||||
|
||||
## 공통 레이아웃
|
||||
- 앱 셸 파일: `frontend/src/App.vue`
|
||||
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 사용자 메뉴, 관리자 메뉴 노출 제어, 일부 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
|
||||
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 일부 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
|
||||
- 세부: 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker by zenn`이 표시되고, 상단 토글 버튼으로 우측 패널을 접고 펼칠 수 있다.
|
||||
|
||||
## 백엔드 진입점
|
||||
|
||||
@@ -26,7 +26,9 @@
|
||||
|
||||
## 화면 구조
|
||||
- 좌측 패널
|
||||
- 사용자 요약, 빠른 검색 버튼, 주요 라우트 내비게이션, 즐겨찾기 성격의 빠른 링크, 관리자 진입 버튼을 배치한다.
|
||||
- 사용자 요약, 전체 공개 티어표 검색 입력, 주요 라우트 내비게이션, 최근 즐겨찾기 티어표 바로가기, 관리자 진입 버튼을 배치한다.
|
||||
- `Settings`는 별도 메뉴 항목으로만 진입하며, 사용자 카드 자체는 정보 표시 용도로만 사용한다.
|
||||
- `Favorites` 영역은 최근 즐겨찾기 티어표 최대 10개를 바로 보여주고, 하단 `즐겨찾기 더 보기` 링크로 전체 즐겨찾기 화면에 연결한다.
|
||||
- 중앙 워크스페이스
|
||||
- 현재 라우트의 핵심 콘텐츠를 렌더링하는 영역이며, 홈/목록 계열 화면은 카드형 대시보드 레이아웃을 우선 적용한다.
|
||||
- 홈, 게임 허브, 내 티어표, 즐겨찾기 화면은 같은 카드 문법(상단 16:9 썸네일, 제목, 작성자/보조 메타, 하단 상태 영역)을 공유하도록 정리한다.
|
||||
@@ -110,6 +112,7 @@
|
||||
- `GET /api/games/:gameId`
|
||||
- 티어표
|
||||
- `GET /api/tierlists/public`
|
||||
- `gameId` 없이 `q`만 전달하면 전체 공개 티어표 검색에 사용한다.
|
||||
- `GET /api/tierlists/me`
|
||||
- `GET /api/tierlists/favorites/me`
|
||||
- `GET /api/tierlists/:id`
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
- 에디터 우측 패널은 셸의 세 번째 컬럼으로 옮겼지만, 내부 카드 간격과 섹션 구분선은 아직 첨부 시안처럼 더 촘촘하게 정리할 필요가 있다.
|
||||
- 에디터 우측 패널 외곽 래퍼는 제거했으므로, 다음 단계는 공통 오른쪽 컬럼 안에서 입력/버튼/구분선 간격을 시안처럼 더 정교하게 다듬는 작업이다.
|
||||
- 공통 56px 셸 헤더는 반영했으므로, 다음 단계는 좌/중앙/우 헤더 안에 실제 아이콘/상태 요소를 시안 순서에 맞게 하나씩 채워 넣는 작업이다.
|
||||
- 좌측 레일은 최근 즐겨찾기와 전역 검색까지 붙었으므로, 다음 단계는 검색 자동완성이나 즐겨찾기 썸네일 품질 같은 디테일을 더 다듬는 작업이다.
|
||||
- 공통 3단 셸 구조는 고정했지만, 관리자/에디터 우측 패널 내부에 아직 바디에 남아 있는 제어 요소를 더 옮겨야 한다.
|
||||
- 홈 화면 우측 사이드는 CTA 하나만 남긴 상태이므로, 이후 필요할 때도 임시 정보 카드 다수를 다시 넣기보다 실제 필요한 기능만 선별해 추가해야 한다.
|
||||
- 관리자 화면은 헤더 요약 통계와 카드 계층까지 정리됐지만, 아직 표준 SVG 아이콘 교체와 더 세밀한 상태 색상/선택 상태 표현은 남아 있다.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-03-30 v1.2.19
|
||||
- **왼쪽 레일 설정 흐름 단순화**: 사용자 카드 클릭 팝업을 제거하고, 설정은 좌측 `Settings` 메뉴에서만 진입하도록 정리했으며 프로필 화면 하단에 로그아웃 버튼을 추가
|
||||
- **좌측 즐겨찾기 바로가기 추가**: 좌측 `Favorites` 영역에 최근 즐겨찾기 티어표 최대 10개를 바로가기 형태로 표시하고, 하단 `즐겨찾기 더 보기` 링크로 전체 즐겨찾기 화면에 연결
|
||||
- **전역 공개 티어표 검색 추가**: 좌측 검색 입력은 이제 전체 공개 티어표를 대상으로 검색하며, 새 `/search` 결과 화면에서 제목/작성자 기준 검색 결과를 카드 목록으로 표시
|
||||
- **설정 아이콘 반영 및 중복 관리자 버튼 제거**: 사용자가 추가한 `settings.svg`를 좌측 `Settings` 메뉴에 연결하고, 상단 내비에 중복되던 관리자 메뉴 항목은 제거
|
||||
|
||||
## 2026-03-30 v1.2.18
|
||||
- **공통 56px 셸 헤더 도입**: 좌측 사이드, 중앙 워크스페이스, 우측 사이드 상단에 각각 높이 `56px`의 고정 헤더 블록을 두고, 사이트 타이틀 `Tier Maker by zenn`은 중앙 상단 헤더에만 표시되도록 셸 구조를 재정리
|
||||
- **에디터 메인 래퍼 단순화**: 티어표 편집 화면의 `.layout` 2열 그리드를 제거해 공통 3단 셸 바깥에 중복 컬럼이 생기지 않도록 정리
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue'
|
||||
import { computed, onMounted, provide, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { toApiUrl } from './lib/runtime'
|
||||
import { api } from './lib/api'
|
||||
import { useToast } from './composables/useToast'
|
||||
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 iconSettings from './assets/icons/settings.svg'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const { toasts, dismissToast } = useToast()
|
||||
|
||||
const menuOpen = ref(false)
|
||||
const rightRailOpen = ref(true)
|
||||
const searchQuery = ref('')
|
||||
const favoriteShortcuts = ref([])
|
||||
provide('rightRailOpen', rightRailOpen)
|
||||
provide('localRightRailTarget', '#local-right-rail-root')
|
||||
|
||||
@@ -36,11 +39,8 @@ const leftNavItems = computed(() => {
|
||||
{ key: 'home', label: 'Games', path: '/', iconSrc: iconGridView },
|
||||
{ key: 'me', label: '내 리스트', path: '/me', iconSrc: iconLists, requiresAuth: true },
|
||||
{ key: 'favorites', label: '즐겨찾기', path: '/favorites', icon: 'M12 4.75l2.18 4.42 4.88.71-3.53 3.44.83 4.86L12 15.9 7.64 18.18l.83-4.86-3.53-3.44 4.88-.71z', requiresAuth: true },
|
||||
{ key: 'profile', label: 'Settings', path: '/profile', icon: 'M12 4.75a2.2 2.2 0 0 1 2.08 1.5l.18.56.58.13a2.2 2.2 0 0 1 1.52 2.76l-.17.56.39.46a2.2 2.2 0 0 1 0 2.86l-.39.46.17.56a2.2 2.2 0 0 1-1.52 2.76l-.58.13-.18.56a2.2 2.2 0 0 1-4.16 0l-.18-.56-.58-.13a2.2 2.2 0 0 1-1.52-2.76l.17-.56-.39-.46a2.2 2.2 0 0 1 0-2.86l.39-.46-.17-.56a2.2 2.2 0 0 1 1.52-2.76l.58-.13.18-.56A2.2 2.2 0 0 1 12 4.75z M12 9.35a2.65 2.65 0 1 0 0 5.3 2.65 2.65 0 0 0 0-5.3z', requiresAuth: true },
|
||||
{ key: 'profile', label: 'Settings', path: '/profile', iconSrc: iconSettings, requiresAuth: true },
|
||||
]
|
||||
if (isAdmin.value) {
|
||||
items.push({ key: 'admin', label: 'Admin', path: '/admin', iconSrc: iconLists })
|
||||
}
|
||||
return items.filter((item) => !item.requiresAuth || auth.user)
|
||||
})
|
||||
const routeMeta = computed(() => {
|
||||
@@ -119,6 +119,16 @@ const routeMeta = computed(() => {
|
||||
action: () => router.push('/me'),
|
||||
}
|
||||
}
|
||||
if (route.name === 'search') {
|
||||
return {
|
||||
title: 'Search',
|
||||
subtitle: '전체 공개 티어표 검색 결과',
|
||||
contextTitle: '검색',
|
||||
contextText: '제목, 작성자 기준으로 공개 티어표를 전체 검색할 수 있어요.',
|
||||
actionLabel: '홈으로',
|
||||
action: () => router.push('/'),
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: 'Tier Maker',
|
||||
subtitle: 'by zenn',
|
||||
@@ -128,12 +138,6 @@ const routeMeta = computed(() => {
|
||||
action: () => router.push('/'),
|
||||
}
|
||||
})
|
||||
const favoriteLinks = computed(() => [
|
||||
{ label: 'Games', path: '/' },
|
||||
...(auth.user ? [{ label: 'Favorites', path: '/favorites' }] : []),
|
||||
...(auth.user ? [{ label: 'My Lists', path: '/me' }] : []),
|
||||
])
|
||||
|
||||
function railGlyph(type) {
|
||||
if (type === 'menu') return 'M4 6.5h16M4 12h16M4 17.5h16'
|
||||
if (type === 'search') return 'M10.2 6.2a4 4 0 1 1 0 8 4 4 0 0 1 0-8z M13.6 13.6l3.2 3.2'
|
||||
@@ -147,35 +151,22 @@ onMounted(async () => {
|
||||
const saved = window.localStorage.getItem('tier-maker:right-rail-open')
|
||||
if (saved === '0') rightRailOpen.value = false
|
||||
}
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
|
||||
await loadFavoriteShortcuts()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
menuOpen.value = false
|
||||
searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
|
||||
}
|
||||
)
|
||||
|
||||
function onDocumentClick(event) {
|
||||
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 toggleRightRail() {
|
||||
rightRailOpen.value = !rightRailOpen.value
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -183,16 +174,42 @@ function toggleRightRail() {
|
||||
}
|
||||
}
|
||||
|
||||
function goProfile() {
|
||||
menuOpen.value = false
|
||||
router.push('/profile')
|
||||
function avatarFallbackOfFavorite(tierList) {
|
||||
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
menuOpen.value = false
|
||||
await auth.logout()
|
||||
router.push('/')
|
||||
function favoriteThumbnailUrl(tierList) {
|
||||
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
|
||||
}
|
||||
|
||||
async function loadFavoriteShortcuts() {
|
||||
if (!auth.user) {
|
||||
favoriteShortcuts.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await api.listMyFavoriteTierLists({ sort: 'favorited' })
|
||||
favoriteShortcuts.value = (data.tierLists || []).slice(0, 10)
|
||||
} catch (e) {
|
||||
favoriteShortcuts.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function openFavoriteShortcut(item) {
|
||||
router.push(`/editor/${item.gameId}/${item.id}`)
|
||||
}
|
||||
|
||||
function submitGlobalSearch() {
|
||||
const query = (searchQuery.value || '').trim()
|
||||
router.push(query ? `/search?q=${encodeURIComponent(query)}` : '/search')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => auth.user?.id,
|
||||
async () => {
|
||||
await loadFavoriteShortcuts()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -218,26 +235,22 @@ async function logout() {
|
||||
|
||||
<div class="leftRail__body">
|
||||
<div v-if="auth.user" class="appUserCard">
|
||||
<button v-if="auth.user" class="appUserCard__button" type="button" @click.stop="toggleMenu">
|
||||
<div class="appUserCard__button">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" />
|
||||
<div v-else class="appUserCard__avatar appUserCard__avatar--fallback">{{ accountName[0]?.toUpperCase() || 'U' }}</div>
|
||||
<div class="appUserCard__meta">
|
||||
<div class="appUserCard__name">{{ accountName }}</div>
|
||||
<div class="appUserCard__email">{{ accountEmail }}</div>
|
||||
</div>
|
||||
</button>
|
||||
<div v-if="menuOpen" class="appUserMenu">
|
||||
<button class="appUserMenu__item" type="button" @click="goProfile">프로필</button>
|
||||
<button class="appUserMenu__item" type="button" @click="logout">로그아웃</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="searchStub" type="button" @click="$router.push('/favorites')">
|
||||
<form class="searchStub" @submit.prevent="submitGlobalSearch">
|
||||
<span class="searchStub__icon">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path :d="railGlyph('search')" /></svg>
|
||||
</span>
|
||||
<span>Quick Search</span>
|
||||
</button>
|
||||
<input v-model="searchQuery" class="searchStub__input" type="search" placeholder="전체 티어표 검색" />
|
||||
</form>
|
||||
|
||||
<nav class="leftNav">
|
||||
<RouterLink
|
||||
@@ -257,10 +270,27 @@ async function logout() {
|
||||
|
||||
<div class="leftRail__section">
|
||||
<div class="leftRail__sectionTitle">Favorites</div>
|
||||
<RouterLink v-for="item in favoriteLinks" :key="item.path" :to="item.path" class="favoriteLink">
|
||||
<span class="favoriteLink__dot"></span>
|
||||
<span>{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
<template v-if="favoriteShortcuts.length">
|
||||
<button
|
||||
v-for="item in favoriteShortcuts"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="favoriteShortcut"
|
||||
@click="openFavoriteShortcut(item)"
|
||||
>
|
||||
<img v-if="favoriteThumbnailUrl(item)" :src="favoriteThumbnailUrl(item)" alt="" class="favoriteShortcut__thumb" />
|
||||
<div v-else class="favoriteShortcut__thumb favoriteShortcut__thumb--fallback">{{ avatarFallbackOfFavorite(item) }}</div>
|
||||
<span class="favoriteShortcut__label">{{ item.title }}</span>
|
||||
</button>
|
||||
<RouterLink to="/favorites" class="favoriteMoreLink">
|
||||
<span class="favoriteMoreLink__icon">
|
||||
<img :src="iconSettings" alt="" />
|
||||
</span>
|
||||
<span>즐겨찾기 더 보기</span>
|
||||
<span class="favoriteMoreLink__arrow">↗</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
<div v-else class="favoriteEmpty">아직 즐겨찾기한 티어표가 없어요.</div>
|
||||
</div>
|
||||
|
||||
<div class="leftRail__bottom">
|
||||
@@ -463,7 +493,7 @@ async function logout() {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
cursor: default;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -501,30 +531,6 @@ async function logout() {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.appUserMenu {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(10, 10, 10, 0.98);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.appUserMenu__item {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 0;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.searchStub {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
@@ -535,8 +541,22 @@ async function logout() {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
cursor: pointer;
|
||||
margin-bottom: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.searchStub__input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
outline: none;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.searchStub__input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
}
|
||||
|
||||
.searchStub__icon {
|
||||
@@ -586,27 +606,87 @@ async function logout() {
|
||||
}
|
||||
|
||||
.leftRail__sectionTitle {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.favoriteLink {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
.favoriteEmpty {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.46);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.favoriteShortcut {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
padding: 6px 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.favoriteShortcut__thumb {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.favoriteShortcut__thumb--fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.favoriteShortcut__label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.favoriteMoreLink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.favoriteLink__dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
.favoriteMoreLink__icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.favoriteMoreLink__icon img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: block;
|
||||
filter: brightness(0) saturate(100%) invert(94%) sepia(6%) saturate(207%) hue-rotate(186deg) brightness(96%) contrast(92%);
|
||||
}
|
||||
|
||||
.favoriteMoreLink__arrow {
|
||||
margin-left: auto;
|
||||
opacity: 0.56;
|
||||
}
|
||||
|
||||
.leftRail__bottom {
|
||||
|
||||
1
frontend/src/assets/icons/settings.svg
Normal file
1
frontend/src/assets/icons/settings.svg
Normal 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="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z"/></svg>
|
||||
|
After Width: | Height: | Size: 770 B |
@@ -62,6 +62,7 @@ export const api = {
|
||||
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}`),
|
||||
searchPublicTierLists: (gameId, q = '') =>
|
||||
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}&q=${encodeURIComponent(q || '')}`),
|
||||
searchAllPublicTierLists: (q = '') => request(`/api/tierlists/public?q=${encodeURIComponent(q || '')}`),
|
||||
listMyTierLists: () => request('/api/tierlists/me'),
|
||||
listMyFavoriteTierLists: ({ q = '', sort = 'favorited' } = {}) =>
|
||||
request(`/api/tierlists/favorites/me?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}`),
|
||||
|
||||
@@ -8,6 +8,7 @@ import MyTierListsView from '../views/MyTierListsView.vue'
|
||||
import FavoriteTierListsView from '../views/FavoriteTierListsView.vue'
|
||||
import AdminView from '../views/AdminView.vue'
|
||||
import ProfileView from '../views/ProfileView.vue'
|
||||
import SearchResultsView from '../views/SearchResultsView.vue'
|
||||
|
||||
export function createRouter() {
|
||||
return _createRouter({
|
||||
@@ -20,6 +21,7 @@ export function createRouter() {
|
||||
{ path: '/login', name: 'login', component: LoginView },
|
||||
{ path: '/me', name: 'me', component: MyTierListsView },
|
||||
{ path: '/favorites', name: 'favorites', component: FavoriteTierListsView },
|
||||
{ path: '/search', name: 'search', component: SearchResultsView },
|
||||
{ path: '/admin', name: 'admin', component: AdminView },
|
||||
{ path: '/profile', name: 'profile', component: ProfileView },
|
||||
],
|
||||
|
||||
@@ -70,6 +70,12 @@ async function saveProfile() {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await auth.logout()
|
||||
toast.success('로그아웃했어요.')
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -93,9 +99,12 @@ async function saveProfile() {
|
||||
<label class="label">아바타 업로드</label>
|
||||
<input class="file" type="file" accept="image/*" :disabled="saving" @change="onAvatarChange" />
|
||||
<div class="hint">파일 선택은 미리보기만 바뀌고, 실제 반영은 저장 버튼을 눌렀을 때 진행됩니다.</div>
|
||||
<button class="saveBtn" :disabled="saving" @click="saveProfile">
|
||||
{{ saving ? '저장중...' : '프로필 저장' }}
|
||||
</button>
|
||||
<div class="actions">
|
||||
<button class="saveBtn" :disabled="saving" @click="saveProfile">
|
||||
{{ saving ? '저장중...' : '프로필 저장' }}
|
||||
</button>
|
||||
<button class="logoutBtn" type="button" @click="logout">로그아웃</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -188,7 +197,6 @@ async function saveProfile() {
|
||||
font-size: 13px;
|
||||
}
|
||||
.saveBtn {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
@@ -197,4 +205,19 @@ async function saveProfile() {
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
}
|
||||
.actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.logoutBtn {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(239, 68, 68, 0.24);
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
}
|
||||
</style>
|
||||
|
||||
302
frontend/src/views/SearchResultsView.vue
Normal file
302
frontend/src/views/SearchResultsView.vue
Normal file
@@ -0,0 +1,302 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const tierLists = ref([])
|
||||
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, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function displayNameOf(tierList) {
|
||||
return tierList.authorName || '알 수 없음'
|
||||
}
|
||||
|
||||
function avatarSrcOf(tierList) {
|
||||
return tierList.authorAvatarSrc ? toApiUrl(tierList.authorAvatarSrc) : ''
|
||||
}
|
||||
|
||||
function avatarFallbackOf(tierList) {
|
||||
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
|
||||
}
|
||||
|
||||
function tierListThumbnailUrl(tierList) {
|
||||
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
|
||||
}
|
||||
|
||||
function openTierList(tierList) {
|
||||
router.push(`/editor/${tierList.gameId}/${tierList.id}`)
|
||||
}
|
||||
|
||||
async function loadResults() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await api.searchAllPublicTierLists(query.value)
|
||||
tierLists.value = data.tierLists || []
|
||||
} catch (e) {
|
||||
error.value = '검색 결과를 불러오지 못했어요.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function submitSearch() {
|
||||
router.push(normalizedQuery.value ? `/search?q=${encodeURIComponent(normalizedQuery.value)}` : '/search')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.q,
|
||||
async (nextQuery) => {
|
||||
query.value = typeof nextQuery === 'string' ? nextQuery : ''
|
||||
await loadResults()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
query.value = typeof route.query.q === 'string' ? route.query.q : ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="wrap">
|
||||
<div class="head">
|
||||
<div>
|
||||
<div class="head__eyebrow">Search</div>
|
||||
<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>
|
||||
<div v-else-if="loading" class="empty">검색 중이에요.</div>
|
||||
<div v-else-if="tierLists.length === 0" class="empty">검색 결과가 없어요.</div>
|
||||
<div v-else class="list">
|
||||
<article v-for="tierList in tierLists" :key="tierList.id" class="boardCard">
|
||||
<button class="boardCard__body" @click="openTierList(tierList)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" />
|
||||
<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>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px 2px 8px;
|
||||
}
|
||||
.head__eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
}
|
||||
.title {
|
||||
margin: 4px 0 0;
|
||||
font-size: 32px;
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.desc {
|
||||
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;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(239, 68, 68, 0.18);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
.empty {
|
||||
opacity: 0.76;
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.boardCard {
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
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;
|
||||
}
|
||||
.boardCard:hover {
|
||||
transform: translateY(-2px);
|
||||
background: rgba(70, 70, 70, 0.96);
|
||||
}
|
||||
.boardCard__body {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.boardCard__thumbWrap {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #555;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.boardCard__thumb,
|
||||
.boardCard__thumbPlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
.boardCard__thumb {
|
||||
object-fit: cover;
|
||||
}
|
||||
.boardCard__thumbPlaceholder {
|
||||
background: #555;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.boardCard__head {
|
||||
padding: 14px 14px 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.boardCard__title {
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
}
|
||||
.boardCard__author {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
opacity: 0.86;
|
||||
}
|
||||
.boardCard__avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
object-fit: cover;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.boardCard__avatar--fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 12px;
|
||||
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;
|
||||
}
|
||||
.favoriteStat {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.74);
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user