diff --git a/backend/src/db.js b/backend/src/db.js index 044776b..125030d 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -279,6 +279,27 @@ async function updateUserProfile({ id, nickname, avatarSrc }) { return findUserById(id) } +async function listUsers() { + const rows = await query( + 'SELECT id, email, nickname, is_admin, avatar_src, created_at FROM users ORDER BY created_at ASC, email ASC' + ) + return rows.map(mapUserRow) +} + +async function adminUpdateUser({ id, email, nickname, isAdmin }) { + await query('UPDATE users SET email = ?, nickname = ?, is_admin = ? WHERE id = ?', [ + email, + nickname || '', + isAdmin ? 1 : 0, + id, + ]) + return findUserById(id) +} + +async function adminDeleteUser(id) { + await query('DELETE FROM users WHERE id = ?', [id]) +} + async function listGames() { const rows = await query( 'SELECT id, name, thumbnail_src, created_at FROM games WHERE id <> ? ORDER BY created_at ASC, name ASC', @@ -516,6 +537,9 @@ module.exports = { findUserById, createUser, updateUserProfile, + listUsers, + adminUpdateUser, + adminDeleteUser, listGames, findGameById, listGameItems, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 151a657..9fbf323 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -4,6 +4,7 @@ const multer = require('multer') const { z } = require('zod') const { nanoid } = require('nanoid') const { + findUserById, findGameById, createGame, updateGameThumbnail, @@ -11,6 +12,9 @@ const { deleteGameItem, deleteGame, listCustomItems, + listUsers, + adminUpdateUser, + adminDeleteUser, } = require('../db') const { requireAdmin } = require('../middleware/auth') @@ -83,4 +87,53 @@ router.get('/custom-items', requireAdmin, async (req, res) => { res.json({ items }) }) +router.get('/users', requireAdmin, async (req, res) => { + const users = await listUsers() + res.json({ users }) +}) + +router.patch('/users/:userId', requireAdmin, async (req, res) => { + const schema = z.object({ + email: z.string().email(), + nickname: z.string().trim().max(40).default(''), + isAdmin: z.boolean(), + }) + const parsed = schema.safeParse(req.body) + if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) + + if (req.params.userId === req.session.userId && !parsed.data.isAdmin) { + return res.status(400).json({ error: 'self_admin_required' }) + } + + const user = await findUserById(req.params.userId) + if (!user) return res.status(404).json({ error: 'not_found' }) + + try { + const updated = await adminUpdateUser({ + id: user.id, + email: parsed.data.email, + nickname: parsed.data.nickname, + isAdmin: parsed.data.isAdmin, + }) + res.json({ user: updated }) + } catch (e) { + if (e && e.code === 'ER_DUP_ENTRY') { + return res.status(409).json({ error: 'email_taken' }) + } + throw e + } +}) + +router.delete('/users/:userId', requireAdmin, async (req, res) => { + if (req.params.userId === req.session.userId) { + return res.status(400).json({ error: 'cannot_delete_self' }) + } + + const user = await findUserById(req.params.userId) + if (!user) return res.status(404).json({ error: 'not_found' }) + + await adminDeleteUser(user.id) + res.json({ ok: true }) +}) + module.exports = router diff --git a/docs/history.md b/docs/history.md index e7f785b..8ee82d2 100644 --- a/docs/history.md +++ b/docs/history.md @@ -50,3 +50,9 @@ - 관리자 화면은 좌우 여백이 크게 남는 구조보다, 상단 2열 작업 카드와 하단 목록 영역으로 나누는 편이 더 안정적이라고 판단해 레이아웃을 재정리했다. - 게임 목록에 없는 주제로도 바로 작업할 수 있도록, 시스템 전용 `freeform` 게임을 내부적으로 유지하고 홈 화면에서는 `직접 티어표 만들기` 카드로 노출하기로 결정했다. - 게임 제안은 현재 운영 흐름과 맞지 않아 사용자 진입점과 프런트 API에서 제거하고, 대신 관리자에게는 사용자 커스텀 아이템 검토 기능을 제공하기로 했다. + +## 2026-03-19 v0.1.12 +- 앱 전체 배경은 화면 폭 전체를 사용하고, 개별 콘텐츠만 필요한 만큼 정렬하는 방향이 더 자연스럽다고 판단해 전역 최대 폭 제한을 제거했다. +- 비로그인 사용자가 새 티어표를 편집하다 저장 단계에서 막히는 경험은 손실 위험이 크므로, 작성 시작 자체를 로그인 사용자로 제한하고 공개 티어표는 읽기 전용으로 보여주기로 결정했다. +- 커스텀 이미지 업로드는 단일 파일 선택만으로는 불편하므로, 다중 선택과 드래그 앤 드롭을 기본 흐름으로 보강했다. +- 관리자에게는 게임 관리뿐 아니라 회원 관리 책임도 필요하므로, 회원 목록 조회/수정/삭제 기능을 추가하기로 결정했다. diff --git a/docs/map.md b/docs/map.md index 98b72e4..c2615cb 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`, `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`, `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 13e5887..115bd00 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -81,6 +81,9 @@ - `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` @@ -90,6 +93,12 @@ - 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다. - 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다. - 사용자 업로드 커스텀 아이템은 관리자 화면 하단 검토 영역에서 목록/다운로드할 수 있다. +- 관리자 화면에서는 회원 이메일/닉네임/권한 수정과 계정 삭제가 가능하다. + +## 티어표 접근 메모 +- `new` 작성 경로는 로그인한 사용자만 진입할 수 있다. +- 비로그인 사용자는 공개된 티어표를 열람만 할 수 있고, 편집 UI와 저장 동작은 비활성화된다. +- 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다. ## 운영 환경 변수 - 프런트엔드 diff --git a/docs/todo.md b/docs/todo.md index 42e1ad2..49d2aa9 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -2,6 +2,7 @@ ## 즉시 확인 필요 - 사용자 커스텀 아이템을 관리자 기본 템플릿으로 승격하는 승인/복제 흐름은 아직 없다. +- 회원 관리에서 아바타/작성 티어표 수/최근 활동 같은 보조 정보는 아직 표시하지 않는다. ## 배포 전 작업 - NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다. @@ -14,3 +15,4 @@ - 게임/이미지/티어표 삭제 후 복구 또는 수정 이력 관리 기능을 추가한다. - 자동 테스트와 최소한의 배포 체크리스트를 만든다. - 관리자용 커스텀 아이템 승인/복제, 아이템 정렬 UI를 추가한다. +- 회원 검색/필터, 일괄 권한 변경 같은 관리 보조 기능을 추가한다. diff --git a/docs/update.md b/docs/update.md index 5b95b70..a757c70 100644 --- a/docs/update.md +++ b/docs/update.md @@ -79,3 +79,9 @@ - **직접 티어표 만들기 추가**: 홈 화면에 게임 카드와 동일한 형태의 `직접 티어표 만들기` 진입점을 추가하고, 내부 전용 `freeform` 게임 레코드로 1회성 빈 티어표 저장 흐름을 지원 - **게임 제안 흐름 제거**: 홈 화면의 `새로운 게임 제안` 버튼/모달과 관련 프런트 API를 제거해 현재 운영 흐름에 맞게 단순화 - **커스텀 아이템 검토 영역 추가**: 관리자 페이지에서 사용자 업로드 커스텀 아이템을 목록으로 보고 다운로드할 수 있는 검토 영역과 조회 API를 추가 + +## 2026-03-19 v0.1.12 +- **전역 레이아웃 폭 정리**: 앱 메인 영역의 고정 최대 너비를 제거해 배경과 페이지 폭이 잘린 듯 보이지 않도록 조정 +- **작성 권한 제한**: 비로그인 사용자는 새 티어표 작성 화면으로 직접 진입할 수 없도록 하고, 공개된 티어표는 읽기 전용으로만 보이게 조정 +- **커스텀 이미지 업로드 개선**: 에디터의 커스텀 이미지 추가 영역에 다중 파일 선택과 드래그 앤 드롭 업로드를 추가 +- **회원 관리 추가**: 관리자 페이지에서 가입 회원 목록 조회, 이메일/닉네임/권한 수정, 계정 삭제가 가능한 관리 영역과 API를 추가 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index d5fa41e..40b42ac 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -143,8 +143,8 @@ async function logout() { } .app-main { padding: 20px 18px 60px; - max-width: 1100px; - margin: 0 auto; + width: 100%; + box-sizing: border-box; } .user { diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 2e09c46..0bc25b7 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -33,6 +33,10 @@ export const api = { listGames: () => request('/api/games'), getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`), listAdminCustomItems: () => request('/api/admin/custom-items'), + listAdminUsers: () => request('/api/admin/users'), + updateAdminUser: (userId, payload) => + request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }), + deleteAdminUser: (userId) => request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }), listPublicTierLists: (gameId) => request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}`), diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 064b454..fddf992 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -9,6 +9,7 @@ const isAdmin = computed(() => !!auth.user?.isAdmin) const games = ref([]) const customItems = ref([]) +const users = ref([]) const adminMode = ref('existing') const selectedGameId = ref('') const selectedGame = ref(null) @@ -33,7 +34,7 @@ const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value) onMounted(async () => { await auth.refresh() - await Promise.all([refreshGames(), refreshCustomItems()]) + await Promise.all([refreshGames(), refreshCustomItems(), refreshUsers()]) }) onUnmounted(() => { @@ -60,6 +61,21 @@ async function refreshCustomItems() { } } +async function refreshUsers() { + if (!auth.user?.isAdmin) return + try { + const data = await api.listAdminUsers() + users.value = (data.users || []).map((user) => ({ + ...user, + draftEmail: user.email, + draftNickname: user.nickname || '', + draftIsAdmin: !!user.isAdmin, + })) + } catch (e) { + error.value = '회원 목록을 불러오지 못했어요.' + } +} + function resetMessages() { error.value = '' success.value = '' @@ -258,6 +274,50 @@ async function removeGame() { } } +async function saveUser(user) { + resetMessages() + try { + const data = await api.updateAdminUser(user.id, { + email: user.draftEmail, + nickname: user.draftNickname, + isAdmin: !!user.draftIsAdmin, + }) + 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 + ) + success.value = '회원 정보를 저장했어요.' + } catch (e) { + error.value = '회원 정보 저장에 실패했어요.' + } +} + +async function removeUser(user) { + resetMessages() + if (user.id === auth.user?.id) { + error.value = '현재 로그인한 관리자 계정은 직접 삭제할 수 없어요.' + return + } + + const ok = window.confirm(`${user.email} 계정을 삭제할까요? 작성한 티어표와 커스텀 이미지도 함께 삭제됩니다.`) + if (!ok) return + + try { + await api.deleteAdminUser(user.id) + users.value = users.value.filter((entry) => entry.id !== user.id) + success.value = '회원 계정을 삭제했어요.' + } catch (e) { + error.value = '회원 삭제에 실패했어요.' + } +} + const displayThumbnailUrl = computed(() => { if (thumbPreviewUrl.value) return thumbPreviewUrl.value if (selectedGame.value?.game?.thumbnailSrc) return toApiUrl(selectedGame.value.game.thumbnailSrc) @@ -398,6 +458,43 @@ function fmt(ts) { + +
+
+
+
회원 관리
+
가입한 계정의 이메일, 닉네임, 관리자 권한을 수정하거나 계정을 삭제할 수 있어요.
+
+ +
+ +
아직 가입한 회원이 없어요.
+
+
+
+
+
{{ user.nickname || '닉네임 없음' }}
+
{{ fmt(user.createdAt) }}
+
+ + {{ user.draftIsAdmin ? '관리자' : '일반 회원' }} + +
+ + + + + +
+ + +
+
+
+
@@ -703,6 +800,55 @@ function fmt(ts) { font-size: 13px; word-break: break-word; } +.userList { + margin-top: 14px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 12px; +} +.userCard { + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 16px; + background: rgba(255, 255, 255, 0.04); + padding: 14px; +} +.userCard__head { + display: flex; + gap: 10px; + justify-content: space-between; + align-items: flex-start; +} +.userCard__title { + font-weight: 900; +} +.userCard__meta { + margin-top: 4px; + opacity: 0.72; + font-size: 13px; +} +.userCard__actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} +.roleBadge { + padding: 6px 10px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.14); + font-size: 12px; + font-weight: 800; + opacity: 0.8; +} +.roleBadge--admin { + background: rgba(96, 165, 250, 0.18); +} +.checkRow { + margin-top: 12px; + display: inline-flex; + gap: 8px; + align-items: center; + opacity: 0.88; +} @media (max-width: 980px) { .section--topGrid { grid-template-columns: 1fr; @@ -719,7 +865,8 @@ function fmt(ts) { width: min(100%, 256px); } .thumbGrid, - .customItemGrid { + .customItemGrid, + .userList { grid-template-columns: 1fr; } .itemPreviewCard { diff --git a/frontend/src/views/GameHubView.vue b/frontend/src/views/GameHubView.vue index 541b202..9df5009 100644 --- a/frontend/src/views/GameHubView.vue +++ b/frontend/src/views/GameHubView.vue @@ -2,9 +2,11 @@ import { computed, onMounted, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' import { api } from '../lib/api' +import { useAuthStore } from '../stores/auth' const route = useRoute() const router = useRouter() +const auth = useAuthStore() const gameId = computed(() => route.params.gameId) @@ -34,6 +36,10 @@ onMounted(async () => { }) function createNew() { + if (!auth.user) { + router.push(`/login?redirect=/editor/${gameId.value}/new`) + return + } router.push(`/editor/${gameId.value}/new`) } @@ -50,7 +56,7 @@ function openTierList(id) {

새 티어표를 만들거나, 다른 사람들이 올린 티어표를 확인하세요.

- +
diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 28a84ca..1dee0b6 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -3,8 +3,10 @@ import { computed, onMounted, ref } from 'vue' import { useRouter } from 'vue-router' import { api } from '../lib/api' import { toApiUrl } from '../lib/runtime' +import { useAuthStore } from '../stores/auth' const router = useRouter() +const auth = useAuthStore() const items = ref([]) const error = ref('') @@ -24,6 +26,10 @@ function goGame(gameId) { } function goFreeform() { + if (!auth.user) { + router.push('/login?redirect=/editor/freeform/new') + return + } router.push('/editor/freeform/new') } @@ -48,7 +54,7 @@ function thumbUrl(g) {
+
-
템플릿 없이 시작
+
{{ auth.user ? '템플릿 없이 시작' : '로그인 후 작성 가능' }}
직접 티어표 만들기
+ @@ -274,7 +318,7 @@ onMounted(() => {
- +
{ @@ -369,6 +427,10 @@ onMounted(() => { width: 16px; height: 16px; } +.toggle--disabled { + opacity: 0.55; + pointer-events: none; +} .btn { padding: 10px 12px; border-radius: 12px; @@ -504,6 +566,27 @@ onMounted(() => { font-size: 13px; margin-bottom: 10px; } +.dropzone { + margin-top: 12px; + padding: 14px; + border-radius: 16px; + border: 1px dashed rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.03); + text-align: center; +} +.dropzone--active { + border-color: rgba(110, 231, 183, 0.6); + background: rgba(110, 231, 183, 0.08); +} +.dropzone__title { + font-weight: 900; +} +.dropzone__desc { + margin-top: 6px; + opacity: 0.74; + font-size: 13px; + line-height: 1.4; +} .pool { display: grid; gap: 10px;