릴리스: v1.2.19 왼쪽 레일 검색과 즐겨찾기 정리

This commit is contained in:
2026-03-30 17:34:49 +09:00
parent 285644bdde
commit 0812640ec1
11 changed files with 523 additions and 94 deletions

View File

@@ -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 {