Compare commits

...

1 Commits

11 changed files with 268 additions and 230 deletions

View File

@@ -6,6 +6,7 @@ const DB_USER = process.env.DB_USER || 'root'
const DB_PASSWORD = process.env.DB_PASSWORD || ''
const DB_NAME = process.env.DB_NAME || 'tier_cursor'
const DB_CONNECTION_LIMIT = process.env.DB_CONNECTION_LIMIT ? Number(process.env.DB_CONNECTION_LIMIT) : 10
const FREEFORM_GAME_ID = 'freeform'
let poolPromise = null
let initPromise = null
@@ -182,16 +183,17 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await query(`
CREATE TABLE IF NOT EXISTS game_suggestions (
id VARCHAR(64) PRIMARY KEY,
name VARCHAR(120) NOT NULL,
created_at BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await query(
`
INSERT INTO games (id, name, thumbnail_src, created_at)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name)
`,
[FREEFORM_GAME_ID, '직접 티어표 만들기', '', now()]
)
const countRows = await query('SELECT COUNT(*) AS count FROM games')
if (Number(countRows[0]?.count || 0) === 0) {
if (Number(countRows[0]?.count || 0) <= 1) {
const createdAt = now()
await query(
`
@@ -278,7 +280,10 @@ async function updateUserProfile({ id, nickname, avatarSrc }) {
}
async function listGames() {
const rows = await query('SELECT id, name, thumbnail_src, created_at FROM games ORDER BY created_at ASC, name ASC')
const rows = await query(
'SELECT id, name, thumbnail_src, created_at FROM games WHERE id <> ? ORDER BY created_at ASC, name ASC',
[FREEFORM_GAME_ID]
)
return rows.map(mapGameRow)
}
@@ -373,10 +378,33 @@ async function createCustomItem({ id, ownerId, src, label }) {
return { id, ownerId, src, label, origin: 'custom', createdAt }
}
async function createGameSuggestion({ id, name }) {
const createdAt = now()
await query('INSERT INTO game_suggestions (id, name, created_at) VALUES (?, ?, ?)', [id, name, createdAt])
return { id, name, createdAt }
async function listCustomItems() {
const rows = await query(
`
SELECT
c.id,
c.owner_id,
c.src,
c.label,
c.created_at,
u.nickname,
u.email
FROM custom_items c
INNER JOIN users u ON u.id = c.owner_id
ORDER BY c.created_at DESC
LIMIT 200
`
)
return rows.map((row) => ({
id: row.id,
ownerId: row.owner_id,
src: row.src,
label: row.label,
createdAt: Number(row.created_at),
ownerName: row.nickname || row.email,
ownerEmail: row.email,
}))
}
async function listPublicTierLists(gameId) {
@@ -498,7 +526,7 @@ module.exports = {
deleteGameItem,
deleteGame,
createCustomItem,
createGameSuggestion,
listCustomItems,
listPublicTierLists,
listUserTierLists,
findTierListById,

View File

@@ -10,6 +10,7 @@ const {
createGameItem,
deleteGameItem,
deleteGame,
listCustomItems,
} = require('../db')
const { requireAdmin } = require('../middleware/auth')
@@ -77,4 +78,9 @@ router.delete('/games/:gameId', requireAdmin, async (req, res) => {
res.json({ ok: true })
})
router.get('/custom-items', requireAdmin, async (req, res) => {
const items = await listCustomItems()
res.json({ items })
})
module.exports = router

View File

@@ -1,7 +1,5 @@
const express = require('express')
const { z } = require('zod')
const { nanoid } = require('nanoid')
const { listGames, getGameDetail, createGameSuggestion } = require('../db')
const { listGames, getGameDetail } = require('../db')
const router = express.Router()
@@ -16,16 +14,4 @@ router.get('/:gameId', async (req, res) => {
res.json({ game: detail.game, items: detail.items })
})
router.post('/suggest', async (req, res) => {
const schema = z.object({ name: z.string().min(1).max(60) })
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const suggestion = await createGameSuggestion({
id: nanoid(),
name: parsed.data.name,
})
res.json({ suggestion })
})
module.exports = router

View File

@@ -45,3 +45,8 @@
- 관리자 업로드 작업은 "파일 선택 후 적용"이 더 정확하므로, 썸네일 버튼 문구와 활성화 조건을 그 흐름에 맞추기로 결정했다.
- 작은 화면에서 미리보기가 실제 작업 영역을 압박하지 않도록, 아이템 미리보기는 정사각형을 유지하되 최대 크기를 제한하는 방향을 채택했다.
- 파일 입력은 업로드 성공 후와 게임 전환 시 초기화해 같은 파일 재선택이 막히지 않도록 정리했다.
## 2026-03-19 v0.1.11
- 관리자 화면은 좌우 여백이 크게 남는 구조보다, 상단 2열 작업 카드와 하단 목록 영역으로 나누는 편이 더 안정적이라고 판단해 레이아웃을 재정리했다.
- 게임 목록에 없는 주제로도 바로 작업할 수 있도록, 시스템 전용 `freeform` 게임을 내부적으로 유지하고 홈 화면에서는 `직접 티어표 만들기` 카드로 노출하기로 결정했다.
- 게임 제안은 현재 운영 흐름과 맞지 않아 사용자 진입점과 프런트 API에서 제거하고, 대신 관리자에게는 사용자 커스텀 아이템 검토 기능을 제공하기로 했다.

View File

@@ -2,8 +2,8 @@
## `/`
- 화면 파일: `frontend/src/views/HomeView.vue`
- 역할: 게임 목록 표시, 게임 카드 클릭 이동, 새 게임 제안 모달
- 연동 API: `GET /api/games`, `POST /api/games/suggest`
- 역할: 게임 목록 표시, 게임 카드 클릭 이동, `직접 티어표 만들기` 진입
- 연동 API: `GET /api/games`
## `/games/:gameId`
- 화면 파일: `frontend/src/views/GameHubView.vue`
@@ -27,8 +27,8 @@
## `/admin`
- 화면 파일: `frontend/src/views/AdminView.vue`
- 역할: 작업 모드 선택, 기존 게임 선택 또는 새 게임 생성, 선택된 게임의 썸네일/아이템 관리, 파일 선택 즉시 미리보기, 파일 입력 초기화, 아이템 삭제, 게임 삭제
- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId`
- 역할: 작업 모드 선택, 기존 게임 선택 또는 새 게임 생성, 선택된 게임의 썸네일/아이템 관리, 파일 선택 즉시 미리보기, 사용자 커스텀 아이템 검토/다운로드, 파일 입력 초기화, 아이템 삭제, 게임 삭제
- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `GET /api/admin/custom-items`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId`
## `/profile`
- 화면 파일: `frontend/src/views/ProfileView.vue`

View File

@@ -30,6 +30,7 @@
- `name`: string
- `thumbnailSrc`: string
- `createdAt`: number
- 시스템 전용 `freeform` 레코드는 홈 화면의 `직접 티어표 만들기` 저장 대상이며 일반 게임 목록에서는 숨긴다.
- `gameItems`
- `id`: string
- `gameId`: string
@@ -69,7 +70,6 @@
- 게임
- `GET /api/games`
- `GET /api/games/:gameId`
- `POST /api/games/suggest`
- 티어표
- `GET /api/tierlists/public`
- `GET /api/tierlists/me`
@@ -80,6 +80,7 @@
- `POST /api/admin/games`
- `POST /api/admin/games/:gameId/thumbnail`
- `POST /api/admin/games/:gameId/images`
- `GET /api/admin/custom-items`
- `DELETE /api/admin/games/:gameId/items/:itemId`
- `DELETE /api/admin/games/:gameId`
@@ -88,6 +89,7 @@
- 아이템 추가는 이름 입력, 파일 선택, 1:1 미리보기 확인 뒤 저장하는 흐름이다.
- 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다.
- 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다.
- 사용자 업로드 커스텀 아이템은 관리자 화면 하단 검토 영역에서 목록/다운로드할 수 있다.
## 운영 환경 변수
- 프런트엔드

View File

@@ -1,7 +1,7 @@
# 할 일 및 이슈
## 즉시 확인 필요
- 관리자 화면에 게임 제안(`gameSuggestions`) 조회/처리 UI가 아직 없다.
- 사용자 커스텀 아이템을 관리자 기본 템플릿으로 승격하는 승인/복제 흐름은 아직 없다.
## 배포 전 작업
- NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다.
@@ -13,4 +13,4 @@
## 중기 개선
- 게임/이미지/티어표 삭제 후 복구 또는 수정 이력 관리 기능을 추가한다.
- 자동 테스트와 최소한의 배포 체크리스트를 만든다.
- 관리자용 게임 제안 승인/반려, 아이템 정렬 UI를 추가한다.
- 관리자용 커스텀 아이템 승인/복제, 아이템 정렬 UI를 추가한다.

View File

@@ -73,3 +73,9 @@
- **아이템 추가 폼 정리**: 아이템 이름 입력 너비를 줄이고, 과한 미리보기 안내 문구를 제거해 작업 집중도를 높임
- **반응형 미리보기 보정**: 태블릿 이하 화면에서도 아이템 1:1 미리보기가 최대 `192px` 범위 안에서 보이도록 조정
- **파일 재선택 버그 수정**: 아이템 추가나 게임 전환 뒤 파일 입력 값을 초기화해 같은 이미지를 다시 선택해도 정상 인식되도록 수정
## 2026-03-19 v0.1.11
- **관리자 레이아웃 재구성**: 인라인 스타일을 제거하고, 썸네일 적용과 아이템 추가를 상단 2열 카드로 재배치한 뒤 아이템 목록은 하단 리스트로 분리
- **직접 티어표 만들기 추가**: 홈 화면에 게임 카드와 동일한 형태의 `직접 티어표 만들기` 진입점을 추가하고, 내부 전용 `freeform` 게임 레코드로 1회성 빈 티어표 저장 흐름을 지원
- **게임 제안 흐름 제거**: 홈 화면의 `새로운 게임 제안` 버튼/모달과 관련 프런트 API를 제거해 현재 운영 흐름에 맞게 단순화
- **커스텀 아이템 검토 영역 추가**: 관리자 페이지에서 사용자 업로드 커스텀 아이템을 목록으로 보고 다운로드할 수 있는 검토 영역과 조회 API를 추가

View File

@@ -32,7 +32,7 @@ export const api = {
listGames: () => request('/api/games'),
getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`),
suggestGame: (name) => request('/api/games/suggest', { method: 'POST', body: { name } }),
listAdminCustomItems: () => request('/api/admin/custom-items'),
listPublicTierLists: (gameId) =>
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}`),

View File

@@ -8,6 +8,7 @@ const auth = useAuthStore()
const isAdmin = computed(() => !!auth.user?.isAdmin)
const games = ref([])
const customItems = ref([])
const adminMode = ref('existing')
const selectedGameId = ref('')
const selectedGame = ref(null)
@@ -26,9 +27,13 @@ const thumbPreviewUrl = ref('')
const itemFileInput = ref(null)
const thumbFileInput = ref(null)
const hasSelectedGame = computed(() => !!selectedGame.value?.game?.id)
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedGameId.value)
const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value)
onMounted(async () => {
await auth.refresh()
await refreshGames()
await Promise.all([refreshGames(), refreshCustomItems()])
})
onUnmounted(() => {
@@ -36,8 +41,6 @@ onUnmounted(() => {
clearPreviewUrl('thumb')
})
const hasSelectedGame = computed(() => !!selectedGame.value?.game?.id)
async function refreshGames() {
try {
const data = await api.listGames()
@@ -47,11 +50,31 @@ async function refreshGames() {
}
}
async function refreshCustomItems() {
if (!auth.user?.isAdmin) return
try {
const data = await api.listAdminCustomItems()
customItems.value = data.items || []
} catch (e) {
error.value = '사용자 커스텀 아이템을 불러오지 못했어요.'
}
}
function resetMessages() {
error.value = ''
success.value = ''
}
function resetUploadState() {
uploadLabel.value = ''
uploadFile.value = null
thumbFile.value = null
resetFileInput('item')
resetFileInput('thumb')
clearPreviewUrl('item')
clearPreviewUrl('thumb')
}
function setMode(mode) {
resetMessages()
adminMode.value = mode
@@ -59,13 +82,7 @@ function setMode(mode) {
selectedGame.value = null
newGameId.value = ''
newGameName.value = ''
uploadLabel.value = ''
uploadFile.value = null
thumbFile.value = null
resetFileInput('item')
resetFileInput('thumb')
clearPreviewUrl('item')
clearPreviewUrl('thumb')
resetUploadState()
}
function clearPreviewUrl(type) {
@@ -90,13 +107,7 @@ function resetFileInput(type) {
async function loadGame() {
resetMessages()
uploadLabel.value = ''
uploadFile.value = null
thumbFile.value = null
resetFileInput('item')
resetFileInput('thumb')
clearPreviewUrl('item')
clearPreviewUrl('thumb')
resetUploadState()
if (!selectedGameId.value) {
selectedGame.value = null
@@ -195,10 +206,7 @@ async function uploadItem() {
})
if (!res.ok) throw new Error('failed')
uploadLabel.value = ''
uploadFile.value = null
resetFileInput('item')
clearPreviewUrl('item')
resetUploadState()
await loadGame()
success.value = '아이템이 추가됐어요.'
} catch (e) {
@@ -242,13 +250,7 @@ async function removeGame() {
const deletedName = selectedGame.value.game.name
selectedGameId.value = ''
selectedGame.value = null
uploadLabel.value = ''
uploadFile.value = null
thumbFile.value = null
resetFileInput('item')
resetFileInput('thumb')
clearPreviewUrl('item')
clearPreviewUrl('thumb')
resetUploadState()
await refreshGames()
success.value = `${deletedName} 게임을 삭제했어요.`
} catch (e) {
@@ -262,15 +264,22 @@ const displayThumbnailUrl = computed(() => {
return ''
})
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedGameId.value)
const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value)
function fmt(ts) {
return new Date(ts).toLocaleString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
</script>
<template>
<section class="wrap">
<h2 class="title">관리자</h2>
<div class="card">
<div class="desc">먼저 작업 방식을 고르고, 다음 선택한 게임의 정보 집중해서 리합니다.</div>
<div class="desc">작업 종류를 먼저 고르고, 선택한 게임의 관리 화면 집중해서 리합니다.</div>
<div v-if="!auth.user" class="warn">로그인이 필요해요.</div>
<div v-else-if="!isAdmin" class="warn"> 계정은 관리자 권한이 없어요.</div>
@@ -294,7 +303,7 @@ const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value)
<option value="">게임을 선택해주세요</option>
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }} ({{ game.id }})</option>
</select>
<div class="hint">게임을 고르면 아래에서 썸네일, 아이템 목록, 삭제 작업을 관리 있어요.</div>
<div class="hint">게임을 고르면 아래에서 썸네일, 아이템 관리, 삭제 작업을 있어요.</div>
</template>
<template v-else>
@@ -317,43 +326,41 @@ const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value)
</div>
</div>
<div class="section">
<div class="section__title">썸네일</div>
<div class="uploadPreviewCard uploadPreviewCard--wide">
<img
v-if="displayThumbnailUrl"
class="selectedThumb"
:src="displayThumbnailUrl"
:alt="selectedGame.game.name"
/>
<div v-else class="selectedThumb selectedThumb--empty">썸네일 미리보기</div>
<div class="uploadPreviewMeta">
<div class="uploadPreviewTitle">대표 썸네일</div>
<div class="uploadPreviewDesc">
파일을 선택하면 먼저 미리보기로 확인되고, 적용 버튼을 눌렀을 실제 저장됩니다.
</div>
<div class="section section--topGrid">
<section class="adminCard">
<div class="section__title">썸네일 적용</div>
<div class="uploadPreviewCard">
<img
v-if="displayThumbnailUrl"
class="selectedThumb"
:src="displayThumbnailUrl"
:alt="selectedGame.game.name"
/>
<div v-else class="selectedThumb selectedThumb--empty">썸네일 미리보기</div>
</div>
</div>
<input ref="thumbFileInput" type="file" accept="image/*" class="inputFile" @change="onThumb" />
<button class="btn" :disabled="!canApplyThumbnail" @click="uploadThumbnail">썸네일 적용</button>
</div>
<div class="uploadControls">
<input ref="thumbFileInput" type="file" accept="image/*" class="inputFile" @change="onThumb" />
<button class="btn" :disabled="!canApplyThumbnail" @click="uploadThumbnail">썸네일 적용</button>
</div>
</section>
<div class="section">
<div class="section__title">아이템 추가</div>
<div class="itemComposer">
<div class="itemComposer__form">
<input v-model="uploadLabel" class="input input--compact" placeholder="아이템 이름" />
<input ref="itemFileInput" type="file" accept="image/*" class="inputFile" @change="onFile" />
<button class="btn" :disabled="!canAddItem" @click="uploadItem">아이템 추가</button>
</div>
<div class="itemPreviewCard">
<div class="itemPreviewFrame">
<img v-if="itemPreviewUrl" class="itemPreviewImage" :src="itemPreviewUrl" alt="item preview" />
<div v-else class="itemPreviewEmpty">이미지를 선택해주세요</div>
<section class="adminCard">
<div class="section__title">아이템 추가</div>
<div class="itemComposer">
<div class="itemComposer__form">
<input v-model="uploadLabel" class="input input--compact" placeholder="아이템 이름" />
<input ref="itemFileInput" type="file" accept="image/*" class="inputFile inputFile--tight" @change="onFile" />
<button class="btn" :disabled="!canAddItem" @click="uploadItem">아이템 추가</button>
</div>
<div class="itemPreviewCard">
<div class="itemPreviewFrame">
<img v-if="itemPreviewUrl" class="itemPreviewImage" :src="itemPreviewUrl" alt="item preview" />
<div v-else class="itemPreviewEmpty">이미지를 선택해주세요</div>
</div>
<div class="thumbLabel thumbLabel--preview">{{ uploadLabel || '아이템 이름 미리보기' }}</div>
</div>
<div class="thumbLabel thumbLabel--preview">{{ uploadLabel || '아이템 이름 미리보기' }}</div>
</div>
</div>
</section>
</div>
<div class="section">
@@ -368,6 +375,29 @@ const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value)
</div>
</div>
</div>
<div class="panel">
<div class="sectionHeader">
<div>
<div class="panel__title">사용자 커스텀 아이템</div>
<div class="hint hint--tight">사용자가 직접 올린 이미지를 훑어보고, 필요하면 다운로드해 기본 템플릿으로 재구성할 있어요.</div>
</div>
<button class="btn btn--ghost" @click="refreshCustomItems">새로고침</button>
</div>
<div v-if="!customItems.length" class="hint">아직 업로드된 사용자 커스텀 아이템이 없어요.</div>
<div v-else class="customItemGrid">
<article v-for="item in customItems" :key="item.id" class="customItemCard">
<img class="customItemCard__image" :src="toApiUrl(item.src)" :alt="item.label" />
<div class="customItemCard__body">
<div class="customItemCard__title">{{ item.label }}</div>
<div class="customItemCard__meta">업로더: {{ item.ownerName }}</div>
<div class="customItemCard__meta">{{ fmt(item.createdAt) }}</div>
<a class="btn btn--small btn--ghost" :href="toApiUrl(item.src)" :download="item.label">이미지 다운로드</a>
</div>
</article>
</div>
</div>
</template>
</div>
</section>
@@ -448,25 +478,28 @@ const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value)
padding-top: 18px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.section--topGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.adminCard {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.03);
border-radius: 16px;
padding: 14px;
min-width: 0;
}
.uploadPreviewCard {
margin-top: 10px;
display: flex;
justify-content: center;
}
.uploadControls {
margin-top: 14px;
display: grid;
gap: 12px;
align-items: start;
}
.uploadPreviewCard--wide {
grid-template-columns: minmax(256px, 256px) minmax(0, 1fr);
}
.uploadPreviewMeta {
display: grid;
gap: 8px;
}
.uploadPreviewTitle {
font-weight: 900;
}
.uploadPreviewDesc {
opacity: 0.76;
line-height: 1.5;
justify-items: center;
}
.select,
.input {
@@ -481,16 +514,22 @@ const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value)
margin-top: 10px;
}
.input--compact {
max-width: 360px;
max-width: 320px;
}
.hint {
margin-top: 10px;
opacity: 0.78;
font-size: 13px;
}
.hint--tight {
margin-top: 6px;
}
.inputFile {
width: 100%;
margin-top: 12px;
max-width: 360px;
}
.inputFile--tight {
margin-top: 0;
}
.btn {
margin-top: 12px;
@@ -501,6 +540,8 @@ const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value)
color: rgba(255, 255, 255, 0.92);
cursor: pointer;
font-weight: 800;
text-align: center;
text-decoration: none;
}
.btn:disabled {
cursor: not-allowed;
@@ -513,6 +554,9 @@ const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value)
background: rgba(239, 68, 68, 0.14);
border-color: rgba(239, 68, 68, 0.28);
}
.btn--ghost {
background: rgba(255, 255, 255, 0.03);
}
.btn--small {
width: 100%;
}
@@ -537,9 +581,6 @@ const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value)
display: flex;
gap: 8px;
}
.thumbnailRow {
margin-top: 10px;
}
.selectedThumb {
width: min(100%, 256px);
aspect-ratio: 16 / 9;
@@ -561,7 +602,9 @@ const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value)
align-items: start;
}
.itemComposer__form {
min-width: 0;
display: grid;
gap: 12px;
align-items: start;
}
.itemPreviewCard {
padding: 10px;
@@ -621,8 +664,47 @@ const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value)
.thumbLabel--preview {
text-align: center;
}
.sectionHeader {
display: flex;
gap: 12px;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
}
.customItemGrid {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
.customItemCard {
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 16px;
background: rgba(255, 255, 255, 0.04);
overflow: hidden;
}
.customItemCard__image {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
display: block;
background: rgba(0, 0, 0, 0.18);
}
.customItemCard__body {
display: grid;
gap: 6px;
padding: 12px;
}
.customItemCard__title {
font-weight: 900;
}
.customItemCard__meta {
opacity: 0.72;
font-size: 13px;
word-break: break-word;
}
@media (max-width: 980px) {
.uploadPreviewCard--wide {
.section--topGrid {
grid-template-columns: 1fr;
}
.itemComposer {
@@ -636,11 +718,12 @@ const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value)
.selectedThumb {
width: min(100%, 256px);
}
.thumbGrid,
.customItemGrid {
grid-template-columns: 1fr;
}
.itemPreviewCard {
max-width: 192px;
}
.thumbGrid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -9,9 +9,6 @@ const router = useRouter()
const items = ref([])
const error = ref('')
const games = computed(() => items.value)
const suggestOpen = ref(false)
const suggestName = ref('')
const suggestError = ref('')
onMounted(async () => {
try {
@@ -26,26 +23,15 @@ function goGame(gameId) {
router.push(`/games/${gameId}`)
}
function goFreeform() {
router.push('/editor/freeform/new')
}
function thumbUrl(g) {
if (!g.thumbnailSrc) return ''
return toApiUrl(g.thumbnailSrc)
}
async function submitSuggest() {
suggestError.value = ''
const name = (suggestName.value || '').trim()
if (!name) {
suggestError.value = '게임 이름을 입력해주세요.'
return
}
try {
await api.suggestGame(name)
suggestName.value = ''
suggestOpen.value = false
} catch (e) {
suggestError.value = '제안 전송에 실패했어요.'
}
}
</script>
<template>
@@ -54,13 +40,17 @@ async function submitSuggest() {
<p class="hero__desc">
게임을 선택하면 티어표를 만들거나, 다른 사람들이 올린 티어표를 있어요.
</p>
<div class="hero__actions">
<button class="smallBtn" @click="suggestOpen = true">새로운 게임 제안</button>
</div>
</section>
<div v-if="error" class="error">{{ error }}</div>
<section class="grid">
<button class="card card--freeform" @click="goFreeform">
<div class="thumbWrap thumbWrap--freeform">
<div class="thumbFallback">+</div>
</div>
<div class="card__eyebrow">템플릿 없이 시작</div>
<div class="card__title">직접 티어표 만들기</div>
</button>
<button v-for="g in games" :key="g.id" class="card" @click="goGame(g.id)">
<div class="thumbWrap">
<img v-if="thumbUrl(g)" class="thumb" :src="thumbUrl(g)" :alt="g.name" />
@@ -69,19 +59,6 @@ async function submitSuggest() {
<div class="card__title">{{ g.name }}</div>
</button>
</section>
<div v-if="suggestOpen" class="modalBack" @click.self="suggestOpen = false">
<div class="modal">
<div class="modal__title">새로운 게임 제안</div>
<div class="modal__desc">목록에 없는 게임을 입력해 주세요. (관리자가 확인 추가)</div>
<input v-model="suggestName" class="modal__input" placeholder="게임 이름" @keydown.enter.prevent="submitSuggest" />
<div v-if="suggestError" class="modal__error">{{ suggestError }}</div>
<div class="modal__actions">
<button class="smallBtn" @click="suggestOpen = false">닫기</button>
<button class="smallBtn smallBtn--primary" @click="submitSuggest">제안하기</button>
</div>
</div>
</div>
</template>
<style scoped>
@@ -98,27 +75,6 @@ async function submitSuggest() {
opacity: 0.86;
line-height: 1.5;
}
.hero__actions {
margin-top: 12px;
}
.smallBtn {
padding: 8px 10px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.92);
cursor: pointer;
font-weight: 800;
}
.smallBtn:hover {
background: rgba(255, 255, 255, 0.08);
}
.smallBtn--primary {
background: rgba(96, 165, 250, 0.18);
}
.smallBtn--primary:hover {
background: rgba(96, 165, 250, 0.24);
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -148,6 +104,9 @@ async function submitSuggest() {
background: rgba(255, 255, 255, 0.07);
border-color: rgba(255, 255, 255, 0.18);
}
.card--freeform {
background: linear-gradient(135deg, rgba(96, 165, 250, 0.12), rgba(16, 185, 129, 0.12));
}
.thumbWrap {
width: 100%;
height: 140px;
@@ -158,6 +117,11 @@ async function submitSuggest() {
display: grid;
place-items: center;
}
.thumbWrap--freeform {
background:
radial-gradient(circle at top, rgba(96, 165, 250, 0.18), transparent 50%),
rgba(0, 0, 0, 0.18);
}
.thumb {
width: 100%;
height: 100%;
@@ -172,54 +136,12 @@ async function submitSuggest() {
font-weight: 800;
letter-spacing: -0.02em;
}
.modalBack {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: grid;
place-items: center;
z-index: 50;
}
.modal {
width: min(520px, 92vw);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(11, 18, 32, 0.92);
backdrop-filter: blur(10px);
padding: 14px;
}
.modal__title {
font-weight: 900;
font-size: 18px;
}
.modal__desc {
margin-top: 6px;
opacity: 0.78;
font-size: 13px;
}
.modal__input {
margin-top: 12px;
width: 100%;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.18);
color: rgba(255, 255, 255, 0.92);
outline: none;
box-sizing: border-box;
}
.modal__error {
margin-top: 10px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(239, 68, 68, 0.3);
background: rgba(239, 68, 68, 0.12);
}
.modal__actions {
margin-top: 12px;
display: flex;
justify-content: flex-end;
gap: 8px;
.card__eyebrow {
font-size: 12px;
font-weight: 800;
opacity: 0.7;
letter-spacing: 0.04em;
text-transform: uppercase;
}
@media (max-width: 720px) {
.grid {