릴리스: v0.1.12 작성 권한과 회원 관리 보강

This commit is contained in:
2026-03-19 16:44:50 +09:00
parent c71a19873d
commit f6a031cfe4
14 changed files with 372 additions and 26 deletions

View File

@@ -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 {

View File

@@ -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 || '')}`),

View File

@@ -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) {
</article>
</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="refreshUsers">새로고침</button>
</div>
<div v-if="!users.length" class="hint">아직 가입한 회원이 없어요.</div>
<div v-else class="userList">
<article v-for="user in users" :key="user.id" class="userCard">
<div class="userCard__head">
<div>
<div class="userCard__title">{{ user.nickname || '닉네임 없음' }}</div>
<div class="userCard__meta">{{ fmt(user.createdAt) }}</div>
</div>
<span class="roleBadge" :class="{ 'roleBadge--admin': user.draftIsAdmin }">
{{ user.draftIsAdmin ? '관리자' : '일반 회원' }}
</span>
</div>
<input v-model="user.draftEmail" class="input" placeholder="이메일" />
<input v-model="user.draftNickname" class="input" placeholder="닉네임" />
<label class="checkRow">
<input v-model="user.draftIsAdmin" type="checkbox" :disabled="user.id === auth.user?.id" />
<span>관리자 권한</span>
</label>
<div class="userCard__actions">
<button class="btn btn--ghost" @click="saveUser(user)">회원 저장</button>
<button class="btn btn--danger" :disabled="user.id === auth.user?.id" @click="removeUser(user)">회원 삭제</button>
</div>
</article>
</div>
</div>
</template>
</div>
</section>
@@ -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 {

View File

@@ -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) {
<p class="desc"> 티어표를 만들거나, 다른 사람들이 올린 티어표를 확인하세요.</p>
</div>
<div class="head__right">
<button class="primary" @click="createNew">새로운 티어표 만들기</button>
<button class="primary" @click="createNew">{{ auth.user ? '새로운 티어표 만들기' : '로그인 티어표 만들기' }}</button>
</div>
</section>

View File

@@ -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) {
<div class="thumbWrap thumbWrap--freeform">
<div class="thumbFallback">+</div>
</div>
<div class="card__eyebrow">템플릿 없이 시작</div>
<div class="card__eyebrow">{{ auth.user ? '템플릿 없이 시작' : '로그인 후 작성 가능' }}</div>
<div class="card__title">직접 티어표 만들기</div>
</button>
<button v-for="g in games" :key="g.id" class="card" @click="goGame(g.id)">

View File

@@ -1,10 +1,11 @@
<script setup>
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { api } from '../lib/api'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const email = ref('')
@@ -27,7 +28,7 @@ async function submit() {
try {
if (mode.value === 'signup') await auth.signup(email.value, password.value)
else await auth.login(email.value, password.value)
router.push('/me')
router.push(typeof route.query.redirect === 'string' ? route.query.redirect : '/me')
} catch (e) {
error.value = '로그인/회원가입에 실패했어요.'
}
@@ -146,4 +147,3 @@ async function submit() {
font-size: 13px;
}
</style>

View File

@@ -1,12 +1,15 @@
<script setup>
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import Sortable from 'sortablejs'
import * as htmlToImage from 'html-to-image'
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 gameId = computed(() => route.params.gameId)
const tierListId = computed(() => route.params.tierListId)
const gameName = ref('')
@@ -27,6 +30,8 @@ const description = ref('')
const isPublic = ref(false)
const error = ref('')
const isSaving = ref(false)
const ownerId = ref('')
const isDragActive = ref(false)
const boardEl = ref(null)
const groupListEl = ref(null)
@@ -34,6 +39,9 @@ const poolEl = ref(null)
const groupDropEls = ref({})
const fileEl = ref(null)
const isNewTierList = computed(() => tierListId.value === 'new')
const canEdit = computed(() => !!auth.user && (!ownerId.value || ownerId.value === auth.user.id))
function setGroupDropEl(groupId, el) {
if (!el) return
groupDropEls.value[groupId] = el
@@ -104,6 +112,7 @@ async function initSortables() {
}
function addCustomImage(file) {
if (!file || !file.type.startsWith('image/')) return
const url = URL.createObjectURL(file)
const id = `c-${Date.now()}-${Math.random().toString(16).slice(2)}`
itemsById.value = {
@@ -114,16 +123,35 @@ function addCustomImage(file) {
}
function openFile() {
if (!canEdit.value) return
fileEl.value?.click()
}
function onFileChange(e) {
const file = e.target.files && e.target.files[0]
if (!file) return
addCustomImage(file)
const files = Array.from(e.target.files || [])
if (!files.length) return
files.forEach(addCustomImage)
e.target.value = ''
}
function onDragEnter() {
if (!canEdit.value) return
isDragActive.value = true
}
function onDragLeave(event) {
if (!event.currentTarget.contains(event.relatedTarget)) {
isDragActive.value = false
}
}
function onDropFiles(event) {
if (!canEdit.value) return
isDragActive.value = false
const files = Array.from(event.dataTransfer?.files || []).filter((file) => file.type.startsWith('image/'))
files.forEach(addCustomImage)
}
async function downloadImage() {
if (!boardEl.value) return
const dataUrl = await htmlToImage.toPng(boardEl.value, { pixelRatio: 2, backgroundColor: '#0b1220' })
@@ -200,6 +228,13 @@ async function save() {
onMounted(() => {
;(async () => {
await auth.refresh()
if (isNewTierList.value && !auth.user) {
router.replace(`/login?redirect=/editor/${gameId.value}/new`)
return
}
try {
const gameRes = await api.getGame(gameId.value)
gameName.value = gameRes.game?.name || gameId.value
@@ -221,6 +256,7 @@ onMounted(() => {
try {
const res = await api.getTierList(tierListId.value)
const t = res.tierList
ownerId.value = t.authorId
title.value = t.title
description.value = t.description || ''
isPublic.value = !!t.isPublic
@@ -237,7 +273,9 @@ onMounted(() => {
}
await nextTick()
await initSortables()
if (canEdit.value) {
await initSortables()
}
})()
})
</script>
@@ -246,22 +284,28 @@ onMounted(() => {
<section class="head">
<div>
<div class="kicker">{{ gameName || gameId }}</div>
<input v-model="title" class="titleInput" placeholder="티어표 이름을 입력하세요" />
<input v-model="title" class="titleInput" placeholder="티어표 이름을 입력하세요" :readonly="!canEdit" />
<input
v-model="description"
class="descInput"
placeholder="설명(선택): 이 티어표의 기준/룰"
:readonly="!canEdit"
/>
<div class="hint">
그룹 이름/순서 변경과 아이템 드래그&드롭이 가능합니다. 저장하려면 로그인 <b>저장</b> 누르세요.
<template v-if="canEdit">
그룹 이름/순서 변경과 아이템 드래그&드롭이 가능합니다. 저장하려면 <b>저장</b> 누르세요.
</template>
<template v-else>
공개된 티어표를 보는 중입니다. 로그인한 작성자만 수정할 있어요.
</template>
</div>
</div>
<div class="actions">
<label class="toggle">
<input v-model="isPublic" type="checkbox" />
<label class="toggle" :class="{ 'toggle--disabled': !canEdit }">
<input v-model="isPublic" type="checkbox" :disabled="!canEdit" />
<span>공개</span>
</label>
<button class="btn" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
<button v-if="canEdit" class="btn" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
<button class="btn btn--primary" @click="downloadImage">이미지로 다운로드</button>
</div>
</section>
@@ -274,7 +318,7 @@ onMounted(() => {
<div v-for="g in groups" :key="g.id" class="row">
<div class="row__label">
<span class="grab" title="드래그로 순서 변경" data-group-handle></span>
<input v-model="g.name" class="groupName" />
<input v-model="g.name" class="groupName" :readonly="!canEdit" />
</div>
<div
class="row__drop"
@@ -293,15 +337,29 @@ onMounted(() => {
<div class="sidebar">
<div class="sidebar__title">아이템</div>
<div class="sidebar__hint">게임별 기본 이미지 + 커스텀 업로드를 여기에 모읍니다.</div>
<div class="sidebar__hint">
{{ canEdit ? '게임별 기본 이미지와 커스텀 업로드를 여기에 모읍니다.' : '공개 티어표는 보기 전용입니다.' }}
</div>
<div ref="poolEl" class="pool" data-list-type="pool">
<div v-for="id in pool" :key="id" class="poolItem" :data-item-id="id">
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
</div>
</div>
<input ref="fileEl" type="file" accept="image/*" class="hidden" @change="onFileChange" />
<button class="btn btn--ghost" @click="openFile">커스텀 이미지 추가</button>
<div
v-if="canEdit"
class="dropzone"
:class="{ 'dropzone--active': isDragActive }"
@dragenter.prevent="onDragEnter"
@dragover.prevent="onDragEnter"
@dragleave="onDragLeave"
@drop.prevent="onDropFiles"
>
<div class="dropzone__title">커스텀 이미지 추가</div>
<div class="dropzone__desc">여러 이미지를 번에 드래그하거나 파일 선택으로 추가할 있어요.</div>
</div>
<input ref="fileEl" type="file" accept="image/*" multiple class="hidden" @change="onFileChange" />
<button v-if="canEdit" class="btn btn--ghost" @click="openFile">파일 선택</button>
</div>
</section>
</template>
@@ -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;