From b97d7eacdaaf6439f238b269ec8b35260263f3e4 Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 19 Mar 2026 16:58:17 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=8A=A4:=20v0.1.13=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=83=AD=EA=B3=BC=20=EA=B0=80?= =?UTF-8?q?=EB=B3=80=20=ED=8B=B0=EC=96=B4=20=ED=96=89=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/db.js | 55 ++- backend/src/routes/admin.js | 33 +- docs/history.md | 6 + docs/map.md | 6 +- docs/spec.md | 8 +- docs/todo.md | 1 + docs/update.md | 6 + frontend/src/lib/api.js | 5 +- frontend/src/views/AdminView.vue | 483 ++++++++++++++++---------- frontend/src/views/TierEditorView.vue | 96 ++++- 10 files changed, 484 insertions(+), 215 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index 125030d..7338245 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -296,6 +296,11 @@ async function adminUpdateUser({ id, email, nickname, isAdmin }) { return findUserById(id) } +async function adminUpdateUserPassword({ id, passwordHash }) { + await query('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, id]) + return findUserById(id) +} + async function adminDeleteUser(id) { await query('DELETE FROM users WHERE id = ?', [id]) } @@ -399,7 +404,25 @@ async function createCustomItem({ id, ownerId, src, label }) { return { id, ownerId, src, label, origin: 'custom', createdAt } } -async function listCustomItems() { +async function listCustomItems({ queryText = '', page = 1, limit = 50 } = {}) { + const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200) + const normalizedPage = Math.max(Number(page) || 1, 1) + const offset = (normalizedPage - 1) * normalizedLimit + const hasQuery = !!(queryText || '').trim() + const search = `%${(queryText || '').trim()}%` + const whereClause = hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : '' + const params = hasQuery ? [search, search, search, search] : [] + + const countRows = await query( + ` + SELECT COUNT(*) AS count + FROM custom_items c + INNER JOIN users u ON u.id = c.owner_id + ${whereClause} + `, + params + ) + const rows = await query( ` SELECT @@ -412,20 +435,27 @@ async function listCustomItems() { u.email FROM custom_items c INNER JOIN users u ON u.id = c.owner_id + ${whereClause} ORDER BY c.created_at DESC - LIMIT 200 - ` + LIMIT ? OFFSET ? + `, + [...params, normalizedLimit, offset] ) - 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, - })) + return { + items: 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, + })), + total: Number(countRows[0]?.count || 0), + page: normalizedPage, + limit: normalizedLimit, + } } async function listPublicTierLists(gameId) { @@ -539,6 +569,7 @@ module.exports = { updateUserProfile, listUsers, adminUpdateUser, + adminUpdateUserPassword, adminDeleteUser, listGames, findGameById, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 9fbf323..0d2a626 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -1,6 +1,7 @@ const path = require('path') const express = require('express') const multer = require('multer') +const bcrypt = require('bcryptjs') const { z } = require('zod') const { nanoid } = require('nanoid') const { @@ -14,6 +15,7 @@ const { listCustomItems, listUsers, adminUpdateUser, + adminUpdateUserPassword, adminDeleteUser, } = require('../db') const { requireAdmin } = require('../middleware/auth') @@ -83,8 +85,20 @@ router.delete('/games/:gameId', requireAdmin, async (req, res) => { }) router.get('/custom-items', requireAdmin, async (req, res) => { - const items = await listCustomItems() - res.json({ items }) + const schema = z.object({ + q: z.string().trim().max(120).optional().default(''), + page: z.coerce.number().int().min(1).optional().default(1), + limit: z.coerce.number().int().min(1).max(200).optional().default(50), + }) + const parsed = schema.safeParse(req.query) + if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) + + const result = await listCustomItems({ + queryText: parsed.data.q, + page: parsed.data.page, + limit: parsed.data.limit, + }) + res.json(result) }) router.get('/users', requireAdmin, async (req, res) => { @@ -136,4 +150,19 @@ router.delete('/users/:userId', requireAdmin, async (req, res) => { res.json({ ok: true }) }) +router.patch('/users/:userId/password', requireAdmin, async (req, res) => { + const schema = z.object({ + password: z.string().min(6).max(120), + }) + const parsed = schema.safeParse(req.body) + if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) + + const user = await findUserById(req.params.userId) + if (!user) return res.status(404).json({ error: 'not_found' }) + + const passwordHash = await bcrypt.hash(parsed.data.password, 10) + await adminUpdateUserPassword({ id: user.id, passwordHash }) + res.json({ ok: true }) +}) + module.exports = router diff --git a/docs/history.md b/docs/history.md index 8ee82d2..2088ad0 100644 --- a/docs/history.md +++ b/docs/history.md @@ -56,3 +56,9 @@ - 비로그인 사용자가 새 티어표를 편집하다 저장 단계에서 막히는 경험은 손실 위험이 크므로, 작성 시작 자체를 로그인 사용자로 제한하고 공개 티어표는 읽기 전용으로 보여주기로 결정했다. - 커스텀 이미지 업로드는 단일 파일 선택만으로는 불편하므로, 다중 선택과 드래그 앤 드롭을 기본 흐름으로 보강했다. - 관리자에게는 게임 관리뿐 아니라 회원 관리 책임도 필요하므로, 회원 목록 조회/수정/삭제 기능을 추가하기로 결정했다. + +## 2026-03-19 v0.1.13 +- 관리자 페이지는 기능 수가 늘어난 만큼 게임, 아이템, 회원 관리 탭으로 나누는 편이 더 안전하다고 판단했다. +- 사용자 커스텀 아이템은 수량이 빠르게 커질 수 있으므로, 검색과 페이지네이션을 기본 제공하는 관리 화면으로 보는 방향을 채택했다. +- 메일 기반 복구가 없으므로, 관리자가 회원 비밀번호를 직접 초기화할 수 있는 기능을 제공하기로 결정했다. +- 티어표 구조는 게임마다 다르므로, 기본 5단은 시작 템플릿일 뿐 사용자가 행을 직접 추가/삭제할 수 있어야 한다고 결정했다. diff --git a/docs/map.md b/docs/map.md index c2615cb..176875a 100644 --- a/docs/map.md +++ b/docs/map.md @@ -12,7 +12,7 @@ ## `/editor/:gameId/new`, `/editor/:gameId/:tierListId` - 화면 파일: `frontend/src/views/TierEditorView.vue` -- 역할: 티어 그룹 편집, 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 작성 권한 제어, 저장, 공개 여부 설정, PNG 다운로드 +- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 작성 권한 제어, 저장, 공개 여부 설정, PNG 다운로드 - 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/:id`, `POST /api/tierlists/custom-items`, `POST /api/tierlists` ## `/login` @@ -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`, `GET /api/admin/custom-items`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `DELETE /api/admin/users/:userId`, `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`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId` ## `/profile` - 화면 파일: `frontend/src/views/ProfileView.vue` diff --git a/docs/spec.md b/docs/spec.md index 115bd00..37af5d3 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -83,22 +83,24 @@ - `GET /api/admin/custom-items` - `GET /api/admin/users` - `PATCH /api/admin/users/:userId` + - `PATCH /api/admin/users/:userId/password` - `DELETE /api/admin/users/:userId` - `DELETE /api/admin/games/:gameId/items/:itemId` - `DELETE /api/admin/games/:gameId` ## 관리자 화면 메모 - 썸네일은 16:9 비율 미리보기 후 `썸네일 적용` 버튼으로 실제 반영한다. -- 아이템 추가는 이름 입력, 파일 선택, 1:1 미리보기 확인 뒤 저장하는 흐름이다. +- 게임 기본 아이템 추가는 이름 입력, 파일 선택, 1:1 미리보기 확인 뒤 저장하는 흐름이다. - 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다. - 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다. -- 사용자 업로드 커스텀 아이템은 관리자 화면 하단 검토 영역에서 목록/다운로드할 수 있다. -- 관리자 화면에서는 회원 이메일/닉네임/권한 수정과 계정 삭제가 가능하다. +- 사용자 업로드 커스텀 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다. +- 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다. ## 티어표 접근 메모 - `new` 작성 경로는 로그인한 사용자만 진입할 수 있다. - 비로그인 사용자는 공개된 티어표를 열람만 할 수 있고, 편집 UI와 저장 동작은 비활성화된다. - 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다. +- 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다. ## 운영 환경 변수 - 프런트엔드 diff --git a/docs/todo.md b/docs/todo.md index 49d2aa9..2ff0a2e 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -16,3 +16,4 @@ - 자동 테스트와 최소한의 배포 체크리스트를 만든다. - 관리자용 커스텀 아이템 승인/복제, 아이템 정렬 UI를 추가한다. - 회원 검색/필터, 일괄 권한 변경 같은 관리 보조 기능을 추가한다. +- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다. diff --git a/docs/update.md b/docs/update.md index a757c70..deb992a 100644 --- a/docs/update.md +++ b/docs/update.md @@ -85,3 +85,9 @@ - **작성 권한 제한**: 비로그인 사용자는 새 티어표 작성 화면으로 직접 진입할 수 없도록 하고, 공개된 티어표는 읽기 전용으로만 보이게 조정 - **커스텀 이미지 업로드 개선**: 에디터의 커스텀 이미지 추가 영역에 다중 파일 선택과 드래그 앤 드롭 업로드를 추가 - **회원 관리 추가**: 관리자 페이지에서 가입 회원 목록 조회, 이메일/닉네임/권한 수정, 계정 삭제가 가능한 관리 영역과 API를 추가 + +## 2026-03-19 v0.1.13 +- **관리자 탭 구조 정리**: 관리자 페이지를 `게임 관리 / 아이템 관리 / 회원 관리` 탭으로 분리하고 기능별 작업 영역을 명확히 분리 +- **커스텀 아이템 조회 강화**: 사용자 커스텀 아이템 목록에 파일명 검색, `50/200` 단위 페이지네이션, 다운로드 흐름 추가 +- **회원 비밀번호 초기화 추가**: 관리자 페이지와 API에서 회원 비밀번호를 직접 재설정할 수 있도록 기능 추가 +- **가변 티어 행 지원**: 티어표 에디터에서 `S~D` 고정 5단이 아니라 티어 행을 직접 추가/삭제할 수 있도록 보강 diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 0bc25b7..76359bc 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -32,10 +32,13 @@ export const api = { listGames: () => request('/api/games'), getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`), - listAdminCustomItems: () => request('/api/admin/custom-items'), + listAdminCustomItems: ({ q = '', page = 1, limit = 50 } = {}) => + request(`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`), listAdminUsers: () => request('/api/admin/users'), updateAdminUser: (userId, payload) => request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }), + updateAdminUserPassword: (userId, payload) => + request(`/api/admin/users/${encodeURIComponent(userId)}/password`, { method: 'PATCH', body: payload }), deleteAdminUser: (userId) => request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }), listPublicTierLists: (gameId) => diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index fddf992..f126944 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -7,13 +7,21 @@ import { useAuthStore } from '../stores/auth' const auth = useAuthStore() const isAdmin = computed(() => !!auth.user?.isAdmin) +const activeTab = ref('games') +const gameMode = ref('existing') + const games = ref([]) -const customItems = ref([]) -const users = ref([]) -const adminMode = ref('existing') const selectedGameId = ref('') const selectedGame = ref(null) +const customItems = ref([]) +const customItemQuery = ref('') +const customItemPage = ref(1) +const customItemLimit = ref(50) +const customItemTotal = ref(0) + +const users = ref([]) + const error = ref('') const success = ref('') @@ -31,6 +39,7 @@ 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) +const customItemPageCount = computed(() => Math.max(1, Math.ceil(customItemTotal.value / customItemLimit.value))) onMounted(async () => { await auth.refresh() @@ -42,6 +51,16 @@ onUnmounted(() => { clearPreviewUrl('thumb') }) +function resetMessages() { + error.value = '' + success.value = '' +} + +function setTab(tab) { + resetMessages() + activeTab.value = tab +} + async function refreshGames() { try { const data = await api.listGames() @@ -54,8 +73,15 @@ async function refreshGames() { async function refreshCustomItems() { if (!auth.user?.isAdmin) return try { - const data = await api.listAdminCustomItems() + const data = await api.listAdminCustomItems({ + q: customItemQuery.value, + page: customItemPage.value, + limit: customItemLimit.value, + }) customItems.value = data.items || [] + customItemTotal.value = data.total || 0 + customItemPage.value = data.page || 1 + customItemLimit.value = data.limit || customItemLimit.value } catch (e) { error.value = '사용자 커스텀 아이템을 불러오지 못했어요.' } @@ -70,17 +96,13 @@ async function refreshUsers() { draftEmail: user.email, draftNickname: user.nickname || '', draftIsAdmin: !!user.isAdmin, + draftPassword: '', })) } catch (e) { error.value = '회원 목록을 불러오지 못했어요.' } } -function resetMessages() { - error.value = '' - success.value = '' -} - function resetUploadState() { uploadLabel.value = '' uploadFile.value = null @@ -91,9 +113,9 @@ function resetUploadState() { clearPreviewUrl('thumb') } -function setMode(mode) { +function setGameMode(mode) { resetMessages() - adminMode.value = mode + gameMode.value = mode selectedGameId.value = '' selectedGame.value = null newGameId.value = '' @@ -113,12 +135,8 @@ function clearPreviewUrl(type) { } function resetFileInput(type) { - if (type === 'item' && itemFileInput.value) { - itemFileInput.value.value = '' - } - if (type === 'thumb' && thumbFileInput.value) { - thumbFileInput.value.value = '' - } + if (type === 'item' && itemFileInput.value) itemFileInput.value.value = '' + if (type === 'thumb' && thumbFileInput.value) thumbFileInput.value.value = '' } async function loadGame() { @@ -151,7 +169,7 @@ async function createGame() { const data = await res.json() await refreshGames() - adminMode.value = 'existing' + gameMode.value = 'existing' selectedGameId.value = data.game.id await loadGame() success.value = '게임이 생성됐어요. 이어서 썸네일과 아이템을 관리할 수 있어요.' @@ -163,17 +181,13 @@ async function createGame() { function onThumb(event) { thumbFile.value = event.target.files && event.target.files[0] ? event.target.files[0] : null clearPreviewUrl('thumb') - if (thumbFile.value) { - thumbPreviewUrl.value = URL.createObjectURL(thumbFile.value) - } + if (thumbFile.value) thumbPreviewUrl.value = URL.createObjectURL(thumbFile.value) } function onFile(event) { uploadFile.value = event.target.files && event.target.files[0] ? event.target.files[0] : null clearPreviewUrl('item') - if (uploadFile.value) { - itemPreviewUrl.value = URL.createObjectURL(uploadFile.value) - } + if (uploadFile.value) itemPreviewUrl.value = URL.createObjectURL(uploadFile.value) } async function uploadThumbnail() { @@ -224,13 +238,13 @@ async function uploadItem() { resetUploadState() await loadGame() - success.value = '아이템이 추가됐어요.' + success.value = '게임 기본 아이템이 추가됐어요.' } catch (e) { error.value = '아이템 추가 실패(관리자 권한/파일 크기 확인)' } } -async function removeItem(itemId) { +async function removeGameItem(itemId) { resetMessages() try { const res = await fetch( @@ -243,9 +257,9 @@ async function removeItem(itemId) { if (!res.ok) throw new Error('failed') await loadGame() - success.value = '아이템을 삭제했어요.' + success.value = '게임 기본 아이템을 삭제했어요.' } catch (e) { - error.value = '아이템 삭제에 실패했어요.' + error.value = '게임 기본 아이템 삭제에 실패했어요.' } } @@ -253,7 +267,7 @@ async function removeGame() { resetMessages() if (!selectedGameId.value || !selectedGame.value?.game) return - const ok = window.confirm(`"${selectedGame.value.game.name}" 게임을 삭제할까요? 관련 아이템과 티어표도 함께 삭제됩니다.`) + const ok = window.confirm(`"${selectedGame.value.game.name}" 게임을 삭제할까요? 관련 기본 아이템과 티어표도 함께 삭제됩니다.`) if (!ok) return try { @@ -285,12 +299,7 @@ async function saveUser(user) { const updated = data.user users.value = users.value.map((entry) => entry.id === updated.id - ? { - ...updated, - draftEmail: updated.email, - draftNickname: updated.nickname || '', - draftIsAdmin: !!updated.isAdmin, - } + ? { ...entry, ...updated, draftEmail: updated.email, draftNickname: updated.nickname || '', draftIsAdmin: !!updated.isAdmin } : entry ) success.value = '회원 정보를 저장했어요.' @@ -299,6 +308,22 @@ async function saveUser(user) { } } +async function resetUserPassword(user) { + resetMessages() + if (!(user.draftPassword || '').trim()) { + error.value = '새 비밀번호를 입력해주세요.' + return + } + + try { + await api.updateAdminUserPassword(user.id, { password: user.draftPassword }) + user.draftPassword = '' + success.value = '비밀번호를 초기화했어요.' + } catch (e) { + error.value = '비밀번호 초기화에 실패했어요.' + } +} + async function removeUser(user) { resetMessages() if (user.id === auth.user?.id) { @@ -318,6 +343,24 @@ async function removeUser(user) { } } +function submitCustomItemSearch() { + customItemPage.value = 1 + refreshCustomItems() +} + +function changeCustomItemLimit(limit) { + customItemLimit.value = limit + customItemPage.value = 1 + refreshCustomItems() +} + +function moveCustomItemPage(direction) { + const nextPage = customItemPage.value + direction + if (nextPage < 1 || nextPage > customItemPageCount.value) return + customItemPage.value = nextPage + refreshCustomItems() +} + const displayThumbnailUrl = computed(() => { if (thumbPreviewUrl.value) return thumbPreviewUrl.value if (selectedGame.value?.game?.thumbnailSrc) return toApiUrl(selectedGame.value.game.thumbnailSrc) @@ -339,162 +382,192 @@ function fmt(ts) {

관리자

-
작업 종류를 먼저 고르고, 선택한 게임의 관리 화면만 집중해서 정리합니다.
+
기능이 많아진 만큼 관리 영역을 게임, 아이템, 회원 관리로 나눠서 정리합니다.
로그인이 필요해요.
이 계정은 관리자 권한이 없어요.
@@ -538,12 +611,14 @@ function fmt(ts) { border: 1px solid rgba(52, 211, 153, 0.32); background: rgba(52, 211, 153, 0.14); } +.tabs, .modeTabs { margin-top: 14px; display: flex; gap: 10px; flex-wrap: wrap; } +.tab, .modeTab { padding: 10px 14px; border-radius: 12px; @@ -553,6 +628,7 @@ function fmt(ts) { cursor: pointer; font-weight: 800; } +.tab--active, .modeTab--active { background: rgba(96, 165, 250, 0.2); } @@ -564,7 +640,7 @@ function fmt(ts) { padding: 14px; } .panel--compact { - max-width: 640px; + max-width: 760px; } .panel__title, .section__title { @@ -587,6 +663,27 @@ function fmt(ts) { padding: 14px; min-width: 0; } +.sectionHeader { + display: flex; + gap: 12px; + justify-content: space-between; + align-items: flex-start; + flex-wrap: wrap; +} +.toolbar { + margin-top: 14px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 10px; + align-items: end; +} +.toolbar__search, +.toolbar__select { + margin-top: 0; +} +.toolbar__button { + margin-top: 0; +} .uploadPreviewCard { margin-top: 10px; display: flex; @@ -664,6 +761,10 @@ function fmt(ts) { align-items: flex-start; flex-wrap: wrap; } +.detailHead__actions { + display: flex; + gap: 8px; +} .selectedGame__name { margin-top: 8px; font-size: 22px; @@ -674,10 +775,6 @@ function fmt(ts) { opacity: 0.72; word-break: break-all; } -.detailHead__actions { - display: flex; - gap: 8px; -} .selectedThumb { width: min(100%, 256px); aspect-ratio: 16 / 9; @@ -734,7 +831,7 @@ function fmt(ts) { .thumbGrid { margin-top: 12px; display: grid; - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 12px; } .thumbCard { @@ -751,6 +848,11 @@ function fmt(ts) { border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.12); } +.thumb--game { + max-width: 150px; + margin: 0 auto; + display: block; +} .thumbLabel { margin-top: 8px; font-weight: 900; @@ -761,13 +863,6 @@ function fmt(ts) { .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; @@ -781,10 +876,12 @@ function fmt(ts) { overflow: hidden; } .customItemCard__image { - width: 100%; + width: min(100%, 150px); aspect-ratio: 1 / 1; object-fit: cover; display: block; + margin: 12px auto 0; + border-radius: 12px; background: rgba(0, 0, 0, 0.18); } .customItemCard__body { @@ -800,10 +897,22 @@ function fmt(ts) { font-size: 13px; word-break: break-word; } +.pager { + margin-top: 16px; + display: flex; + gap: 12px; + align-items: center; + justify-content: center; + flex-wrap: wrap; +} +.pager__info { + opacity: 0.82; + font-size: 14px; +} .userList { margin-top: 14px; display: grid; - grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; } .userCard { @@ -831,6 +940,11 @@ function fmt(ts) { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } +.passwordBox { + margin-top: 12px; + display: grid; + gap: 10px; +} .roleBadge { padding: 6px 10px; border-radius: 999px; @@ -850,9 +964,8 @@ function fmt(ts) { opacity: 0.88; } @media (max-width: 980px) { - .section--topGrid { - grid-template-columns: 1fr; - } + .section--topGrid, + .toolbar, .itemComposer { grid-template-columns: 1fr; } @@ -861,16 +974,10 @@ function fmt(ts) { } } @media (max-width: 640px) { - .selectedThumb { - width: min(100%, 256px); - } .thumbGrid, .customItemGrid, .userList { grid-template-columns: 1fr; } - .itemPreviewCard { - max-width: 192px; - } } diff --git a/frontend/src/views/TierEditorView.vue b/frontend/src/views/TierEditorView.vue index c680ca7..f503347 100644 --- a/frontend/src/views/TierEditorView.vue +++ b/frontend/src/views/TierEditorView.vue @@ -1,5 +1,5 @@