Compare commits

...

5 Commits

9 changed files with 370 additions and 67 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) {
@@ -1295,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,
@@ -1329,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.64
- 메인 콘텐츠가 길어질 때 스크롤 끝이 화면 바닥에 붙지 않도록 중앙 워크스페이스 하단 여백을 추가하고, 긴 작업 화면에서도 마감선이 답답하지 않게 보정함.
- 템플릿 요청 모달 입력창을 Settings 화면과 같은 어두운 언더라인 입력 문법으로 통일하고, 에디터의 공개/이름 표시 옵션은 체크박스 대신 스위치형 토글로 재구성함.
## 2026-03-31 v1.2.63
- 앱 셸과 워크스페이스에 걸려 있던 고정 `100dvh` 높이를 풀어, 본문이 길어질 때 중앙 `main` 영역이 잘리거나 접히는 현상을 보정함.
- 좌우 레일은 그대로 화면 기준 높이를 유지하되, 중앙 작업 영역은 내용만큼 자연스럽게 늘어나도록 높이 계산을 다시 정리함.
## 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와 게임 이름 라벨을 추가해 구분을 명확히 함.

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,10 @@ function submitGlobalSearch() {
<style scoped>
.appShell {
min-height: 100vh;
min-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 +429,7 @@ function submitGlobalSearch() {
.leftRail,
.rightRail {
min-height: 100vh;
min-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 +495,7 @@ function submitGlobalSearch() {
}
.leftRail__body {
max-height: calc(100vh - 56px);
max-height: calc(100dvh - 56px);
}
.rightRail__body {
@@ -785,7 +788,11 @@ function submitGlobalSearch() {
.appMain {
min-width: 0;
min-height: 0;
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 +801,9 @@ function submitGlobalSearch() {
.workspace {
display: grid;
grid-template-rows: 56px minmax(0, 1fr);
gap: 0;
min-height: 100vh;
min-height: 100dvh;
}
.workspace--localRail {
@@ -836,23 +844,23 @@ function submitGlobalSearch() {
}
.workspaceBody {
min-height: calc(100vh - 56px);
padding: 0;
min-height: 0;
padding: 18px 18px 32px;
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 32px;
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 +986,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 +1004,12 @@ function submitGlobalSearch() {
@media (max-width: 860px) {
.appShell {
grid-template-columns: 1fr;
min-height: 100dvh;
}
.leftRail {
min-height: auto;
height: auto;
border-right: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
@@ -1013,6 +1023,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

@@ -1421,14 +1421,14 @@ async function saveFeaturedOrder() {
</div>
<div v-if="request.type === 'create'" class="templateRequestCard__form">
<label class="templateRequestField">
<span class="templateRequestField__label">게임 ID</span>
<input v-model="request.draftGameId" class="input" placeholder="새 게임 ID" />
</label>
<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">
@@ -1461,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>
@@ -2950,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;

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="templateRequestDraft__input" maxlength="80" placeholder="예: 템플릿 등록 요청" />
</label>
<label class="templateRequestDraft__field">
<span class="templateRequestDraft__label">요청 설명</span>
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input 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="templateRequestDraft__input" maxlength="80" placeholder="예: 템플릿 업데이트 요청" />
</label>
<label class="templateRequestDraft__field">
<span class="templateRequestDraft__label">요청 설명</span>
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input 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>
@@ -952,13 +990,15 @@ onUnmounted(() => {
</div>
<div class="editorSidebar__section editorSidebar__section--footer">
<label class="toggle" :class="{ 'toggle--disabled': !canEdit }">
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': !canEdit }">
<input v-model="isPublic" type="checkbox" :disabled="!canEdit" />
<span>공개</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
<span class="toggleSwitch__label">공개</span>
</label>
<label class="toggle" :class="{ 'toggle--disabled': !canEdit }">
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': !canEdit }">
<input v-model="showCharacterNames" type="checkbox" :disabled="!canEdit" />
<span>캐릭터 이름 표시</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
<span class="toggleSwitch__label">캐릭터 이름 표시</span>
</label>
<div class="editorSidebar__actionGrid">
<button class="btn btn--ghost editorSidebar__button" @click="downloadImage">이미지 다운로드</button>
@@ -1094,23 +1134,55 @@ onUnmounted(() => {
display: inline-flex;
position: relative;
}
.toggle {
.toggleSwitch {
display: inline-flex;
gap: 8px;
align-items: center;
padding: 8px 10px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.12);
font-weight: 800;
gap: 12px;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.03);
cursor: pointer;
user-select: none;
}
.toggle input {
width: 16px;
height: 16px;
.toggleSwitch input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.toggle--disabled {
.toggleSwitch__track {
position: relative;
width: 42px;
height: 24px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
border: 1px solid rgba(255, 255, 255, 0.12);
transition: background 180ms ease, border-color 180ms ease;
flex: 0 0 auto;
}
.toggleSwitch__thumb {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.24);
transition: transform 180ms ease;
}
.toggleSwitch__label {
font-weight: 800;
color: rgba(255, 255, 255, 0.9);
}
.toggleSwitch input:checked + .toggleSwitch__track {
background: rgba(96, 165, 250, 0.34);
border-color: rgba(96, 165, 250, 0.42);
}
.toggleSwitch input:checked + .toggleSwitch__track .toggleSwitch__thumb {
transform: translateX(18px);
}
.toggleSwitch--disabled {
opacity: 0.55;
pointer-events: none;
}
@@ -1253,6 +1325,42 @@ 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__input {
width: 100%;
padding: 14px 0;
border: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
background: transparent;
color: rgba(255, 255, 255, 0.94);
outline: none;
font-size: 18px;
line-height: 1.5;
letter-spacing: -0.02em;
resize: none;
}
.templateRequestDraft__input:focus {
border-bottom-color: rgba(96, 165, 250, 0.9);
}
.templateRequestDraft__input::placeholder {
color: rgba(255, 255, 255, 0.34);
}
.templateRequestDraft__textarea {
min-height: 92px;
resize: vertical;
}
.boardTools {
display: flex;