Compare commits

...

4 Commits

7 changed files with 293 additions and 58 deletions

View File

@@ -252,6 +252,18 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ) 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(` await query(`
CREATE TABLE IF NOT EXISTS template_requests ( CREATE TABLE IF NOT EXISTS template_requests (
id VARCHAR(64) PRIMARY KEY, id VARCHAR(64) PRIMARY KEY,
@@ -431,7 +443,7 @@ async function adminDeleteUser(id) {
await query('DELETE FROM users WHERE id = ?', [id]) await query('DELETE FROM users WHERE id = ?', [id])
} }
async function listGames() { async function listGames(currentUserId = '') {
const rows = await query( const rows = await query(
` `
SELECT id, name, thumbnail_src, display_rank, created_at SELECT id, name, thumbnail_src, display_rank, created_at
@@ -445,7 +457,15 @@ async function listGames() {
`, `,
[FREEFORM_GAME_ID] [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) { 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]) 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 = { module.exports = {
DB_NAME, DB_NAME,
ensureData, ensureData,
@@ -1329,6 +1357,8 @@ module.exports = {
findTierListById, findTierListById,
favoriteTierList, favoriteTierList,
unfavoriteTierList, unfavoriteTierList,
favoriteGame,
unfavoriteGame,
deleteTierList, deleteTierList,
findCustomItemsByIds, findCustomItemsByIds,
deleteCustomItems, deleteCustomItems,

View File

@@ -1,13 +1,32 @@
const express = require('express') 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() const router = express.Router()
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
const games = await listGames() const games = await listGames(req.session?.userId || '')
res.json({ games }) 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) => { router.get('/:gameId', async (req, res) => {
const detail = await getGameDetail(req.params.gameId) const detail = await getGameDetail(req.params.gameId)
if (!detail) return res.status(404).json({ error: 'not_found' }) if (!detail) return res.status(404).json({ error: 'not_found' })

View File

@@ -1,5 +1,22 @@
# 업데이트 로그 # 업데이트 로그
## 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.60
- 관리자 티어표 관리 카드에서 사용자가 입력한 설명을 제목 아래에 함께 노출해 요청 의도를 더 빨리 파악할 수 있게 함. - 관리자 티어표 관리 카드에서 사용자가 입력한 설명을 제목 아래에 함께 노출해 요청 의도를 더 빨리 파악할 수 있게 함.
- 템플릿 등록/업데이트 요청은 이제 에디터 모달에서 제목과 설명을 별도로 입력받고, 예시 문구와 함께 전송하도록 정리함. - 템플릿 등록/업데이트 요청은 이제 에디터 모달에서 제목과 설명을 별도로 입력받고, 예시 문구와 함께 전송하도록 정리함.

View File

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

View File

@@ -32,6 +32,8 @@ export const api = {
listGames: () => request('/api/games'), listGames: () => request('/api/games'),
getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`), 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 }), updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }),
updateAdminGameItem: (gameId, itemId, payload) => updateAdminGameItem: (gameId, itemId, payload) =>
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }), request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),

View File

@@ -1,28 +1,71 @@
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api' import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime' import { toApiUrl } from '../lib/runtime'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
const router = useRouter() const router = useRouter()
const auth = useAuthStore()
const items = ref([]) const items = ref([])
const error = 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 { try {
const data = await api.listGames() const data = await api.listGames()
items.value = data.games || [] items.value = data.games || []
} catch (e) { } catch (e) {
error.value = '백엔드에 연결할 수 없어요. backend 서버가 실행 중인지 확인해주세요.' error.value = '백엔드에 연결할 수 없어요. backend 서버가 실행 중인지 확인해주세요.'
} }
}) }
onMounted(loadGames)
watch(() => auth.user?.id, loadGames)
function goGame(gameId) { function goGame(gameId) {
router.push(`/games/${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) { function thumbUrl(g) {
return g.thumbnailSrc ? toApiUrl(g.thumbnailSrc) : '' return g.thumbnailSrc ? toApiUrl(g.thumbnailSrc) : ''
} }
@@ -34,22 +77,35 @@ function thumbUrl(g) {
<div class="pageHead__eyebrow">Workspace</div> <div class="pageHead__eyebrow">Workspace</div>
<h1 class="pageHead__title">Game Library</h1> <h1 class="pageHead__title">Game Library</h1>
<p class="pageHead__desc">자주 쓰는 게임 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 있어요.</p> <p class="pageHead__desc">자주 쓰는 게임 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 있어요.</p>
<p v-if="query" class="pageHead__searchState">"{{ query }}" 맞는 게임 템플릿만 보고 있어요.</p>
</div> </div>
</section> </section>
<div v-if="error" class="error">{{ error }}</div> <div v-if="error" class="error">{{ error }}</div>
<section class="libraryGrid"> <section v-if="games.length" class="libraryGrid">
<button v-for="g in games" :key="g.id" class="libraryCard" @click="goGame(g.id)"> <article v-for="g in games" :key="g.id" class="libraryCard">
<div class="libraryCard__thumbWrap"> <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" /> <img v-if="thumbUrl(g)" class="libraryCard__thumb" :src="thumbUrl(g)" :alt="g.name" />
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div> <div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
</div> </div>
<div class="libraryCard__body"> <div class="libraryCard__body">
<div class="libraryCard__title">{{ g.name }}</div> <div class="libraryCard__title">{{ g.name }}</div>
<div class="libraryCard__meta">{{ g.id }}</div> <div class="libraryCard__meta">{{ g.id }}</div>
</div> </div>
</button> </button>
</article>
</section> </section>
<div v-else class="libraryEmpty">{{ query ? '검색어에 맞는 게임 템플릿이 없어요.' : '표시할 게임 템플릿이 없어요.' }}</div>
</template> </template>
<style scoped> <style scoped>
@@ -66,7 +122,12 @@ function thumbUrl(g) {
background: rgba(239, 68, 68, 0.1); background: rgba(239, 68, 68, 0.1);
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.92);
} }
.pageHead__searchState {
margin-top: 8px;
color: rgba(255, 255, 255, 0.62);
}
.libraryCard { .libraryCard {
position: relative;
text-align: left; text-align: left;
padding: 14px; padding: 14px;
border-radius: 22px; border-radius: 22px;
@@ -77,14 +138,40 @@ function thumbUrl(g) {
display: grid; display: grid;
gap: 12px; gap: 12px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
transition: transition: transform 0.16s ease, background 0.16s ease;
transform 0.16s ease,
background 0.16s ease;
} }
.libraryCard:hover { .libraryCard:hover {
background: rgba(70, 70, 70, 0.96); background: rgba(70, 70, 70, 0.96);
transform: translateY(-2px); 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 { .libraryCard__thumbWrap {
width: 100%; width: 100%;
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
@@ -119,6 +206,10 @@ function thumbUrl(g) {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.libraryEmpty {
padding: 20px 0;
color: rgba(255, 255, 255, 0.62);
}
@media (max-width: 1400px) { @media (max-width: 1400px) {
.libraryGrid { .libraryGrid {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));

View File

@@ -744,11 +744,11 @@ onUnmounted(() => {
<div class="templateRequestDraft"> <div class="templateRequestDraft">
<label class="templateRequestDraft__field"> <label class="templateRequestDraft__field">
<span class="templateRequestDraft__label">요청 제목</span> <span class="templateRequestDraft__label">요청 제목</span>
<input v-model="templateRequestDraftTitle" class="input" maxlength="80" placeholder="예: 템플릿 등록 요청" /> <input v-model="templateRequestDraftTitle" class="templateRequestDraft__input" maxlength="80" placeholder="예: 템플릿 등록 요청" />
</label> </label>
<label class="templateRequestDraft__field"> <label class="templateRequestDraft__field">
<span class="templateRequestDraft__label">요청 설명</span> <span class="templateRequestDraft__label">요청 설명</span>
<textarea v-model="templateRequestDraftDescription" class="textarea templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가용으로 신규 템플릿이 필요합니다." /> <textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가용으로 신규 템플릿이 필요합니다." />
</label> </label>
</div> </div>
<div class="modalCard__actions"> <div class="modalCard__actions">
@@ -773,11 +773,11 @@ onUnmounted(() => {
<div class="templateRequestDraft"> <div class="templateRequestDraft">
<label class="templateRequestDraft__field"> <label class="templateRequestDraft__field">
<span class="templateRequestDraft__label">요청 제목</span> <span class="templateRequestDraft__label">요청 제목</span>
<input v-model="templateRequestDraftTitle" class="input" maxlength="80" placeholder="예: 템플릿 업데이트 요청" /> <input v-model="templateRequestDraftTitle" class="templateRequestDraft__input" maxlength="80" placeholder="예: 템플릿 업데이트 요청" />
</label> </label>
<label class="templateRequestDraft__field"> <label class="templateRequestDraft__field">
<span class="templateRequestDraft__label">요청 설명</span> <span class="templateRequestDraft__label">요청 설명</span>
<textarea v-model="templateRequestDraftDescription" class="textarea templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가" /> <textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가" />
</label> </label>
</div> </div>
<div class="modalCard__actions"> <div class="modalCard__actions">
@@ -990,13 +990,15 @@ onUnmounted(() => {
</div> </div>
<div class="editorSidebar__section editorSidebar__section--footer"> <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" /> <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>
<label class="toggle" :class="{ 'toggle--disabled': !canEdit }"> <label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': !canEdit }">
<input v-model="showCharacterNames" type="checkbox" :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> </label>
<div class="editorSidebar__actionGrid"> <div class="editorSidebar__actionGrid">
<button class="btn btn--ghost editorSidebar__button" @click="downloadImage">이미지 다운로드</button> <button class="btn btn--ghost editorSidebar__button" @click="downloadImage">이미지 다운로드</button>
@@ -1132,23 +1134,55 @@ onUnmounted(() => {
display: inline-flex; display: inline-flex;
position: relative; position: relative;
} }
.toggle { .toggleSwitch {
display: inline-flex; display: inline-flex;
gap: 8px;
align-items: center; align-items: center;
padding: 8px 10px; gap: 12px;
border-radius: 12px; padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 14px;
background: rgba(0, 0, 0, 0.12); border: 1px solid rgba(255, 255, 255, 0.1);
font-weight: 800; background: rgba(255, 255, 255, 0.03);
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
} }
.toggle input { .toggleSwitch input {
width: 16px; position: absolute;
height: 16px; 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; opacity: 0.55;
pointer-events: none; pointer-events: none;
} }
@@ -1305,6 +1339,25 @@ onUnmounted(() => {
font-size: 12px; font-size: 12px;
color: rgba(255, 255, 255, 0.64); 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 { .templateRequestDraft__textarea {
min-height: 92px; min-height: 92px;
resize: vertical; resize: vertical;