릴리스: v1.2.61 게임 템플릿 검색과 즐겨찾기 추가
This commit is contained in:
@@ -1,28 +1,71 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const items = ref([])
|
||||
const error = ref('')
|
||||
const games = computed(() => items.value.filter((item) => item.id !== 'freeform'))
|
||||
const loadingFavoriteId = ref('')
|
||||
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim().toLowerCase() : ''))
|
||||
const games = computed(() => {
|
||||
const filtered = items.value
|
||||
.filter((item) => item.id !== 'freeform')
|
||||
.filter((item) => {
|
||||
if (!query.value) return true
|
||||
const haystack = `${item.name || ''} ${item.id || ''}`.toLowerCase()
|
||||
return haystack.includes(query.value)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
return filtered.slice().sort((a, b) => {
|
||||
if (!!a.isFavorited !== !!b.isFavorited) return a.isFavorited ? -1 : 1
|
||||
const rankA = a.displayRank == null ? Number.MAX_SAFE_INTEGER : a.displayRank
|
||||
const rankB = b.displayRank == null ? Number.MAX_SAFE_INTEGER : b.displayRank
|
||||
if (rankA !== rankB) return rankA - rankB
|
||||
return (a.name || '').localeCompare(b.name || '', 'ko')
|
||||
})
|
||||
})
|
||||
|
||||
async function loadGames() {
|
||||
try {
|
||||
const data = await api.listGames()
|
||||
items.value = data.games || []
|
||||
} catch (e) {
|
||||
error.value = '백엔드에 연결할 수 없어요. backend 서버가 실행 중인지 확인해주세요.'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(loadGames)
|
||||
watch(() => auth.user?.id, loadGames)
|
||||
|
||||
function goGame(gameId) {
|
||||
router.push(`/games/${gameId}`)
|
||||
}
|
||||
|
||||
async function toggleFavorite(game, event) {
|
||||
event?.stopPropagation()
|
||||
if (!auth.user) {
|
||||
router.push(`/login?redirect=${encodeURIComponent(route.fullPath || '/')}`)
|
||||
return
|
||||
}
|
||||
if (!game?.id || loadingFavoriteId.value === game.id) return
|
||||
|
||||
try {
|
||||
loadingFavoriteId.value = game.id
|
||||
const res = game.isFavorited ? await api.unfavoriteGame(game.id) : await api.favoriteGame(game.id)
|
||||
items.value = items.value.map((entry) => (entry.id === game.id ? { ...entry, ...res.game } : entry))
|
||||
} catch (e) {
|
||||
error.value = '즐겨찾기 변경에 실패했어요.'
|
||||
} finally {
|
||||
loadingFavoriteId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function thumbUrl(g) {
|
||||
return g.thumbnailSrc ? toApiUrl(g.thumbnailSrc) : ''
|
||||
}
|
||||
@@ -34,22 +77,35 @@ function thumbUrl(g) {
|
||||
<div class="pageHead__eyebrow">Workspace</div>
|
||||
<h1 class="pageHead__title">Game Library</h1>
|
||||
<p class="pageHead__desc">자주 쓰는 게임 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 수 있어요.</p>
|
||||
<p v-if="query" class="pageHead__searchState">"{{ query }}"에 맞는 게임 템플릿만 보고 있어요.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<section class="libraryGrid">
|
||||
<button v-for="g in games" :key="g.id" class="libraryCard" @click="goGame(g.id)">
|
||||
<div class="libraryCard__thumbWrap">
|
||||
<section v-if="games.length" class="libraryGrid">
|
||||
<article v-for="g in games" :key="g.id" class="libraryCard">
|
||||
<button
|
||||
class="libraryCard__favorite"
|
||||
type="button"
|
||||
:class="{ 'libraryCard__favorite--active': g.isFavorited }"
|
||||
:disabled="loadingFavoriteId === g.id"
|
||||
@click.stop="toggleFavorite(g, $event)"
|
||||
>
|
||||
{{ g.isFavorited ? '★' : '☆' }}
|
||||
</button>
|
||||
<button class="libraryCard__main" type="button" @click="goGame(g.id)">
|
||||
<div class="libraryCard__thumbWrap">
|
||||
<img v-if="thumbUrl(g)" class="libraryCard__thumb" :src="thumbUrl(g)" :alt="g.name" />
|
||||
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="libraryCard__body">
|
||||
<div class="libraryCard__title">{{ g.name }}</div>
|
||||
<div class="libraryCard__meta">{{ g.id }}</div>
|
||||
</div>
|
||||
</button>
|
||||
<div class="libraryCard__body">
|
||||
<div class="libraryCard__title">{{ g.name }}</div>
|
||||
<div class="libraryCard__meta">{{ g.id }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
</section>
|
||||
<div v-else class="libraryEmpty">{{ query ? '검색어에 맞는 게임 템플릿이 없어요.' : '표시할 게임 템플릿이 없어요.' }}</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -66,7 +122,12 @@ function thumbUrl(g) {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
.pageHead__searchState {
|
||||
margin-top: 8px;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
.libraryCard {
|
||||
position: relative;
|
||||
text-align: left;
|
||||
padding: 14px;
|
||||
border-radius: 22px;
|
||||
@@ -77,14 +138,40 @@ function thumbUrl(g) {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
background 0.16s ease;
|
||||
transition: transform 0.16s ease, background 0.16s ease;
|
||||
}
|
||||
.libraryCard:hover {
|
||||
background: rgba(70, 70, 70, 0.96);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.libraryCard__main {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.libraryCard__favorite {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(15, 15, 15, 0.72);
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 17px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
}
|
||||
.libraryCard__favorite--active {
|
||||
color: #ffd86b;
|
||||
}
|
||||
.libraryCard__thumbWrap {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
@@ -119,6 +206,10 @@ function thumbUrl(g) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.libraryEmpty {
|
||||
padding: 20px 0;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
@media (max-width: 1400px) {
|
||||
.libraryGrid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
Reference in New Issue
Block a user