Compare commits

..

5 Commits

9 changed files with 528 additions and 134 deletions

View File

@@ -252,6 +252,18 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await query(`
CREATE TABLE IF NOT EXISTS favorite_games (
user_id VARCHAR(64) NOT NULL,
game_id VARCHAR(120) NOT NULL,
created_at BIGINT NOT NULL,
PRIMARY KEY (user_id, game_id),
INDEX idx_favorite_games_game_id (game_id),
CONSTRAINT fk_favorite_games_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_favorite_games_game FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await query(`
CREATE TABLE IF NOT EXISTS template_requests (
id VARCHAR(64) PRIMARY KEY,
@@ -431,7 +443,7 @@ async function adminDeleteUser(id) {
await query('DELETE FROM users WHERE id = ?', [id])
}
async function listGames() {
async function listGames(currentUserId = '') {
const rows = await query(
`
SELECT id, name, thumbnail_src, display_rank, created_at
@@ -445,7 +457,15 @@ async function listGames() {
`,
[FREEFORM_GAME_ID]
)
return rows.map(mapGameRow)
const games = rows.map(mapGameRow)
if (!currentUserId) return games.map((game) => ({ ...game, isFavorited: false }))
const favoriteRows = await query('SELECT game_id FROM favorite_games WHERE user_id = ?', [currentUserId])
const favoriteSet = new Set(favoriteRows.map((row) => row.game_id))
return games.map((game) => ({
...game,
isFavorited: favoriteSet.has(game.id),
}))
}
async function findGameById(id) {
@@ -605,28 +625,51 @@ async function findCustomItemById(id) {
}
}
async function getCustomItemUsageMap() {
const rows = await query('SELECT groups_json, pool_json FROM tierlists')
async function getCustomItemUsageMeta() {
const rows = await query(
`
SELECT t.game_id, g.name AS game_name, t.groups_json, t.pool_json
FROM tierlists t
LEFT JOIN games g ON g.id = t.game_id
`
)
const usageMap = new Map()
const linkedGamesMap = new Map()
rows.forEach((row) => {
const groups = parseJson(row.groups_json, [])
const pool = parseJson(row.pool_json, [])
const seenItemIds = new Set()
groups.forEach((group) => {
;(group?.itemIds || []).forEach((itemId) => {
usageMap.set(itemId, (usageMap.get(itemId) || 0) + 1)
if (itemId) seenItemIds.add(itemId)
})
})
pool.forEach((item) => {
if (item?.id) {
usageMap.set(item.id, (usageMap.get(item.id) || 0) + 1)
seenItemIds.add(item.id)
}
})
if (!row.game_id) return
seenItemIds.forEach((itemId) => {
if (!linkedGamesMap.has(itemId)) linkedGamesMap.set(itemId, new Map())
linkedGamesMap.get(itemId).set(row.game_id, {
id: row.game_id,
name: row.game_name || row.game_id,
})
})
})
return usageMap
return {
usageMap,
linkedGamesMap: new Map(Array.from(linkedGamesMap.entries()).map(([itemId, gameMap]) => [itemId, Array.from(gameMap.values())])),
}
}
async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnly = false } = {}) {
@@ -655,7 +698,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl
params
)
const usageMap = await getCustomItemUsageMap()
const { usageMap, linkedGamesMap } = await getCustomItemUsageMeta()
const allItems = rows
.map((row) => ({
id: row.id,
@@ -666,6 +709,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl
ownerName: row.nickname || row.email,
ownerEmail: row.email,
usageCount: usageMap.get(row.id) || 0,
linkedGames: linkedGamesMap.get(row.id) || [],
}))
.filter((item) => (orphanOnly ? item.usageCount === 0 : true))
@@ -705,7 +749,7 @@ async function findUnusedCustomItems({ queryText = '' } = {}) {
params
)
const usageMap = await getCustomItemUsageMap()
const { usageMap } = await getCustomItemUsageMeta()
return rows
.map((row) => ({
id: row.id,
@@ -1271,6 +1315,14 @@ async function unfavoriteTierList({ userId, tierListId }) {
await query('DELETE FROM favorite_tierlists WHERE user_id = ? AND tierlist_id = ?', [userId, tierListId])
}
async function favoriteGame({ userId, gameId }) {
await query('INSERT IGNORE INTO favorite_games (user_id, game_id, created_at) VALUES (?, ?, ?)', [userId, gameId, now()])
}
async function unfavoriteGame({ userId, gameId }) {
await query('DELETE FROM favorite_games WHERE user_id = ? AND game_id = ?', [userId, gameId])
}
module.exports = {
DB_NAME,
ensureData,
@@ -1305,6 +1357,8 @@ module.exports = {
findTierListById,
favoriteTierList,
unfavoriteTierList,
favoriteGame,
unfavoriteGame,
deleteTierList,
findCustomItemsByIds,
deleteCustomItems,

View File

@@ -1,13 +1,32 @@
const express = require('express')
const { listGames, getGameDetail } = require('../db')
const { listGames, getGameDetail, findGameById, favoriteGame, unfavoriteGame } = require('../db')
const { requireAuth } = require('../middleware/auth')
const router = express.Router()
router.get('/', async (req, res) => {
const games = await listGames()
const games = await listGames(req.session?.userId || '')
res.json({ games })
})
router.post('/:gameId/favorite', requireAuth, async (req, res) => {
const game = await findGameById(req.params.gameId)
if (!game || game.id === 'freeform') return res.status(404).json({ error: 'not_found' })
await favoriteGame({ userId: req.session.userId, gameId: game.id })
const games = await listGames(req.session.userId)
const updated = games.find((entry) => entry.id === game.id) || { ...game, isFavorited: true }
res.json({ game: updated })
})
router.delete('/:gameId/favorite', requireAuth, async (req, res) => {
const game = await findGameById(req.params.gameId)
if (!game || game.id === 'freeform') return res.status(404).json({ error: 'not_found' })
await unfavoriteGame({ userId: req.session.userId, gameId: game.id })
const games = await listGames(req.session.userId)
const updated = games.find((entry) => entry.id === game.id) || { ...game, isFavorited: false }
res.json({ game: updated })
})
router.get('/:gameId', async (req, res) => {
const detail = await getGameDetail(req.params.gameId)
if (!detail) return res.status(404).json({ error: 'not_found' })

View File

@@ -184,6 +184,8 @@ router.post('/thumbnail', requireAuth, thumbnailUpload.single('thumbnail'), asyn
router.post('/:id/template-request', requireAuth, async (req, res) => {
const schema = z.object({
type: z.enum(['create', 'update']),
requestTitle: z.string().trim().min(1).max(80),
requestDescription: z.string().trim().min(1).max(240),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
@@ -197,9 +199,6 @@ router.post('/:id/template-request', requireAuth, async (req, res) => {
if (parsed.data.type === 'create') {
if (tierList.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
if (!(tierList.title || '').trim() || (tierList.title || '').trim() === FREEFORM_DEFAULT_TITLE) {
return res.status(400).json({ error: 'title_required' })
}
} else {
if (tierList.gameId === FREEFORM_GAME_ID) return res.status(400).json({ error: 'game_template_required' })
}
@@ -212,8 +211,8 @@ router.post('/:id/template-request', requireAuth, async (req, res) => {
sourceTierListId: tierList.id,
sourceGameId: tierList.gameId,
targetGameId: parsed.data.type === 'update' ? tierList.gameId : '',
title: tierList.title,
description: tierList.description || '',
title: parsed.data.requestTitle,
description: parsed.data.requestDescription,
thumbnailSrc: tierList.thumbnailSrc || '',
items: customItems,
})

View File

@@ -1,5 +1,26 @@
# 업데이트 로그
## 2026-03-31 v1.2.62
- 템플릿 요청 모달의 제목/설명 입력을 Settings 화면과 같은 어두운 입력 문법으로 맞춰 흰 배경/흰 글자처럼 보이던 문제를 정리함.
- 앱 셸은 사이드 기본 바탕색을 중심으로 재정리하고, 중앙 바디에 배경과 좌우 보더를 줘 긴 스크롤에서도 사이드가 잘리는 듯한 인상을 줄이도록 조정함.
## 2026-03-31 v1.2.61
- Game Library 왼쪽 검색을 전체 티어표 검색이 아니라 게임 템플릿 검색으로 바꾸고, 홈 화면에서 검색어에 맞는 게임만 필터링하도록 조정함.
- 게임 템플릿에 사용자별 즐겨찾기 별 아이콘을 추가하고, 즐겨찾기한 게임이 관리자 고정 순서보다 우선 노출되도록 백엔드와 홈 화면을 함께 확장함.
- 앱 셸의 100vh 높이 계산을 100dvh와 고정 행 구조로 정리해, 콘텐츠가 없어도 생기던 불필요한 세로 스크롤을 줄임.
## 2026-03-31 v1.2.60
- 관리자 티어표 관리 카드에서 사용자가 입력한 설명을 제목 아래에 함께 노출해 요청 의도를 더 빨리 파악할 수 있게 함.
- 템플릿 등록/업데이트 요청은 이제 에디터 모달에서 제목과 설명을 별도로 입력받고, 예시 문구와 함께 전송하도록 정리함.
## 2026-03-31 v1.2.59
- 관리자 아이템 상세 모달의 게임 선택을 전용 상태로 분리해 기본 선택값이 비어 있도록 바꾸고, 썸네일 아래에 배치해 정보/액션과 시각적으로 분리함.
- 커스텀 아이템이 실제로 사용 중인 게임 목록을 백엔드에서 함께 내려주고, 템플릿 요청 생성 폼에는 게임 ID와 게임 이름 라벨을 추가해 구분을 명확히 함.
## 2026-03-31 v1.2.58
- 관리자 아이템 관리 카드를 썸네일과 제목만 보이는 compact 카드로 줄여, 대량 업로드된 이미지도 훨씬 높은 밀도로 탐색할 수 있게 정리함.
- 카드 클릭 시 상세 정보를 모달로 열고 이미지 다운로드, 기본 템플릿 추가, 삭제를 모달 안에서 결정하는 흐름으로 바꿈.
## 2026-03-31 v1.2.57
- 관리자 오른쪽 사이드에서 Featured, Game Summary, Users 패널을 완전히 제거하고, 티어표 요청 모드에는 모드 전환 탭만 남기도록 정리함.

View File

@@ -21,6 +21,7 @@ const { toasts, dismissToast } = useToast()
const leftRailCollapsed = ref(false)
const rightRailOpen = ref(true)
const searchQuery = ref('')
const searchPlaceholder = computed(() => (route.name === 'home' ? '게임 템플릿 검색' : '전체 티어표 검색'))
const isCollapsedSearchOpen = ref(false)
const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1440)
provide('rightRailOpen', rightRailOpen)
@@ -261,6 +262,10 @@ function handleLeftRailSearch() {
function submitGlobalSearch() {
const query = (searchQuery.value || '').trim()
isCollapsedSearchOpen.value = false
if (route.name === 'home') {
router.push(query ? `/?q=${encodeURIComponent(query)}` : '/')
return
}
router.push(query ? `/search?q=${encodeURIComponent(query)}` : '/search')
}
@@ -310,7 +315,7 @@ function submitGlobalSearch() {
<img :src="iconSearch" alt="" />
</span>
</button>
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : '전체 티어표 검색'" />
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : searchPlaceholder" />
</form>
<nav class="leftNav">
@@ -359,12 +364,12 @@ function submitGlobalSearch() {
</section>
</main>
<div v-if="isCollapsedSearchOpen" class="collapsedSearchModal" role="dialog" aria-modal="true" aria-label="전체 티어표 검색" @click.self="closeCollapsedSearch">
<div v-if="isCollapsedSearchOpen" class="collapsedSearchModal" role="dialog" aria-modal="true" :aria-label="searchPlaceholder" @click.self="closeCollapsedSearch">
<form class="collapsedSearchBar" @submit.prevent="submitGlobalSearch">
<span class="collapsedSearchBar__icon">
<img :src="iconSearch" alt="" />
</span>
<input v-model="searchQuery" class="collapsedSearchBar__input" type="search" placeholder="전체 티어표 검색" autofocus />
<input v-model="searchQuery" class="collapsedSearchBar__input" type="search" :placeholder="searchPlaceholder" autofocus />
</form>
</div>
@@ -410,12 +415,11 @@ function submitGlobalSearch() {
<style scoped>
.appShell {
min-height: 100vh;
min-height: 100dvh;
height: 100dvh;
display: grid;
grid-template-columns: var(--left-rail-width, 248px) minmax(0, 1fr) var(--right-rail-width, 320px);
background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.04), transparent 28%),
linear-gradient(180deg, #1a1a1a 0%, #121212 100%);
background: rgba(14, 14, 14, 0.96);
color: rgba(255, 255, 255, 0.92);
transition: grid-template-columns 220ms ease;
}
@@ -426,7 +430,8 @@ function submitGlobalSearch() {
.leftRail,
.rightRail {
min-height: 100vh;
min-height: 100dvh;
height: 100dvh;
border-right: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(14, 14, 14, 0.92);
box-sizing: border-box;
@@ -492,7 +497,7 @@ function submitGlobalSearch() {
}
.leftRail__body {
max-height: calc(100vh - 56px);
max-height: calc(100dvh - 56px);
}
.rightRail__body {
@@ -785,7 +790,11 @@ function submitGlobalSearch() {
.appMain {
min-width: 0;
min-height: 100dvh;
box-sizing: border-box;
background: rgba(18, 18, 18, 0.98);
border-left: 1px solid rgba(255, 255, 255, 0.08);
border-right: 1px solid rgba(255, 255, 255, 0.08);
}
.appMain--preview {
@@ -794,8 +803,10 @@ function submitGlobalSearch() {
.workspace {
display: grid;
grid-template-rows: 56px minmax(0, 1fr);
gap: 0;
min-height: 100vh;
min-height: 100dvh;
height: 100dvh;
}
.workspace--localRail {
@@ -836,23 +847,23 @@ function submitGlobalSearch() {
}
.workspaceBody {
min-height: calc(100vh - 56px);
padding: 0;
min-height: 0;
padding: 18px 18px 0;
border: 0;
border-radius: 0;
background: transparent;
background: rgba(24, 24, 24, 0.92);
box-shadow: none;
margin: 18px 18px 0;
margin: 0;
}
.workspaceBody--localRail {
min-height: calc(100vh - 56px);
padding: 0;
min-height: 0;
padding: 18px 18px 0;
border: 0;
border-radius: 0;
background: transparent;
background: rgba(24, 24, 24, 0.92);
box-shadow: none;
margin: 18px 18px 0;
margin: 0;
}
.rightRail {
@@ -978,7 +989,7 @@ function submitGlobalSearch() {
top: 0;
right: 0;
width: min(360px, calc(100vw - 20px));
height: 100vh;
height: 100dvh;
z-index: 30;
border-left: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(14, 14, 14, 0.96);
@@ -996,10 +1007,13 @@ function submitGlobalSearch() {
@media (max-width: 860px) {
.appShell {
grid-template-columns: 1fr;
height: auto;
min-height: 100dvh;
}
.leftRail {
min-height: auto;
height: auto;
border-right: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
@@ -1013,6 +1027,19 @@ function submitGlobalSearch() {
padding: 12px 14px;
}
.appMain {
min-height: auto;
border-left: 0;
border-right: 0;
}
.workspace,
.workspaceBody,
.workspaceBody--localRail {
min-height: 0;
height: auto;
}
.leftRail__content {
overflow: visible;
}

View File

@@ -32,6 +32,8 @@ export const api = {
listGames: () => request('/api/games'),
getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`),
favoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'POST' }),
unfavoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'DELETE' }),
updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }),
updateAdminGameItem: (gameId, itemId, payload) =>
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),

View File

@@ -29,6 +29,7 @@ const customItemLimit = ref(50)
const customItemTotal = ref(0)
const customItemOrphanOnly = ref(false)
const customItemTargetGameId = ref('')
const customItemModalTargetGameId = ref('')
const adminTierLists = ref([])
const adminTierListQuery = ref('')
@@ -48,9 +49,12 @@ const previewTierList = ref(null)
const userPasswordModalOpen = ref(false)
const userDeleteModalOpen = ref(false)
const userRoleModalOpen = ref(false)
const customItemModalOpen = ref(false)
const customItemDeleteModalOpen = ref(false)
const modalTargetUser = ref(null)
const modalPasswordDraft = ref('')
const modalRoleNextAdmin = ref(false)
const modalTargetCustomItem = ref(null)
const users = ref([])
@@ -227,9 +231,6 @@ function setTab(tab) {
if (tab === 'tierlists') {
tierlistsMode.value = 'requests'
}
if (tab === 'items' && !customItemTargetGameId.value && games.value.length) {
customItemTargetGameId.value = games.value[0].id
}
}
function setTierlistsMode(mode) {
@@ -257,9 +258,6 @@ async function refreshGames() {
try {
const data = await api.listGames()
games.value = data.games || []
if (!customItemTargetGameId.value && games.value.length) {
customItemTargetGameId.value = games.value[0].id
}
featuredGameIds.value = games.value
.filter((game) => game.displayRank != null)
.sort((a, b) => a.displayRank - b.displayRank)
@@ -855,18 +853,44 @@ function moveCustomItemPage(direction) {
refreshCustomItems()
}
async function removeCustomItem(item) {
function openCustomItemModal(item) {
modalTargetCustomItem.value = item || null
customItemModalTargetGameId.value = ''
customItemModalOpen.value = true
}
function closeCustomItemModal() {
customItemModalOpen.value = false
modalTargetCustomItem.value = null
customItemModalTargetGameId.value = ''
}
function openCustomItemDeleteModal(item) {
if (!item) return
if (item.usageCount > 0) {
error.value = '사용 중인 커스텀 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
return
}
modalTargetCustomItem.value = item
customItemDeleteModalOpen.value = true
}
function closeCustomItemDeleteModal() {
customItemDeleteModalOpen.value = false
}
async function removeCustomItem(item = modalTargetCustomItem.value) {
resetMessages()
if (!item) return
if (item.usageCount > 0) {
error.value = '사용 중인 커스텀 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
return
}
const ok = window.confirm(`"${item.label}" 미사용 커스텀 이미지를 삭제할까요?`)
if (!ok) return
try {
await api.deleteAdminCustomItem(item.id)
closeCustomItemDeleteModal()
closeCustomItemModal()
await refreshCustomItems()
success.value = '미사용 커스텀 이미지를 삭제했어요.'
} catch (e) {
@@ -890,16 +914,17 @@ async function removeUnusedCustomItems() {
async function promoteCustomItem(item) {
resetMessages()
if (!customItemTargetGameId.value) {
if (!customItemModalTargetGameId.value) {
error.value = '가져올 게임을 먼저 선택해주세요.'
return
}
try {
item.isPromoting = true
await api.promoteAdminCustomItem(item.id, { gameId: customItemTargetGameId.value })
const targetGameName = games.value.find((game) => game.id === customItemTargetGameId.value)?.name || customItemTargetGameId.value
if (selectedGameId.value === customItemTargetGameId.value) await loadGame()
await api.promoteAdminCustomItem(item.id, { gameId: customItemModalTargetGameId.value })
const targetGameName = games.value.find((game) => game.id === customItemModalTargetGameId.value)?.name || customItemModalTargetGameId.value
if (selectedGameId.value === customItemModalTargetGameId.value) await loadGame()
closeCustomItemModal()
success.value = `"${item.label}" 아이템을 ${targetGameName} 기본 템플릿으로 추가했어요.`
} catch (e) {
error.value = '커스텀 아이템을 기본 템플릿으로 가져오지 못했어요.'
@@ -1349,37 +1374,10 @@ async function saveFeaturedOrder() {
<div class="panel">
<div v-if="!customItems.length" class="hint">조건에 맞는 커스텀 아이템이 없어요.</div>
<div v-else class="customItemGrid">
<article v-for="item in customItems" :key="item.id" class="customItemCard">
<button v-for="item in customItems" :key="item.id" type="button" class="customItemCard" @click="openCustomItemModal(item)">
<img class="customItemCard__image" :src="toApiUrl(item.src)" :alt="item.label" />
<div class="customItemCard__body">
<div class="customItemCard__title" :title="item.label">{{ item.label }}</div>
<div class="customItemCard__metaList">
<div class="customItemCard__metaRow">
<span>파일</span>
<strong :title="item.src.split('/').pop()">{{ item.src.split('/').pop() }}</strong>
</div>
<div class="customItemCard__metaRow">
<span>업로더</span>
<strong :title="item.ownerName">{{ item.ownerName }}</strong>
</div>
<div class="customItemCard__metaRow">
<span>사용 </span>
<strong>{{ item.usageCount }} 티어표</strong>
</div>
<div class="customItemCard__metaRow">
<span>등록일</span>
<strong>{{ fmt(item.createdAt) }}</strong>
</div>
</div>
<div class="customItemCard__actions">
<a class="btn btn--small btn--ghost" :href="toApiUrl(item.src)" :download="item.label">이미지 다운로드</a>
<button class="btn btn--small btn--ghost" :disabled="!customItemTargetGameId || item.isPromoting" @click="promoteCustomItem(item)">
{{ item.isPromoting ? '추가중...' : '기본 템플릿에 추가' }}
</button>
<button class="btn btn--small btn--danger" :disabled="item.usageCount > 0" @click="removeCustomItem(item)">개별 삭제</button>
</div>
</div>
</article>
<div class="customItemCard__title" :title="item.label">{{ item.label }}</div>
</button>
</div>
<div class="pager">
@@ -1423,8 +1421,14 @@ async function saveFeaturedOrder() {
</div>
<div v-if="request.type === 'create'" class="templateRequestCard__form">
<input v-model="request.draftGameId" class="input" placeholder="새 게임 ID" />
<input v-model="request.draftGameName" class="input" placeholder="새 게임 이름" />
<label class="templateRequestField">
<span class="templateRequestField__label">게임 이름</span>
<input v-model="request.draftGameName" class="input" placeholder="새 게임 이름" />
</label>
<label class="templateRequestField">
<span class="templateRequestField__label">게임 ID</span>
<input v-model="request.draftGameId" class="input" placeholder="새 게임 ID" />
</label>
</div>
<div class="templateRequestCard__actions">
@@ -1457,6 +1461,7 @@ async function saveFeaturedOrder() {
<div class="tierAdminCard__head">
<div>
<div class="tierAdminCard__title">{{ tierList.title }}</div>
<div v-if="tierList.description" class="tierAdminCard__desc">{{ tierList.description }}</div>
<div class="tierAdminCard__meta">
{{ tierList.gameName || tierList.gameId }} · {{ tierListAuthorDisplayName(tierList) }} · {{ tierListVisibilityLabel(tierList) }}
</div>
@@ -1671,6 +1676,61 @@ async function saveFeaturedOrder() {
</div>
</div>
<div v-if="customItemModalOpen" class="modalOverlay" @click.self="closeCustomItemModal">
<div class="modalCard modalCard--customItem" role="dialog" aria-modal="true">
<div class="modalCard__titleRow">
<div class="modalCard__title">커스텀 아이템 상세</div>
<button class="btn btn--ghost btn--small" @click="closeCustomItemModal">닫기</button>
</div>
<div v-if="modalTargetCustomItem" class="customItemModal">
<div class="customItemModal__side">
<img class="customItemModal__image" :src="toApiUrl(modalTargetCustomItem.src)" :alt="modalTargetCustomItem.label" />
<div class="customItemModal__selector">
<span class="customItemModal__label">기본 템플릿에 추가</span>
<select v-model="customItemModalTargetGameId" class="select">
<option value="">게임 선택</option>
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }}</option>
</select>
</div>
<div class="customItemModal__linked">
<span class="customItemModal__label">이미 사용 중인 게임</span>
<div v-if="modalTargetCustomItem.linkedGames?.length" class="customItemModal__chips">
<span v-for="game in modalTargetCustomItem.linkedGames" :key="game.id" class="pill">{{ game.name }}</span>
</div>
<div v-else class="hint hint--tight">아직 연결된 게임이 없어요.</div>
</div>
</div>
<div class="customItemModal__body">
<div class="customItemModal__title">{{ modalTargetCustomItem.label }}</div>
<div class="customItemModal__metaList">
<div class="customItemModal__metaRow"><span>파일</span><strong :title="modalTargetCustomItem.src.split('/').pop()">{{ modalTargetCustomItem.src.split('/').pop() }}</strong></div>
<div class="customItemModal__metaRow"><span>업로더</span><strong :title="modalTargetCustomItem.ownerName">{{ modalTargetCustomItem.ownerName }}</strong></div>
<div class="customItemModal__metaRow"><span>사용 </span><strong>{{ modalTargetCustomItem.usageCount }} 티어표</strong></div>
<div class="customItemModal__metaRow"><span>등록일</span><strong>{{ fmt(modalTargetCustomItem.createdAt) }}</strong></div>
</div>
<div class="customItemModal__actions">
<a class="btn btn--ghost" :href="toApiUrl(modalTargetCustomItem.src)" :download="modalTargetCustomItem.label">이미지 다운로드</a>
<button class="btn btn--ghost" :disabled="!customItemModalTargetGameId || modalTargetCustomItem.isPromoting" @click="promoteCustomItem(modalTargetCustomItem)">
{{ modalTargetCustomItem.isPromoting ? '추가중...' : '기본 템플릿에 추가' }}
</button>
<button class="btn btn--danger" :disabled="modalTargetCustomItem.usageCount > 0" @click="openCustomItemDeleteModal(modalTargetCustomItem)">삭제</button>
</div>
</div>
</div>
</div>
</div>
<div v-if="customItemDeleteModalOpen" class="modalOverlay" @click.self="closeCustomItemDeleteModal">
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title">커스텀 아이템 삭제</div>
<div class="modalCard__desc">{{ modalTargetCustomItem ? '"' + modalTargetCustomItem.label + '" 이미지를 삭제할까요? 미사용 상태의 이미지에만 삭제를 허용합니다.' : '' }}</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeCustomItemDeleteModal">취소</button>
<button class="btn btn--danger" @click="removeCustomItem()">삭제</button>
</div>
</div>
</div>
<div v-if="previewModalOpen" class="modalOverlay" @click.self="closePreviewModal">
<div class="modalCard modalCard--preview" role="dialog" aria-modal="true">
<div class="modalCard__titleRow">
@@ -2435,22 +2495,28 @@ async function saveFeaturedOrder() {
text-align: center;
}
.customItemGrid {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 280px));
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
gap: 12px;
justify-content: start;
}
.customItemCard {
appearance: none;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 18px;
background: rgba(255, 255, 255, 0.04);
overflow: hidden;
display: grid;
grid-template-rows: auto 1fr;
gap: 14px;
padding: 14px;
gap: 10px;
padding: 10px;
min-width: 0;
text-align: left;
cursor: pointer;
transition: border-color 0.18s ease, transform 0.18s ease, background 0.18s ease;
}
.customItemCard:hover {
border-color: rgba(126, 162, 255, 0.42);
background: rgba(255, 255, 255, 0.06);
transform: translateY(-1px);
}
.customItemCard__image {
width: 100%;
@@ -2460,51 +2526,88 @@ async function saveFeaturedOrder() {
border-radius: 14px;
background: rgba(0, 0, 0, 0.18);
}
.customItemCard__body {
display: grid;
gap: 12px;
min-width: 0;
align-content: start;
}
.customItemCard__actions {
display: grid;
gap: 8px;
margin-top: auto;
}
.customItemCard__actions > * {
width: 100%;
}
.customItemCard__title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-word;
font-weight: 900;
line-height: 1.35;
white-space: nowrap;
font-weight: 800;
font-size: 13px;
line-height: 1.3;
color: #ffffff;
}
.customItemCard__metaList {
.customItemModal {
display: grid;
grid-template-columns: minmax(200px, 240px) minmax(0, 1fr);
gap: 18px;
align-items: start;
}
.customItemModal__side {
display: grid;
gap: 12px;
min-width: 0;
}
.customItemModal__image {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: 18px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.customItemModal__body {
display: grid;
gap: 14px;
min-width: 0;
}
.customItemModal__selector,
.customItemModal__linked {
display: grid;
gap: 8px;
}
.customItemCard__metaRow {
.customItemModal__label {
font-size: 11px;
color: rgba(255, 255, 255, 0.52);
}
.customItemModal__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.customItemModal__title {
font-size: 19px;
font-weight: 900;
line-height: 1.35;
word-break: break-word;
}
.customItemModal__metaList {
display: grid;
gap: 3px;
gap: 10px;
}
.customItemModal__metaRow {
display: grid;
gap: 4px;
min-width: 0;
}
.customItemCard__metaRow span {
.customItemModal__metaRow span {
font-size: 11px;
color: rgba(255, 255, 255, 0.46);
}
.customItemCard__metaRow strong {
.customItemModal__metaRow strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
color: rgba(255, 255, 255, 0.82);
color: rgba(255, 255, 255, 0.84);
}
.customItemModal__actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.modalCard--customItem {
width: min(760px, 100%);
}
.pager {
margin-top: 16px;
@@ -2791,6 +2894,14 @@ async function saveFeaturedOrder() {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.templateRequestField {
display: grid;
gap: 6px;
}
.templateRequestField__label {
font-size: 11px;
color: rgba(255, 255, 255, 0.52);
}
.templateRequestCard__actions {
display: flex;
gap: 10px;
@@ -2840,6 +2951,15 @@ async function saveFeaturedOrder() {
font-size: 18px;
font-weight: 900;
}
.tierAdminCard__desc {
margin-top: 6px;
color: rgba(255, 255, 255, 0.74);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.tierAdminCard__meta {
margin-top: 4px;
opacity: 0.74;
@@ -2991,6 +3111,9 @@ async function saveFeaturedOrder() {
.adminHero__stats {
grid-template-columns: 1fr;
}
.customItemModal {
grid-template-columns: 1fr;
}
.adminSidebar {
display: none;
}
@@ -3034,19 +3157,22 @@ async function saveFeaturedOrder() {
font-size: 24px;
}
.thumbGrid,
.customItemGrid,
.userList {
grid-template-columns: 1fr;
}
.customItemGrid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
.tierAdminCard__head {
display: grid;
}
.customItemCard {
align-items: stretch;
padding: 10px;
}
.customItemCard__image {
width: clamp(72px, 28vw, 120px);
flex-basis: 120px;
width: 100%;
aspect-ratio: 1 / 1;
}
}
</style>

View File

@@ -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));

View File

@@ -43,6 +43,8 @@ const isExporting = ref(false)
const isSaveModalOpen = ref(false)
const isTemplateRequestModalOpen = ref(false)
const isTemplateUpdateModalOpen = ref(false)
const templateRequestDraftTitle = ref('')
const templateRequestDraftDescription = ref('')
const isDeleteModalOpen = ref(false)
const ownerId = ref('')
const authorName = ref('')
@@ -112,7 +114,8 @@ const templateRequestChecks = computed(() => [
passed: !!(title.value || '').trim() && (title.value || '').trim() !== (gameName.value || '').trim(),
},
])
const canSubmitTemplateCreateRequest = computed(() => templateRequestChecks.value.every((item) => item.passed))
const canSubmitTemplateCreateRequest = computed(() => templateRequestChecks.value.every((item) => item.passed) && !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
const canSubmitTemplateUpdateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
const templateRequestTargetLabel = computed(() => (gameId.value === 'freeform' ? '새로운 템플릿' : (gameName.value || gameId.value || '선택한 게임')))
watch(error, (message) => {
@@ -510,20 +513,29 @@ function closeSaveModal() {
isSaveModalOpen.value = false
}
function resetTemplateRequestDrafts() {
templateRequestDraftTitle.value = ''
templateRequestDraftDescription.value = ''
}
function openTemplateRequestModal() {
resetTemplateRequestDrafts()
isTemplateRequestModalOpen.value = true
}
function closeTemplateRequestModal() {
isTemplateRequestModalOpen.value = false
resetTemplateRequestDrafts()
}
function openTemplateUpdateModal() {
resetTemplateRequestDrafts()
isTemplateUpdateModalOpen.value = true
}
function closeTemplateUpdateModal() {
isTemplateUpdateModalOpen.value = false
resetTemplateRequestDrafts()
}
function openDeleteModal() {
@@ -574,7 +586,11 @@ async function requestTemplate(type) {
try {
isRequestingTemplate.value = true
const persisted = await persistTierList({ showModal: false })
await api.requestTierListTemplate(persisted.savedTierListId, { type })
await api.requestTierListTemplate(persisted.savedTierListId, {
type,
requestTitle: templateRequestDraftTitle.value.trim(),
requestDescription: templateRequestDraftDescription.value.trim(),
})
if (type === 'create') closeTemplateRequestModal()
if (type === 'update') closeTemplateUpdateModal()
toast.success(type === 'create' ? '템플릿 등록 요청을 보냈어요.' : '템플릿 업데이트 요청을 보냈어요.')
@@ -722,7 +738,18 @@ onUnmounted(() => {
</div>
</div>
<div class="requestChecklist__hint">
제목 명확하게 적어두면 관리자가 어떤 게임 템플릿 요청인지 빠르게 파악할 있어요. 여러 사용자가 비슷한 주제로 요청할 있으니 게임 이름을 구체적으로 적어주세요.
제목 설명을 함께 적어두면 관리자가 어떤 신규 템플릿인지 훨씬 빠르게 파악할 있어요.
예시: 제목 `템플릿 등록 요청`, 설명 `여름 이벤트 한정 캐릭터 중심으로 새 게임 템플릿이 필요합니다.`
</div>
<div class="templateRequestDraft">
<label class="templateRequestDraft__field">
<span class="templateRequestDraft__label">요청 제목</span>
<input v-model="templateRequestDraftTitle" class="input" maxlength="80" placeholder="예: 템플릿 등록 요청" />
</label>
<label class="templateRequestDraft__field">
<span class="templateRequestDraft__label">요청 설명</span>
<textarea v-model="templateRequestDraftDescription" class="textarea templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가용으로 신규 템플릿이 필요합니다." />
</label>
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeTemplateRequestModal">취소</button>
@@ -741,10 +768,21 @@ onUnmounted(() => {
</div>
<div class="modalCard__note">
모두가 사용하는 기본 템플릿이니 개인적인 항목이 아닌 공통된 항목만 추가한 신청해주세요.
예시: 제목 `템플릿 업데이트 요청`, 설명 `여름 이벤트 한정 캐릭터 추가`
</div>
<div class="templateRequestDraft">
<label class="templateRequestDraft__field">
<span class="templateRequestDraft__label">요청 제목</span>
<input v-model="templateRequestDraftTitle" class="input" maxlength="80" placeholder="예: 템플릿 업데이트 요청" />
</label>
<label class="templateRequestDraft__field">
<span class="templateRequestDraft__label">요청 설명</span>
<textarea v-model="templateRequestDraftDescription" class="textarea templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가" />
</label>
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeTemplateUpdateModal">요청 취소</button>
<button class="btn btn--save" :disabled="isRequestingTemplate" @click="requestTemplate('update')">
<button class="btn btn--save" :disabled="!canSubmitTemplateUpdateRequest || isRequestingTemplate" @click="requestTemplate('update')">
{{ isRequestingTemplate ? '요청중...' : ', 요청할게요' }}
</button>
</div>
@@ -1253,6 +1291,23 @@ onUnmounted(() => {
font-size: 13px;
line-height: 1.6;
opacity: 0.78;
white-space: pre-line;
}
.templateRequestDraft {
display: grid;
gap: 12px;
}
.templateRequestDraft__field {
display: grid;
gap: 6px;
}
.templateRequestDraft__label {
font-size: 12px;
color: rgba(255, 255, 255, 0.64);
}
.templateRequestDraft__textarea {
min-height: 92px;
resize: vertical;
}
.boardTools {
display: flex;