릴리스: v1.2.26 관리자 회원 관리와 셸 UI 개선

This commit is contained in:
2026-03-31 14:17:19 +09:00
parent df46e43da5
commit ba6ad0593a
25 changed files with 1944 additions and 733 deletions

View File

@@ -3,6 +3,8 @@ import { Teleport, computed, inject, nextTick, onMounted, onUnmounted, ref, watc
import Sortable from 'sortablejs'
import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
import lockResetIcon from '../assets/icons/lock_reset.svg'
import deleteIcon from '../assets/icons/delete.svg'
import { useAuthStore } from '../stores/auth'
import { useToast } from '../composables/useToast'
@@ -12,9 +14,8 @@ const globalRightRailOpen = inject('rightRailOpen', ref(true))
const localRightRailTarget = inject('localRightRailTarget', '#local-right-rail-root')
const isAdmin = computed(() => !!auth.user?.isAdmin)
const activeTab = ref('games')
const activeTab = ref('featured')
const tierlistsMode = ref('requests')
const gameMode = ref('existing')
const games = ref([])
const selectedGameId = ref('')
@@ -44,6 +45,12 @@ const importModalNewGameId = ref('')
const importModalNewGameName = ref('')
const previewModalOpen = ref(false)
const previewTierList = ref(null)
const userPasswordModalOpen = ref(false)
const userDeleteModalOpen = ref(false)
const userRoleModalOpen = ref(false)
const modalTargetUser = ref(null)
const modalPasswordDraft = ref('')
const modalRoleNextAdmin = ref(false)
const users = ref([])
@@ -62,6 +69,7 @@ const itemFileInput = ref(null)
const thumbFileInput = ref(null)
const featuredListEl = ref(null)
const featuredSortable = ref(null)
const userAvatarInputs = ref({})
const hasSelectedGame = computed(() => !!selectedGame.value?.game?.id)
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedGameId.value)
@@ -76,7 +84,8 @@ const featuredGames = computed(() =>
const availableGamesForFeatured = computed(() => games.value.filter((game) => !featuredGameIds.value.includes(game.id)))
const importModalItemCount = computed(() => importModalItems.value.length)
const activeTabTitle = computed(() => {
if (activeTab.value === 'games') return '게임 관리'
if (activeTab.value === 'featured') return '목록 관리'
if (activeTab.value === 'game-admin') return '게임 관리'
if (activeTab.value === 'items') return '아이템 관리'
if (activeTab.value === 'tierlists') {
return tierlistsMode.value === 'requests' ? '템플릿 요청 관리' : '전체 티어표 관리'
@@ -84,8 +93,11 @@ const activeTabTitle = computed(() => {
return '회원 관리'
})
const activeTabDescription = computed(() => {
if (activeTab.value === 'games') {
return '홈 노출 순서, 게임 생성, 썸네일, 기본 아이템을 한 화면에서 정리합니다.'
if (activeTab.value === 'featured') {
return '홈 화면 상단에 고정 노출되는 게임 순서를 따로 관리합니다.'
}
if (activeTab.value === 'game-admin') {
return '게임 생성, 선택, 썸네일, 기본 아이템 관리를 전용 작업 화면으로 분리했습니다.'
}
if (activeTab.value === 'items') {
return '사용자 커스텀 이미지를 검색하고, 미사용 이미지를 정리하거나 템플릿으로 승격할 수 있어요.'
@@ -95,7 +107,7 @@ const activeTabDescription = computed(() => {
? '사용자 요청 기반으로 새 템플릿 생성이나 템플릿 업데이트를 승인합니다.'
: '공개/비공개 포함 전체 티어표를 확인하고, 추가 아이템을 템플릿으로 가져올 수 있어요.'
}
return '계정 정보, 권한, 비밀번호와 최근 활동을 함께 확인합니다.'
return '계정 정보, 권한, 비밀번호와 최근 활동을 더 가볍게 확인하고 수정합니다.'
})
const adminOverviewStats = computed(() => {
const publishedTierLists = adminTierLists.value.filter((tierList) => tierList.isPublic).length
@@ -103,11 +115,18 @@ const adminOverviewStats = computed(() => {
const orphanItems = customItems.value.filter((item) => item.usageCount === 0).length
const adminCount = users.value.filter((user) => user.isAdmin || user.draftIsAdmin).length
if (activeTab.value === 'games') {
if (activeTab.value === 'featured') {
return [
{ label: '전체 게임', value: `${games.value.length}` },
{ label: '상단 고정', value: `${featuredGameIds.value.length}/50` },
{ label: '추가 가능', value: `${Math.max(0, 50 - featuredGameIds.value.length)}` },
]
}
if (activeTab.value === 'game-admin') {
return [
{ label: '전체 게임', value: `${games.value.length}` },
{ label: '선택 상태', value: hasSelectedGame.value ? '활성' : '대기' },
{ label: '기본 아이템', value: `${selectedGame.value?.items?.length || 0}` },
]
}
if (activeTab.value === 'items') {
@@ -199,6 +218,70 @@ async function refreshGames() {
}
}
function setUserAvatarInput(userId, el) {
if (!userId) return
if (!el) {
delete userAvatarInputs.value[userId]
return
}
userAvatarInputs.value[userId] = el
}
function isUserDirty(user) {
if (!user) return false
return user.draftEmail !== user.email || (user.draftNickname || '') !== (user.nickname || '') || !!user.draftIsAdmin !== !!user.isAdmin
}
function openUserAvatarPicker(user) {
userAvatarInputs.value[user?.id]?.click()
}
async function uploadUserAvatar(user, file, { remove = false } = {}) {
resetMessages()
if (!user?.id) return
try {
user.isAvatarBusy = true
const data = await api.updateAdminUserAvatar(user.id, { file, removeAvatar: remove })
const updated = data.user
users.value = users.value.map((entry) =>
entry.id === updated.id
? {
...entry,
avatarSrc: updated.avatarSrc || '',
email: updated.email,
nickname: updated.nickname || '',
isAdmin: !!updated.isAdmin,
draftEmail: updated.email,
draftNickname: updated.nickname || '',
draftIsAdmin: !!updated.isAdmin,
isAvatarBusy: false,
}
: entry
)
if (updated.id === auth.user?.id) await auth.refresh()
await refreshUsers()
success.value = remove ? '회원 썸네일을 삭제했어요.' : '회원 썸네일을 업데이트했어요.'
} catch (e) {
error.value = remove ? '회원 썸네일 삭제에 실패했어요.' : '회원 썸네일 변경에 실패했어요.'
} finally {
const target = users.value.find((entry) => entry.id === user.id)
if (target) target.isAvatarBusy = false
}
}
async function onUserAvatarChange(user, event) {
const file = event.target.files && event.target.files[0] ? event.target.files[0] : null
event.target.value = ''
if (!file) return
await uploadUserAvatar(user, file)
}
async function removeUserAvatar(user) {
if (!user?.avatarSrc) return
await uploadUserAvatar(user, null, { remove: true })
}
function destroyFeaturedSortable() {
if (featuredSortable.value) {
featuredSortable.value.destroy()
@@ -295,7 +378,7 @@ async function refreshUsers() {
draftEmail: user.email,
draftNickname: user.nickname || '',
draftIsAdmin: !!user.isAdmin,
draftPassword: '',
isAvatarBusy: false,
}))
} catch (e) {
error.value = '회원 목록을 불러오지 못했어요.'
@@ -311,32 +394,6 @@ function resetUploadState() {
clearPreviewUrl('thumb')
}
function setGameMode(mode) {
resetMessages()
gameMode.value = mode
selectedGameId.value = ''
selectedGame.value = null
newGameId.value = ''
newGameName.value = ''
resetUploadState()
}
function clearPreviewUrl(type) {
if (type === 'item' && itemPreviewUrls.value.length) {
itemPreviewUrls.value.forEach((url) => URL.revokeObjectURL(url))
itemPreviewUrls.value = []
}
if (type === 'thumb' && thumbPreviewUrl.value) {
URL.revokeObjectURL(thumbPreviewUrl.value)
thumbPreviewUrl.value = ''
}
}
function resetFileInput(type) {
if (type === 'item' && itemFileInput.value) itemFileInput.value.value = ''
if (type === 'thumb' && thumbFileInput.value) thumbFileInput.value.value = ''
}
async function loadGame() {
resetMessages()
resetUploadState()
@@ -373,7 +430,6 @@ async function createGame() {
const data = await res.json()
await refreshGames()
gameMode.value = 'existing'
selectedGameId.value = data.game.id
await loadGame()
success.value = '게임이 생성됐어요. 이어서 썸네일과 아이템을 관리할 수 있어요.'
@@ -563,50 +619,114 @@ async function saveUser(user) {
const updated = data.user
users.value = users.value.map((entry) =>
entry.id === updated.id
? { ...entry, ...updated, draftEmail: updated.email, draftNickname: updated.nickname || '', draftIsAdmin: !!updated.isAdmin }
? {
...entry,
email: updated.email,
nickname: updated.nickname || '',
isAdmin: !!updated.isAdmin,
draftEmail: updated.email,
draftNickname: updated.nickname || '',
draftIsAdmin: !!updated.isAdmin,
}
: entry
)
if (updated.id === auth.user?.id) await auth.refresh()
await refreshUsers()
success.value = '회원 정보를 저장했어요.'
} catch (e) {
error.value = '회원 정보 저장에 실패했어요.'
}
}
async function resetUserPassword(user) {
function openUserPasswordModal(user) {
resetMessages()
if (!(user.draftPassword || '').trim()) {
error.value = '새 비밀번호를 입력해주세요.'
modalTargetUser.value = user || null
modalPasswordDraft.value = ''
userPasswordModalOpen.value = true
}
function closeUserPasswordModal() {
userPasswordModalOpen.value = false
modalTargetUser.value = null
modalPasswordDraft.value = ''
}
async function confirmUserPasswordReset() {
resetMessages()
if (!modalTargetUser.value?.id) return
const password = modalPasswordDraft.value.trim()
if (!password) {
error.value = '초기화할 비밀번호를 입력해주세요.'
return
}
try {
await api.updateAdminUserPassword(user.id, { password: user.draftPassword })
user.draftPassword = ''
success.value = '비밀번호를 초기화했어요.'
await api.updateAdminUserPassword(modalTargetUser.value.id, { password })
success.value = `${userDisplayName(modalTargetUser.value)} 계정 비밀번호를 초기화했어요.`
closeUserPasswordModal()
} catch (e) {
error.value = '비밀번호 초기화에 실패했어요.'
}
}
async function removeUser(user) {
function openUserDeleteModal(user) {
resetMessages()
if (user.id === auth.user?.id) {
error.value = '현재 로그인한 관리자 계정은 직접 삭제할 수 없어요.'
return
}
modalTargetUser.value = user || null
userDeleteModalOpen.value = true
}
const ok = window.confirm(`${user.email} 계정을 삭제할까요? 작성한 티어표와 커스텀 이미지도 함께 삭제됩니다.`)
if (!ok) return
function closeUserDeleteModal() {
userDeleteModalOpen.value = false
modalTargetUser.value = null
}
async function confirmUserDelete() {
resetMessages()
if (!modalTargetUser.value?.id) return
try {
await api.deleteAdminUser(user.id)
users.value = users.value.filter((entry) => entry.id !== user.id)
success.value = '회원 계정을 삭제했어요.'
const deletingSelf = modalTargetUser.value.id === auth.user?.id
const deletedName = userDisplayName(modalTargetUser.value)
await api.deleteAdminUser(modalTargetUser.value.id)
users.value = users.value.filter((entry) => entry.id !== modalTargetUser.value.id)
closeUserDeleteModal()
success.value = `${deletedName} 계정을 삭제했어요.`
if (deletingSelf) await auth.refresh()
} catch (e) {
error.value = '회원 삭제에 실패했어요.'
}
}
function openUserRoleModal(user) {
resetMessages()
modalTargetUser.value = user || null
modalRoleNextAdmin.value = !user?.draftIsAdmin
userRoleModalOpen.value = true
}
function closeUserRoleModal() {
userRoleModalOpen.value = false
modalTargetUser.value = null
modalRoleNextAdmin.value = false
}
function confirmUserRoleDraft() {
if (!modalTargetUser.value?.id) return
users.value = users.value.map((entry) =>
entry.id === modalTargetUser.value.id
? {
...entry,
draftIsAdmin: modalRoleNextAdmin.value,
}
: entry
)
const targetLabel = modalRoleNextAdmin.value ? '관리자로 지정했어요. 저장하면 반영됩니다.' : '관리자 권한 해제로 표시했어요. 저장하면 반영됩니다.'
closeUserRoleModal()
success.value = targetLabel
}
function submitCustomItemSearch() {
customItemPage.value = 1
refreshCustomItems()
@@ -946,7 +1066,7 @@ async function saveFeaturedOrder() {
</div>
</header>
<template v-if="activeTab === 'games'">
<template v-if="activeTab === 'featured'">
<div class="panel">
<div class="sectionHeader">
<div>
@@ -996,6 +1116,40 @@ async function saveFeaturedOrder() {
</div>
</div>
</div>
</template>
<template v-else-if="activeTab === 'game-admin'">
<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="refreshGames">게임 목록 새로고침</button>
</div>
<div class="gameManagerGrid">
<section class="adminCard">
<div class="section__title">등록된 게임 선택</div>
<div class="gameManagerCard__body">
<select v-model="selectedGameId" class="select" @change="loadGame">
<option value="">게임을 선택해주세요</option>
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }} ({{ game.id }})</option>
</select>
<div class="hint hint--tight">선택하면 아래 상세 영역에서 썸네일과 기본 아이템을 바로 수정할 있어요.</div>
</div>
</section>
<section class="adminCard">
<div class="section__title"> 게임 만들기</div>
<div class="gameManagerCard__body">
<input v-model="newGameId" class="input" placeholder="game id (영문/숫자)" />
<input v-model="newGameName" class="input" placeholder="게임 이름" />
<button class="btn btn--primary" @click="createGame">게임 생성</button>
</div>
</section>
</div>
</div>
<div v-if="hasSelectedGame" class="panel">
<div class="detailHead">
@@ -1084,9 +1238,9 @@ async function saveFeaturedOrder() {
</div>
<div v-else class="panel panel--empty">
<div class="emptyState">
<div class="emptyState__title">{{ gameMode === 'existing' ? '게임을 선택하면 상세 관리가 열려요.' : '새 게임 정보를 입력한 뒤 생성해 주세요.' }}</div>
<div class="emptyState__title">게임을 선택하면 상세 관리가 열려요.</div>
<div class="emptyState__desc">
{{ gameMode === 'existing' ? '우측 패널에서 등록된 게임을 선택하면 썸네일과 기본 아이템 관리 영역이 활성화됩니다.' : '새 게임을 만들면 바로 선택 상태로 전환되어 썸네일과 기본 아이템 추가를 이어서 진행할 수 있어요.' }}
위에서 기존 게임을 선택하거나 게임을 만든 , 같은 화면에서 바로 썸네일과 기본 아이템을 정리할 있어요.
</div>
</div>
</div>
@@ -1235,7 +1389,7 @@ async function saveFeaturedOrder() {
<div class="sectionHeader">
<div>
<div class="panel__title">회원 관리</div>
<div class="hint hint--tight">이메일, 닉네임, 관리자 권한 수정하고 비밀번호 직접 초기화할 있어요.</div>
<div class="hint hint--tight">회원 프로필을 정리하고, 필요한 경우에만 권한 변경과 비밀번호 초기화 진행 있어요.</div>
</div>
</div>
@@ -1244,52 +1398,114 @@ async function saveFeaturedOrder() {
<article v-for="user in users" :key="user.id" class="userCard">
<div class="userCard__head">
<div class="userCard__identity">
<div class="userAvatar">
<img v-if="userAvatarUrl(user)" class="userAvatar__image" :src="userAvatarUrl(user)" :alt="userDisplayName(user)" />
<span v-else class="userAvatar__fallback">{{ userAvatarFallback(user) }}</span>
<input
:ref="(el) => setUserAvatarInput(user.id, el)"
type="file"
accept="image/*"
class="srOnlyInput"
@change="onUserAvatarChange(user, $event)"
/>
<div class="userAvatarWrap">
<button class="userAvatar userAvatarButton" type="button" :disabled="user.isAvatarBusy" @click="openUserAvatarPicker(user)">
<img v-if="userAvatarUrl(user)" class="userAvatar__image" :src="userAvatarUrl(user)" :alt="userDisplayName(user)" />
<span v-else class="userAvatar__fallback">{{ userAvatarFallback(user) }}</span>
<span class="userAvatarButton__overlay">{{ user.isAvatarBusy ? '업데이트중...' : '수정' }}</span>
</button>
<button
v-if="user?.avatarSrc"
class="userAvatarRemoveButton"
type="button"
title="회원 썸네일 삭제"
:disabled="user.isAvatarBusy"
@click.stop="removeUserAvatar(user)"
>
<img :src="deleteIcon" alt="" />
</button>
</div>
<div>
<div class="userCard__identityMeta">
<div class="userCard__title">{{ userDisplayName(user) }}</div>
<div class="userCard__meta">가입일 {{ fmt(user.createdAt) }}</div>
<div class="userCard__meta">{{ user.email }}</div>
</div>
</div>
<span class="roleBadge" :class="{ 'roleBadge--admin': user.draftIsAdmin }">
{{ user.draftIsAdmin ? '관리자' : '일반 회원' }}
</span>
</div>
<div class="userStats">
<div class="userStat">
<span class="userStat__label">작성 티어표</span>
<strong class="userStat__value">{{ user.tierListCount }}</strong>
</div>
<div class="userStat">
<span class="userStat__label">최근 활동</span>
<strong class="userStat__value">{{ fmt(user.recentActivityAt || user.createdAt) }}</strong>
</div>
<div v-if="user.draftIsAdmin" class="roleBadge userCard__roleBadge">Administrator</div>
<div class="userInfoList">
<div class="userInfoLine"><span>가입일</span><strong>{{ fmt(user.createdAt) }}</strong></div>
<div class="userInfoLine"><span>작성 티어표</span><strong>{{ user.tierListCount }}</strong></div>
<div class="userInfoLine"><span>최근 활동</span><strong>{{ fmt(user.recentActivityAt || user.createdAt) }}</strong></div>
</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>
<button
class="userRoleAction"
type="button"
:disabled="user.id === auth.user?.id"
@click="openUserRoleModal(user)"
>
{{ user.draftIsAdmin ? '관리자 권한 해제' : '관리자 권한 임명' }}
</button>
<div class="passwordBox">
<input v-model="user.draftPassword" class="input" type="text" placeholder="새 비밀번호 직접 입력" />
<button class="btn btn--ghost" @click="resetUserPassword(user)">비밀번호 초기화</button>
</div>
<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 class="userCard__actions userCard__actions--compact">
<button class="iconActionButton" type="button" title="비밀번호 초기화" @click="openUserPasswordModal(user)">
<img :src="lockResetIcon" alt="" />
</button>
<button class="iconActionButton iconActionButton--danger" type="button" title="회원 삭제" @click="openUserDeleteModal(user)">
<img :src="deleteIcon" alt="" />
</button>
<button class="btn btn--ghost userSaveButton" :disabled="!isUserDirty(user)" @click="saveUser(user)">회원정보 저장</button>
</div>
</article>
</div>
</div>
</template>
<div v-if="userPasswordModalOpen" class="modalOverlay" @click.self="closeUserPasswordModal">
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title">비밀번호 초기화</div>
<div class="modalCard__desc">{{ modalTargetUser ? `${userDisplayName(modalTargetUser)} 계정에 설정할 새 비밀번호를 입력해주세요.` : '' }}</div>
<div class="modalCard__form">
<input v-model="modalPasswordDraft" class="input" type="password" placeholder="초기화할 비밀번호 입력" @keydown.enter.prevent="confirmUserPasswordReset" />
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeUserPasswordModal">취소</button>
<button class="btn btn--primary" :disabled="!modalPasswordDraft.trim()" @click="confirmUserPasswordReset">초기화</button>
</div>
</div>
</div>
<div v-if="userDeleteModalOpen" class="modalOverlay" @click.self="closeUserDeleteModal">
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title">회원 삭제</div>
<div class="modalCard__desc">{{ modalTargetUser ? `${modalTargetUser.email} 계정을 삭제할까요? 작성한 티어표와 커스텀 이미지도 함께 삭제됩니다.` : '' }}</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeUserDeleteModal">취소</button>
<button class="btn btn--danger" @click="confirmUserDelete">삭제</button>
</div>
</div>
</div>
<div v-if="userRoleModalOpen" class="modalOverlay" @click.self="closeUserRoleModal">
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title">관리자 권한 변경</div>
<div class="modalCard__desc">
{{
modalTargetUser
? modalRoleNextAdmin
? `${userDisplayName(modalTargetUser)} 사용자를 관리자로 임명할까요?`
: `${userDisplayName(modalTargetUser)} 사용자의 관리자 권한을 해제할까요?`
: ''
}}
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeUserRoleModal">취소</button>
<button class="btn btn--primary" @click="confirmUserRoleDraft">확인</button>
</div>
</div>
</div>
<div v-if="importModalOpen" class="modalOverlay" @click.self="closeTierListImportModal">
<div class="modalCard modalCard--import" role="dialog" aria-modal="true">
<div class="modalCard__title">티어표 아이템 가져오기</div>
@@ -1352,48 +1568,50 @@ async function saveFeaturedOrder() {
<section class="adminSidebar__panel">
<div class="adminSidebar__label">Mode</div>
<div class="adminSidebar__tabs">
<button class="tab" :class="{ 'tab--active': activeTab === 'games' }" @click="setTab('games')">게임 관리</button>
<button class="tab" :class="{ 'tab--active': activeTab === 'featured' }" @click="setTab('featured')">목록 관리</button>
<button class="tab" :class="{ 'tab--active': activeTab === 'game-admin' }" @click="setTab('game-admin')">게임 관리</button>
<button class="tab" :class="{ 'tab--active': activeTab === 'items' }" @click="setTab('items')">아이템 관리</button>
<button class="tab" :class="{ 'tab--active': activeTab === 'tierlists' }" @click="setTab('tierlists')">티어표 관리</button>
<button class="tab" :class="{ 'tab--active': activeTab === 'users' }" @click="setTab('users')">회원 관리</button>
</div>
</section>
<section v-if="activeTab === 'games'" class="adminSidebar__panel">
<div class="adminSidebar__label">Game Flow</div>
<div class="modeTabs modeTabs--stack">
<button class="modeTab" :class="{ 'modeTab--active': gameMode === 'existing' }" @click="setGameMode('existing')">
등록된 게임 선택
</button>
<button class="modeTab" :class="{ 'modeTab--active': gameMode === 'new' }" @click="setGameMode('new')">
게임 추가
</button>
<section v-if="activeTab === 'featured'" class="adminSidebar__panel">
<div class="adminSidebar__label">Featured</div>
<div class="adminSidebar__actions">
<button class="btn btn--ghost" @click="refreshGames">목록 새로고침</button>
<button class="btn btn--primary" @click="saveFeaturedOrder">순서 저장</button>
</div>
<div class="adminSidebar__stats">
<div class="sidebarStat">
<span class="sidebarStat__label">상단 고정</span>
<strong class="sidebarStat__value">{{ featuredGameIds.length }}/50</strong>
</div>
<div class="sidebarStat">
<span class="sidebarStat__label">추가 가능</span>
<strong class="sidebarStat__value">{{ Math.max(0, 50 - featuredGameIds.length) }}</strong>
</div>
</div>
</section>
<div v-if="gameMode === 'existing'" class="adminSidebar__group">
<div class="adminSidebar__groupTitle">선택할 게임</div>
<select v-model="selectedGameId" class="select" @change="loadGame">
<option value="">게임을 선택해주세요</option>
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }} ({{ game.id }})</option>
</select>
<section v-else-if="activeTab === 'game-admin'" class="adminSidebar__panel">
<div class="adminSidebar__label">Game Summary</div>
<div class="adminSidebar__actions">
<button class="btn btn--ghost" @click="refreshGames">게임 목록 새로고침</button>
<button v-if="hasSelectedGame" class="btn btn--ghost" @click="loadGame">선택 게임 다시 불러오기</button>
</div>
<div v-else class="adminSidebar__group">
<div class="adminSidebar__groupTitle"> 게임 만들기</div>
<input v-model="newGameId" class="input" placeholder="game id (영문/숫자)" />
<input v-model="newGameName" class="input" placeholder="게임 이름" />
<button class="btn btn--primary" @click="createGame">게임 생성</button>
</div>
<div class="adminSidebar__stats">
<div class="sidebarStat">
<span class="sidebarStat__label">전체 게임</span>
<strong class="sidebarStat__value">{{ games.length }}</strong>
</div>
<div class="sidebarStat">
<span class="sidebarStat__label">상단 고정</span>
<strong class="sidebarStat__value">{{ featuredGameIds.length }}/50</strong>
<span class="sidebarStat__label">선택 상태</span>
<strong class="sidebarStat__value">{{ hasSelectedGame ? '활성' : '대기' }}</strong>
</div>
<div class="sidebarStat">
<span class="sidebarStat__label">기본 아이템</span>
<strong class="sidebarStat__value">{{ selectedGame?.items?.length || 0 }}</strong>
</div>
</div>
</section>
@@ -1828,6 +2046,17 @@ async function saveFeaturedOrder() {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.gameManagerGrid {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.gameManagerCard__body {
margin-top: 10px;
display: grid;
gap: 10px;
}
.adminCard {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.025);
@@ -1917,6 +2146,9 @@ async function saveFeaturedOrder() {
}
.btn {
font-size: 12px;
line-height: 1.2;
white-space: nowrap;
word-break: keep-all;
margin-top: 12px;
padding: 11px 13px;
border-radius: 14px;
@@ -2146,7 +2378,7 @@ async function saveFeaturedOrder() {
}
.customItemCard__actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
gap: 10px;
margin-top: 4px;
}
@@ -2182,16 +2414,18 @@ async function saveFeaturedOrder() {
gap: 12px;
}
.userCard {
position: relative;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 18px;
background: rgba(255, 255, 255, 0.04);
padding: 16px;
padding: 24px 16px 16px;
overflow: visible;
}
.userCard__head {
display: flex;
gap: 10px;
justify-content: space-between;
align-items: flex-start;
display: block;
}
.userCard__identityMeta {
min-width: 0;
}
.userCard__identity {
display: flex;
@@ -2199,6 +2433,12 @@ async function saveFeaturedOrder() {
align-items: center;
min-width: 0;
}
.userAvatarWrap {
position: relative;
width: 56px;
height: 56px;
flex: 0 0 auto;
}
.userCard__title {
font-weight: 900;
}
@@ -2208,9 +2448,8 @@ async function saveFeaturedOrder() {
font-size: 13px;
}
.userAvatar {
width: 48px;
height: 48px;
flex: 0 0 auto;
width: 56px;
height: 56px;
display: grid;
place-items: center;
border-radius: 999px;
@@ -2218,6 +2457,71 @@ async function saveFeaturedOrder() {
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(96, 165, 250, 0.18);
}
.userAvatarButton {
position: relative;
padding: 0;
cursor: pointer;
}
.userAvatarButton:disabled {
cursor: wait;
}
.userAvatarRemoveButton {
position: absolute;
top: -4px;
right: -4px;
width: 24px;
height: 24px;
display: grid;
place-items: center;
padding: 0;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(10, 14, 22, 0.96);
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.3);
cursor: pointer;
z-index: 2;
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translateY(2px) scale(0.96);
transition: opacity 160ms ease, transform 160ms ease, background 160ms ease, visibility 160ms ease;
}
.userAvatarRemoveButton img {
width: 12px;
height: 12px;
filter: brightness(0) invert(1);
}
.userAvatarRemoveButton:disabled {
opacity: 0.45;
cursor: wait;
}
.userAvatarRemoveButton:hover {
background: rgba(255, 255, 255, 0.12);
}
.userAvatarButton__overlay {
position: absolute;
inset: auto 0 0 0;
padding: 10px 0 6px;
background: linear-gradient(180deg, rgba(7, 10, 18, 0), rgba(7, 10, 18, 0.88));
color: rgba(255, 255, 255, 0.9);
font-size: 10px;
font-weight: 800;
opacity: 0;
transform: translateY(4px);
transition: opacity 160ms ease, transform 160ms ease;
}
.userAvatarWrap:hover .userAvatarButton__overlay,
.userAvatarWrap:focus-within .userAvatarButton__overlay {
opacity: 1;
transform: translateY(0);
}
.userAvatarWrap:hover .userAvatarRemoveButton,
.userAvatarWrap:focus-within .userAvatarRemoveButton {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translateY(0) scale(1);
}
.userAvatar__image {
width: 100%;
height: 100%;
@@ -2227,25 +2531,26 @@ async function saveFeaturedOrder() {
font-size: 18px;
font-weight: 900;
}
.userStats {
margin-top: 12px;
.userInfoList {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
gap: 8px;
}
.userStat {
display: grid;
gap: 4px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
.userInfoLine {
display: flex;
gap: 12px;
justify-content: space-between;
align-items: baseline;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.userStat__label {
.userInfoLine span {
font-size: 12px;
opacity: 0.66;
color: rgba(255, 255, 255, 0.56);
}
.userStat__value {
.userInfoLine strong {
min-width: 0;
text-align: right;
font-size: 14px;
font-weight: 900;
}
@@ -2254,21 +2559,68 @@ async function saveFeaturedOrder() {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.passwordBox {
margin-top: 12px;
display: grid;
gap: 10px;
.userCard__actions--compact {
grid-template-columns: auto auto minmax(0, 1fr);
align-items: center;
}
.roleBadge {
width: fit-content;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.14);
border: 1px solid rgba(96, 165, 250, 0.28);
background: rgba(96, 165, 250, 0.1);
color: rgba(191, 219, 254, 0.92);
font-size: 12px;
font-weight: 800;
opacity: 0.8;
font-weight: 700;
}
.roleBadge--admin {
background: rgba(96, 165, 250, 0.18);
.userCard__roleBadge {
position: absolute;
top: 6px;
right: 6px;
z-index: 2;
box-shadow: 0 10px 24px rgba(7, 10, 18, 0.28);
}
.iconActionButton {
width: 42px;
height: 42px;
display: grid;
place-items: center;
padding: 0;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
cursor: pointer;
}
.iconActionButton img {
width: 18px;
height: 18px;
}
.iconActionButton:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.iconActionButton--danger {
border-color: rgba(239, 68, 68, 0.24);
background: rgba(239, 68, 68, 0.1);
}
.userSaveButton:disabled {
opacity: 0.4;
}
.userRoleAction {
width: fit-content;
margin-top: 8px;
padding: 0;
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.46);
font-size: 9px;
line-height: 1.4;
letter-spacing: 0.01em;
cursor: pointer;
}
.userRoleAction:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.templateRequestList {
margin-top: 14px;
@@ -2497,6 +2849,7 @@ async function saveFeaturedOrder() {
gap: 10px;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
}
.previewFrame {
width: 100%;
@@ -2532,13 +2885,11 @@ async function saveFeaturedOrder() {
}
.featuredOrderPanel,
.section--topGrid,
.gameManagerGrid,
.toolbar,
.itemComposer,
.tierAdminCard,
.userStats,
.templateRequestCard__form {
grid-template-columns: 1fr;
}
.templateRequestCard__form,
.toolbar--secondary {
grid-template-columns: 1fr;
}
@@ -2548,7 +2899,22 @@ async function saveFeaturedOrder() {
.userCard__identity {
width: 100%;
}
.userInfoLine {
display: grid;
gap: 4px;
}
.userInfoLine strong {
text-align: left;
}
.userCard__actions--compact {
grid-template-columns: repeat(3, minmax(0, auto));
}
.userSaveButton {
width: 100%;
}
}
@media (max-width: 640px) {
.adminHero {
padding: 16px;

View File

@@ -54,14 +54,14 @@ onMounted(loadFavorites)
</script>
<template>
<section class="wrap">
<div class="head">
<div>
<div class="head__eyebrow">Collection</div>
<h2 class="title"> 즐겨찾기</h2>
<div class="desc">마음에 드는 티어표를 모아보고, 원하는 기준으로 정렬할 있어요.</div>
<section class="pageWrap">
<div class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Collection</div>
<h2 class="pageHead__title"> 즐겨찾기</h2>
<div class="pageHead__desc">마음에 드는 티어표를 모아보고, 원하는 기준으로 정렬할 있어요.</div>
</div>
<div class="toolbar">
<div class="pageHead__aside toolbar">
<input v-model="query" class="input" placeholder="제목, 게임, 작성자 검색" @keydown.enter.prevent="loadFavorites" />
<select v-model="sort" class="select" @change="loadFavorites">
<option value="favorited">즐겨찾기한 </option>
@@ -101,34 +101,6 @@ onMounted(loadFavorites)
</template>
<style scoped>
.wrap {
display: grid;
gap: 18px;
}
.head {
display: flex;
gap: 14px;
justify-content: space-between;
align-items: flex-end;
flex-wrap: wrap;
padding: 6px 2px 8px;
}
.head__eyebrow {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.42);
}
.title {
margin: 4px 0 0;
font-size: 32px;
color: rgba(255, 255, 255, 0.96);
letter-spacing: -0.04em;
}
.desc {
margin-top: 6px;
color: rgba(255, 255, 255, 0.58);
}
.toolbar {
display: flex;
gap: 10px;
@@ -210,15 +182,19 @@ onMounted(loadFavorites)
.boardCard__head {
padding: 16px 18px 18px;
display: grid;
gap: 10px;
gap: 6px;
}
.boardCard__titleRow,
.boardCard__metaRow {
display: flex;
gap: 12px;
gap: 10px;
align-items: center;
justify-content: space-between;
}
.boardCard__metaRow {
align-items: flex-end;
}
.boardCard__title {
min-width: 0;
font-weight: 800;
@@ -244,7 +220,7 @@ onMounted(loadFavorites)
.boardCard__avatar {
width: 22px;
height: 22px;
border-radius: 6px;
border-radius: 9999px;
object-fit: cover;
background: rgba(255, 255, 255, 0.08);
flex: 0 0 auto;
@@ -262,6 +238,10 @@ onMounted(loadFavorites)
color: rgba(255, 255, 255, 0.64);
white-space: nowrap;
}
.boardCard__date {
font-size: 10px;
}
@media (max-width: 1400px) {
.list {
grid-template-columns: repeat(3, minmax(0, 1fr));

View File

@@ -278,19 +278,23 @@ function submitSearch() {
.boardCard__head {
padding: 16px 18px 18px;
display: grid;
gap: 10px;
gap: 6px;
}
.boardCard__titleRow,
.boardCard__metaRow {
display: flex;
gap: 12px;
gap: 10px;
align-items: center;
justify-content: space-between;
}
.boardCard__metaRow {
align-items: flex-end;
}
.boardCard__author {
min-width: 0;
display: inline-flex;
gap: 8px;
gap: 7px;
align-items: center;
font-size: 13px;
opacity: 0.86;
@@ -304,7 +308,7 @@ function submitSearch() {
.boardCard__avatar {
width: 22px;
height: 22px;
border-radius: 6px;
border-radius: 9999px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
@@ -323,6 +327,10 @@ function submitSearch() {
color: rgba(255, 255, 255, 0.64);
white-space: nowrap;
}
.boardCard__date {
font-size: 10px;
}
@media (max-width: 1400px) {
.list {
grid-template-columns: repeat(3, minmax(0, 1fr));

View File

@@ -29,11 +29,11 @@ function thumbUrl(g) {
</script>
<template>
<section class="dashboardHero">
<div class="dashboardHero__copy">
<div class="dashboardHero__eyebrow">Workspace</div>
<h1 class="dashboardHero__title">Game Library</h1>
<p class="dashboardHero__desc">자주 쓰는 게임 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 있어요.</p>
<section class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Workspace</div>
<h1 class="pageHead__title">Game Library</h1>
<p class="pageHead__desc">자주 쓰는 게임 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 있어요.</p>
</div>
</section>
@@ -53,39 +53,6 @@ function thumbUrl(g) {
</template>
<style scoped>
.dashboardHero {
display: flex;
gap: 18px;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
margin-top: 2px;
margin-bottom: 18px;
padding: 6px 2px 18px;
}
.dashboardHero__copy {
display: grid;
gap: 8px;
max-width: 720px;
}
.dashboardHero__eyebrow {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.42);
}
.dashboardHero__title {
margin: 0;
font-size: 34px;
letter-spacing: -0.04em;
color: rgba(255, 255, 255, 0.96);
}
.dashboardHero__desc {
margin: 0;
color: rgba(255, 255, 255, 0.58);
line-height: 1.5;
max-width: 720px;
}
.libraryGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -139,7 +106,6 @@ function thumbUrl(g) {
}
.libraryCard__body {
display: grid;
gap: 6px;
}
.libraryCard__title {
font-weight: 800;
@@ -169,9 +135,6 @@ function thumbUrl(g) {
}
}
@media (max-width: 720px) {
.dashboardHero {
align-items: stretch;
}
.libraryGrid {
grid-template-columns: 1fr;
}

View File

@@ -1,5 +1,5 @@
<script setup>
import { onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { api } from '../lib/api'
@@ -12,6 +12,7 @@ const toast = useToast()
const email = ref('')
const password = ref('')
const passwordConfirm = ref('')
const mode = ref('login')
const error = ref('')
const hasUsers = ref(true)
@@ -22,6 +23,14 @@ watch(error, (message) => {
error.value = ''
})
const title = computed(() => (mode.value === 'signup' ? '회원가입' : '로그인'))
const description = computed(() =>
mode.value === 'signup'
? '티어표를 저장하고 즐겨찾기, 개인 설정을 관리할 수 있도록 계정을 만들어요.'
: '저장한 티어표와 즐겨찾기, 프로필 설정을 이어서 관리할 수 있어요.'
)
const submitLabel = computed(() => (mode.value === 'signup' ? '가입하기' : '로그인'))
onMounted(async () => {
try {
const meta = await api.authMeta()
@@ -33,6 +42,10 @@ onMounted(async () => {
async function submit() {
error.value = ''
if (mode.value === 'signup' && password.value !== passwordConfirm.value) {
error.value = '비밀번호 확인이 일치하지 않아요.'
return
}
try {
if (mode.value === 'signup') await auth.signup(email.value, password.value)
else await auth.login(email.value, password.value)
@@ -44,104 +57,184 @@ async function submit() {
</script>
<template>
<section class="wrap">
<form class="card" @submit.prevent="submit">
<div class="tabs">
<button type="button" class="tab" :class="{ 'tab--active': mode === 'login' }" @click="mode = 'login'">
<section class="pageWrap">
<header class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Account</div>
<h2 class="pageHead__title">{{ title }}</h2>
<div class="pageHead__desc">{{ description }}</div>
</div>
</header>
<section class="authScreen">
<div class="authTabs" role="tablist" aria-label="로그인 또는 회원가입">
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'login' }" @click="mode = 'login'">
로그인
</button>
<button type="button" class="tab" :class="{ 'tab--active': mode === 'signup' }" @click="mode = 'signup'">
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'signup' }" @click="mode = 'signup'">
회원가입
</button>
</div>
<label class="label">이메일</label>
<input v-model="email" class="input" placeholder="you@example.com" autocomplete="email" />
<label class="label">비밀번호</label>
<input
v-model="password"
class="input"
type="password"
placeholder="********"
autocomplete="current-password"
/>
<form class="authFields" @submit.prevent="submit">
<label class="field">
<span class="field__label">이메일</span>
<input v-model="email" class="field__input" placeholder="you@example.com" autocomplete="email" />
<span class="field__hint">로그인과 알림에 사용되는 계정 이메일입니다.</span>
</label>
<button class="btn" type="submit">{{ mode === 'signup' ? '회원가입' : '로그인' }}</button>
<label class="field">
<span class="field__label">비밀번호</span>
<input
v-model="password"
class="field__input"
type="password"
placeholder="********"
autocomplete="current-password"
/>
<span class="field__hint">8 이상으로 설정하면 안전하게 사용할 있어요.</span>
</label>
<div v-if="!hasUsers" class="hint"> 회원가입 계정은 자동으로 admin 권한이 부여됩니다(개발용).</div>
</form>
<label v-if="mode === 'signup'" class="field">
<span class="field__label">비밀번호 확인</span>
<input
v-model="passwordConfirm"
class="field__input"
type="password"
placeholder="********"
autocomplete="new-password"
/>
<span class="field__hint">같은 비밀번호를 입력해주세요.</span>
</label>
<div v-if="!hasUsers" class="roleBadge"> 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>
<div class="authActions">
<button class="secondaryAction" type="button" @click="router.push('/')">취소</button>
<button class="primaryAction" type="submit">{{ submitLabel }}</button>
</div>
</form>
</section>
</section>
</template>
<style scoped>
.wrap {
min-height: calc(100vh - 74px);
.authScreen {
display: grid;
place-items: center;
padding: 14px 2px;
gap: 28px;
max-width: 620px;
padding-top: 4px;
}
.card {
max-width: 420px;
width: min(420px, 92vw);
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
border-radius: 16px;
padding: 14px;
box-sizing: border-box;
}
.tabs {
display: grid;
grid-template-columns: 1fr 1fr;
.authTabs {
display: inline-flex;
gap: 8px;
margin-bottom: 10px;
width: fit-content;
padding: 6px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
}
.tab {
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.12);
color: rgba(255, 255, 255, 0.9);
font-weight: 800;
.authTabs__button {
min-width: 112px;
padding: 10px 16px;
border: 0;
border-radius: 999px;
background: transparent;
color: rgba(255, 255, 255, 0.62);
font-weight: 700;
cursor: pointer;
}
.tab--active {
background: rgba(96, 165, 250, 0.18);
border-color: rgba(255, 255, 255, 0.16);
.authTabs__button--active {
background: rgba(76, 133, 245, 0.22);
color: rgba(255, 255, 255, 0.96);
}
.label {
display: block;
.authFields {
display: grid;
gap: 20px;
}
.field {
display: grid;
gap: 8px;
}
.field__label {
font-size: 13px;
opacity: 0.78;
margin-top: 10px;
margin-bottom: 6px;
color: rgba(255, 255, 255, 0.62);
}
.input {
.field__input {
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);
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;
box-sizing: border-box;
font-size: 18px;
letter-spacing: -0.02em;
}
.btn {
margin-top: 12px;
width: 100%;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(96, 165, 250, 0.2);
color: rgba(255, 255, 255, 0.92);
.field__input:focus {
border-bottom-color: rgba(96, 165, 250, 0.9);
}
.field__hint {
font-size: 12px;
color: rgba(255, 255, 255, 0.42);
}
.roleBadge {
width: fit-content;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(96, 165, 250, 0.28);
background: rgba(96, 165, 250, 0.1);
color: rgba(191, 219, 254, 0.92);
font-size: 12px;
font-weight: 700;
}
.authActions {
display: flex;
gap: 12px;
flex-wrap: wrap;
padding-top: 8px;
}
.primaryAction,
.secondaryAction {
padding: 12px 18px;
border-radius: 999px;
font-weight: 700;
cursor: pointer;
font-weight: 800;
}
.btn:hover {
background: rgba(96, 165, 250, 0.26);
.primaryAction {
border: 1px solid rgba(76, 133, 245, 0.96);
background: rgba(76, 133, 245, 0.92);
color: #fff;
}
.hint {
margin-top: 10px;
opacity: 0.72;
font-size: 13px;
.secondaryAction {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.86);
}
@media (max-width: 720px) {
.authTabs {
width: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.authTabs__button {
min-width: 0;
}
}
</style>

View File

@@ -69,12 +69,12 @@ async function removeList(t) {
</script>
<template>
<section class="wrap">
<header class="head">
<div>
<div class="head__eyebrow">Library</div>
<h2 class="title"> 티어표</h2>
<div class="desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 있어요.</div>
<section class="pageWrap">
<header class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Library</div>
<h2 class="pageHead__title"> 티어표</h2>
<div class="pageHead__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 있어요.</div>
</div>
</header>
<div class="card">
@@ -109,33 +109,6 @@ async function removeList(t) {
</template>
<style scoped>
.wrap {
padding: 4px 2px;
}
.head {
display: flex;
gap: 16px;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
margin-bottom: 18px;
padding: 6px 2px 8px;
}
.head__eyebrow {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.42);
}
.title {
margin: 4px 0 6px;
font-size: 32px;
letter-spacing: -0.04em;
color: rgba(255, 255, 255, 0.96);
}
.desc {
color: rgba(255, 255, 255, 0.58);
}
.card {
border: 0;
background: transparent;
@@ -221,19 +194,23 @@ async function removeList(t) {
.boardCard__head {
padding: 16px 18px 18px;
display: grid;
gap: 10px;
gap: 6px;
}
.boardCard__titleRow,
.boardCard__metaRow {
display: flex;
gap: 12px;
gap: 10px;
align-items: center;
justify-content: space-between;
}
.boardCard__metaRow {
align-items: flex-end;
}
.boardCard__author {
min-width: 0;
display: inline-flex;
gap: 8px;
gap: 7px;
align-items: center;
font-size: 13px;
opacity: 0.84;
@@ -247,7 +224,7 @@ async function removeList(t) {
.boardCard__avatar {
width: 22px;
height: 22px;
border-radius: 6px;
border-radius: 9999px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
@@ -266,6 +243,9 @@ async function removeList(t) {
color: rgba(255, 255, 255, 0.64);
white-space: nowrap;
}
.boardCard__date {
font-size: 10px;
}
.link--danger {
background: rgba(239, 68, 68, 0.14);
border-color: rgba(239, 68, 68, 0.28);

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { toApiUrl } from '../lib/runtime'
@@ -14,6 +14,8 @@ const saving = ref(false)
const nickname = ref('')
const previewUrl = ref('')
const avatarFile = ref(null)
const removeAvatar = ref(false)
const fileInput = ref(null)
watch(error, (message) => {
if (!message) return
@@ -23,26 +25,53 @@ watch(error, (message) => {
const avatarUrl = computed(() => {
if (previewUrl.value) return previewUrl.value
if (removeAvatar.value) return ''
if (!auth.user?.avatarSrc) return ''
return toApiUrl(auth.user.avatarSrc)
})
const displayInitial = computed(() => {
const email = auth.user?.email || 'U'
return email[0].toUpperCase()
})
onMounted(async () => {
await auth.refresh()
if (!auth.user) router.push('/login')
nickname.value = auth.user?.nickname || ''
removeAvatar.value = false
})
onBeforeUnmount(() => {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
})
function openAvatarPicker() {
fileInput.value?.click()
}
function onAvatarChange(e) {
const file = e.target.files && e.target.files[0]
if (!file) return
error.value = ''
removeAvatar.value = false
avatarFile.value = file
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = URL.createObjectURL(file)
}
function clearAvatar() {
error.value = ''
avatarFile.value = null
removeAvatar.value = true
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
}
if (fileInput.value) fileInput.value.value = ''
}
async function saveProfile() {
error.value = ''
saving.value = true
@@ -50,6 +79,8 @@ async function saveProfile() {
const fd = new FormData()
fd.append('nickname', nickname.value)
if (avatarFile.value) fd.append('avatar', avatarFile.value)
if (removeAvatar.value) fd.append('removeAvatar', '1')
const res = await fetch(toApiUrl('/api/auth/profile'), {
method: 'POST',
credentials: 'include',
@@ -59,10 +90,12 @@ async function saveProfile() {
const data = await res.json()
auth.user = data.user
avatarFile.value = null
removeAvatar.value = false
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
}
if (fileInput.value) fileInput.value.value = ''
toast.success('프로필을 저장했어요.')
} catch (e2) {
error.value = '프로필 저장에 실패했어요.'
@@ -79,145 +112,280 @@ async function logout() {
</script>
<template>
<section class="wrap">
<h2 class="title">프로필</h2>
<div class="card" v-if="auth.user">
<div class="row">
<div class="avatar">
<img v-if="avatarUrl" :src="avatarUrl" class="avatarImg" alt="avatar" />
<div v-else class="avatarFallback">{{ (auth.user.email || 'U')[0].toUpperCase() }}</div>
</div>
<div class="meta">
<div class="email">{{ auth.user.email }}</div>
<input v-model="nickname" class="nicknameInput" placeholder="작성자 닉네임" />
<div class="badge" v-if="auth.user.isAdmin">admin</div>
</div>
<section class="pageWrap">
<header class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Account</div>
<h2 class="pageHead__title">Settings</h2>
<div class="pageHead__desc">계정 정보를 간결하게 정리하고, 프로필 이미지를 클릭해서 바로 변경할 있어요.</div>
</div>
</header>
<div class="upload">
<label class="label">아바타 업로드</label>
<input class="file" type="file" accept="image/*" :disabled="saving" @change="onAvatarChange" />
<div class="hint">파일 선택은 미리보기만 바뀌고, 실제 반영은 저장 버튼을 눌렀을 진행됩니다.</div>
<div class="actions">
<button class="saveBtn" :disabled="saving" @click="saveProfile">
{{ saving ? '저장중...' : '프로필 저장' }}
<section v-if="auth.user" class="settingsScreen">
<div class="settingsIdentity">
<div class="avatarButtonWrap">
<button class="avatarButton" type="button" @click="openAvatarPicker">
<img v-if="avatarUrl" :src="avatarUrl" class="avatarButton__image" alt="avatar" />
<div v-else class="avatarButton__fallback">{{ displayInitial }}</div>
<div class="avatarButton__overlay">
<span>{{ avatarUrl ? '이미지 변경' : '이미지 추가' }}</span>
</div>
</button>
<button
v-if="avatarUrl || previewUrl"
class="avatarButton__remove"
type="button"
aria-label="프로필 이미지 삭제"
@click="clearAvatar"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
<button class="logoutBtn" type="button" @click="logout">로그아웃</button>
</div>
<div class="identityMeta">
<div class="identityMeta__eyebrow">Profile Photo</div>
<div class="identityMeta__title">프로필 이미지</div>
<div class="identityMeta__desc">아바타를 클릭해서 이미지를 추가하거나 교체할 있습니다.</div>
</div>
<input ref="fileInput" class="hiddenInput" type="file" accept="image/*" :disabled="saving" @change="onAvatarChange" />
</div>
</div>
<div class="settingsFields">
<label class="field">
<span class="field__label">닉네임</span>
<input v-model="nickname" class="field__input" placeholder="작성자 닉네임" />
<span class="field__hint">티어표 작성자 이름으로 표시됩니다.</span>
</label>
<label class="field">
<span class="field__label">이메일</span>
<input :value="auth.user.email" class="field__input field__input--readonly" readonly />
<span class="field__hint">현재 로그인에 사용 중인 계정입니다.</span>
</label>
<div v-if="auth.user.isAdmin" class="roleBadge">Administrator</div>
</div>
<div class="settingsActions">
<button class="primaryAction" :disabled="saving" @click="saveProfile">{{ saving ? '저장 중...' : '변경사항 저장' }}</button>
<button class="secondaryAction" type="button" @click="logout">로그아웃</button>
</div>
</section>
</section>
</template>
<style scoped>
.wrap {
padding: 10px 2px;
.settingsScreen {
display: grid;
gap: 32px;
max-width: 620px;
padding-top: 4px;
}
.title {
margin: 0 0 10px;
font-size: 26px;
letter-spacing: -0.02em;
}
.card {
max-width: 520px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
border-radius: 16px;
padding: 14px;
}
.row {
display: flex;
gap: 12px;
.settingsIdentity {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
gap: 24px;
align-items: center;
}
.avatar {
width: 68px;
height: 68px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(0, 0, 0, 0.16);
.avatarButtonWrap {
position: relative;
width: 120px;
height: 120px;
}
.avatarButton {
position: relative;
width: 120px;
height: 120px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 9999px;
background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.92);
overflow: hidden;
cursor: pointer;
display: grid;
place-items: center;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.avatarImg {
.avatarButton__image {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatarFallback {
.avatarButton__fallback {
font-size: 34px;
font-weight: 900;
font-size: 20px;
opacity: 0.9;
color: rgba(255, 255, 255, 0.86);
}
.meta {
.avatarButton__overlay {
position: absolute;
inset: auto 0 0 0;
padding: 12px 10px;
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.72));
font-size: 12px;
font-weight: 700;
color: rgba(255, 255, 255, 0.82);
}
.avatarButton__remove {
position: absolute;
top: 0;
right: 0;
width: 30px;
height: 30px;
border: 0;
border-radius: 999px;
background: rgba(10, 10, 10, 0.72);
color: rgba(255, 255, 255, 0.88);
display: grid;
place-items: center;
cursor: pointer;
z-index: 2;
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(10px);
}
.avatarButton__remove svg {
width: 14px;
height: 14px;
stroke: currentColor;
stroke-width: 2.1;
fill: none;
stroke-linecap: round;
}
.avatarButton__remove:hover {
background: rgba(190, 24, 24, 0.88);
color: #fff;
}
.identityMeta {
display: grid;
gap: 6px;
flex: 1;
}
.email {
font-weight: 900;
.identityMeta__eyebrow {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.36);
}
.nicknameInput {
.identityMeta__title {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.03em;
}
.identityMeta__desc {
color: rgba(255, 255, 255, 0.58);
line-height: 1.6;
}
.hiddenInput {
display: none;
}
.settingsFields {
display: grid;
gap: 20px;
}
.field {
display: grid;
gap: 8px;
}
.field__label {
font-size: 13px;
color: rgba(255, 255, 255, 0.62);
}
.field__input {
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);
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;
box-sizing: border-box;
font-size: 18px;
letter-spacing: -0.02em;
}
.badge {
.field__input:focus {
border-bottom-color: rgba(96, 165, 250, 0.9);
}
.field__input--readonly {
color: rgba(255, 255, 255, 0.58);
}
.field__hint {
font-size: 12px;
padding: 2px 8px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 999px;
color: rgba(255, 255, 255, 0.42);
}
.roleBadge {
width: fit-content;
opacity: 0.9;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(96, 165, 250, 0.28);
background: rgba(96, 165, 250, 0.1);
color: rgba(191, 219, 254, 0.92);
font-size: 12px;
font-weight: 700;
}
.upload {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.label {
display: block;
font-size: 13px;
opacity: 0.78;
margin-bottom: 6px;
}
.file {
width: 100%;
}
.hint {
margin-top: 8px;
opacity: 0.72;
font-size: 13px;
}
.saveBtn {
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(96, 165, 250, 0.2);
color: rgba(255, 255, 255, 0.92);
cursor: pointer;
font-weight: 800;
}
.actions {
margin-top: 12px;
.settingsActions {
display: flex;
gap: 10px;
gap: 12px;
flex-wrap: wrap;
padding-top: 8px;
}
.logoutBtn {
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(239, 68, 68, 0.24);
background: rgba(239, 68, 68, 0.12);
color: rgba(255, 255, 255, 0.92);
.primaryAction,
.secondaryAction {
padding: 12px 18px;
border-radius: 999px;
font-weight: 700;
cursor: pointer;
font-weight: 800;
}
.primaryAction {
border: 1px solid rgba(76, 133, 245, 0.96);
background: rgba(76, 133, 245, 0.92);
color: #fff;
}
.secondaryAction {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.86);
}
@media (max-width: 720px) {
.settingsIdentity {
grid-template-columns: 1fr;
}
.avatarButtonWrap {
width: 108px;
height: 108px;
}
.avatarButton {
width: 108px;
height: 108px;
}
}
</style>

View File

@@ -198,15 +198,19 @@ watch(
.boardCard__head {
padding: 16px 18px 18px;
display: grid;
gap: 10px;
gap: 6px;
}
.boardCard__titleRow,
.boardCard__metaRow {
display: flex;
gap: 12px;
gap: 10px;
align-items: center;
justify-content: space-between;
}
.boardCard__metaRow {
align-items: flex-end;
}
.boardCard__title {
min-width: 0;
font-weight: 800;
@@ -232,7 +236,7 @@ watch(
.boardCard__avatar {
width: 22px;
height: 22px;
border-radius: 6px;
border-radius: 9999px;
object-fit: cover;
background: rgba(255, 255, 255, 0.08);
flex: 0 0 auto;
@@ -250,6 +254,13 @@ watch(
color: rgba(255, 255, 255, 0.64);
white-space: nowrap;
}
.boardCard__date {
font-size: 10px;
}
.boardCard__date {
font-size: 10px;
}
@media (max-width: 1400px) {
.list {
grid-template-columns: repeat(3, minmax(0, 1fr));

View File

@@ -36,21 +36,26 @@ const pendingThumbnailFile = ref(null)
const thumbnailPreviewUrl = ref('')
const description = ref('')
const isPublic = ref(true)
const showCharacterNames = ref(false)
const error = ref('')
const isSaving = ref(false)
const isExporting = ref(false)
const isSaveModalOpen = ref(false)
const isTemplateRequestModalOpen = ref(false)
const isTemplateUpdateModalOpen = ref(false)
const isDeleteModalOpen = ref(false)
const ownerId = ref('')
const authorName = ref('')
const authorAccountName = ref('')
const updatedAt = ref(0)
const isDragActive = ref(false)
const isThumbnailDragActive = ref(false)
const iconSize = ref(80)
const isFavoriteBusy = ref(false)
const favoriteCount = ref(0)
const isFavorited = ref(false)
const isRequestingTemplate = ref(false)
const isDeleting = ref(false)
const boardEl = ref(null)
const exportBoardEl = ref(null)
@@ -108,6 +113,7 @@ const templateRequestChecks = computed(() => [
},
])
const canSubmitTemplateCreateRequest = computed(() => templateRequestChecks.value.every((item) => item.passed))
const templateRequestTargetLabel = computed(() => (gameId.value === 'freeform' ? '새로운 템플릿' : (gameName.value || gameId.value || '선택한 게임')))
watch(error, (message) => {
if (!message) return
@@ -304,14 +310,48 @@ function openThumbnailFile() {
thumbnailFileEl.value?.click()
}
function onThumbnailChange(event) {
const file = event.target.files?.[0]
function applyThumbnailFile(file) {
if (!file || !file.type.startsWith('image/')) return
if (thumbnailPreviewUrl.value) {
URL.revokeObjectURL(thumbnailPreviewUrl.value)
thumbnailPreviewUrl.value = ''
}
pendingThumbnailFile.value = file || null
if (file) thumbnailPreviewUrl.value = URL.createObjectURL(file)
pendingThumbnailFile.value = file
thumbnailPreviewUrl.value = URL.createObjectURL(file)
}
function onThumbnailDragEnter() {
if (!canEdit.value) return
isThumbnailDragActive.value = true
}
function onThumbnailDragLeave(event) {
if (!event.currentTarget.contains(event.relatedTarget)) {
isThumbnailDragActive.value = false
}
}
function onThumbnailDrop(event) {
if (!canEdit.value) return
isThumbnailDragActive.value = false
const files = Array.from(event.dataTransfer?.files || []).filter((file) => file.type.startsWith('image/'))
if (!files.length) return
if (files.length > 1) {
toast.info('대표 썸네일은 하나만 설정할 수 있어요. 첫 번째 이미지를 사용할게요.')
}
applyThumbnailFile(files[0])
}
function onThumbnailChange(event) {
const files = Array.from(event.target.files || []).filter((file) => file.type.startsWith('image/'))
if (!files.length) {
event.target.value = ''
return
}
if (files.length > 1) {
toast.info('대표 썸네일은 하나만 설정할 수 있어요. 첫 번째 이미지를 사용할게요.')
}
applyThumbnailFile(files[0])
event.target.value = ''
}
@@ -430,6 +470,7 @@ function buildPayload(existingId) {
thumbnailSrc: thumbnailSrc.value || '',
description: (description.value || '').trim(),
isPublic: !!isPublic.value,
showCharacterNames: !!showCharacterNames.value,
groups: groups.value.map((g) => ({ id: g.id, name: g.name, itemIds: g.itemIds })),
pool: Object.values(itemsById.value),
}
@@ -477,17 +518,35 @@ function closeTemplateRequestModal() {
isTemplateRequestModalOpen.value = false
}
async function removeTierList() {
if (!canEdit.value || isNewTierList.value) return
function openTemplateUpdateModal() {
isTemplateUpdateModalOpen.value = true
}
function closeTemplateUpdateModal() {
isTemplateUpdateModalOpen.value = false
}
function openDeleteModal() {
isDeleteModalOpen.value = true
}
function closeDeleteModal() {
isDeleteModalOpen.value = false
}
async function confirmDeleteTierList() {
if (!canEdit.value || isNewTierList.value || isDeleting.value) return
error.value = ''
try {
const ok = window.confirm(`"${title.value || gameName.value || '이 티어표'}"를 삭제할까요?`)
if (!ok) return
isDeleting.value = true
await api.deleteTierList(tierListId.value)
closeDeleteModal()
toast.success('티어표를 삭제했어요.')
router.push(gameId.value === 'freeform' ? '/me' : `/games/${gameId.value}`)
} catch (e) {
error.value = '티어표 삭제에 실패했어요.'
} finally {
isDeleting.value = false
}
}
@@ -517,6 +576,7 @@ async function requestTemplate(type) {
const persisted = await persistTierList({ showModal: false })
await api.requestTierListTemplate(persisted.savedTierListId, { type })
if (type === 'create') closeTemplateRequestModal()
if (type === 'update') closeTemplateUpdateModal()
toast.success(type === 'create' ? '템플릿 등록 요청을 보냈어요.' : '템플릿 업데이트 요청을 보냈어요.')
} catch (e) {
if (e?.status === 400 && e?.data?.error === 'title_required') {
@@ -574,6 +634,7 @@ onMounted(() => {
thumbnailSrc.value = t.thumbnailSrc || ''
description.value = t.description || ''
isPublic.value = !!t.isPublic
showCharacterNames.value = !!t.showCharacterNames
authorName.value = t.authorName || ''
authorAccountName.value = t.authorAccountName || ''
updatedAt.value = Number(t.updatedAt || 0)
@@ -615,6 +676,7 @@ onUnmounted(() => {
<div class="previewOnly__drop">
<div v-for="id in g.itemIds" :key="id" class="previewOnly__cell">
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
</div>
</div>
</div>
@@ -671,6 +733,39 @@ onUnmounted(() => {
</div>
</div>
<div v-if="isTemplateUpdateModalOpen" class="modalOverlay" @click.self="closeTemplateUpdateModal">
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="templateUpdateTitle">
<div id="templateUpdateTitle" class="modalCard__title">템플릿 요청하기</div>
<div class="modalCard__desc">
{{ templateRequestTargetLabel }} 직접 추가한 아이템을 포함해 달라고 관리자에게 요청을 보냅니다.
</div>
<div class="modalCard__note">
모두가 사용하는 기본 템플릿이니 개인적인 항목이 아닌 공통된 항목만 추가한 신청해주세요.
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeTemplateUpdateModal">요청 취소</button>
<button class="btn btn--save" :disabled="isRequestingTemplate" @click="requestTemplate('update')">
{{ isRequestingTemplate ? '요청중...' : ', 요청할게요' }}
</button>
</div>
</div>
</div>
<div v-if="isDeleteModalOpen" class="modalOverlay" @click.self="closeDeleteModal">
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="deleteTierListTitle">
<div id="deleteTierListTitle" class="modalCard__title">티어표 삭제</div>
<div class="modalCard__desc">
"{{ title || gameName || '이 티어표' }}" 삭제할까요? 삭제 후에는 복구할 없어요.
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeDeleteModal">취소</button>
<button class="btn btn--danger" :disabled="isDeleting" @click="confirmDeleteTierList">
{{ isDeleting ? '삭제중...' : '삭제하기' }}
</button>
</div>
</div>
</div>
<section class="layout" :style="{ '--thumb-size': `${iconSize}px` }">
<div class="editorMain">
<section class="head">
@@ -732,6 +827,7 @@ onUnmounted(() => {
<div v-if="!isExporting" class="row__empty" v-show="g.itemIds.length === 0">여기로 드래그해서 배치</div>
<div v-for="id in g.itemIds" :key="id" class="cell" :data-item-id="id">
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
<button
v-if="canEdit && !isExporting"
class="cellRemoveBtn"
@@ -751,12 +847,31 @@ onUnmounted(() => {
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
</div>
</div>
<div
v-if="canEdit"
class="dropzone dropzone--board"
:class="{ 'dropzone--active': isDragActive }"
@dragenter.prevent="onDragEnter"
@dragover.prevent="onDragEnter"
@dragleave="onDragLeave"
@drop.prevent="onDropFiles"
>
<div>
<div class="dropzone__title">커스텀 이미지 추가</div>
<div class="dropzone__desc">이곳으로 이미지를 드래그하거나 파일 선택으로 번에 추가할 있어요.</div>
</div>
<div class="dropzone__actions">
<input ref="fileEl" type="file" accept="image/*" multiple class="hidden" @change="onFileChange" />
<button class="btn btn--ghost dropzone__button" @click="openFile">파일 선택</button>
</div>
</div>
</div>
<div class="sidebar">
<div class="sidebar__title">아이템</div>
<div class="sidebar__hint">
{{ canEdit ? '보드 바로 옆에서 드래그해 넣을 수 있도록 아이템 풀을 고정합니다.' : '공개 티어표는 보기 전용입니다.' }}
{{ 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">
@@ -764,22 +879,9 @@ onUnmounted(() => {
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
</div>
</div>
<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>
</div>
</div>
</section>
@@ -805,16 +907,24 @@ onUnmounted(() => {
<div class="editorSidebar__section">
<div class="editorSidebar__label">대표 썸네일</div>
<input ref="thumbnailFileEl" type="file" accept="image/*" class="hidden" @change="onThumbnailChange" />
<div class="editorSidebar__thumbFrame">
<div
class="editorSidebar__thumbFrame"
:class="{ 'editorSidebar__thumbFrame--active': isThumbnailDragActive }"
@dragenter.prevent="onThumbnailDragEnter"
@dragover.prevent="onThumbnailDragEnter"
@dragleave="onThumbnailDragLeave"
@drop.prevent="onThumbnailDrop"
>
<img v-if="displayThumbnailUrl" class="editorSidebar__thumbImage" :src="displayThumbnailUrl" alt="썸네일 미리보기" />
<div v-else class="editorSidebar__thumbEmpty">대표 썸네일</div>
<div class="editorSidebar__thumbOverlay">드래그 또는 클릭으로 썸네일 추가</div>
</div>
<button v-if="canEdit" class="btn btn--ghost editorSidebar__button" @click="openThumbnailFile">파일 업로드</button>
<div v-if="pendingThumbnailFile" class="editorSidebar__fileName">{{ pendingThumbnailFile.name }}</div>
</div>
<div class="editorSidebar__section">
<button v-if="canFavorite" class="editorSidebar__favorite" :disabled="isFavoriteBusy" @click="toggleFavorite">
<div v-if="canFavorite" class="editorSidebar__section">
<button class="editorSidebar__favorite" :disabled="isFavoriteBusy" @click="toggleFavorite">
<span> 즐겨찾기</span>
<span>{{ favoriteCount }}</span>
</button>
@@ -846,27 +956,33 @@ onUnmounted(() => {
<input v-model="isPublic" type="checkbox" :disabled="!canEdit" />
<span>공개</span>
</label>
<label class="toggle" :class="{ 'toggle--disabled': !canEdit }">
<input v-model="showCharacterNames" type="checkbox" :disabled="!canEdit" />
<span>캐릭터 이름 표시</span>
</label>
<div class="editorSidebar__actionGrid">
<button class="btn btn--ghost editorSidebar__button" @click="downloadImage">이미지 다운로드</button>
<button v-if="canEdit" class="btn btn--save editorSidebar__button" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
</div>
<button v-if="canEdit && !isNewTierList" class="btn btn--danger editorSidebar__button" @click="removeTierList">삭제</button>
<button
v-if="canRequestTemplateCreate"
class="btn btn--ghost editorSidebar__button"
:disabled="isRequestingTemplate"
@click="openTemplateRequestModal"
>
템플릿 등록 요청
</button>
<button
v-if="canRequestTemplateUpdate"
class="btn btn--ghost editorSidebar__button"
:disabled="isRequestingTemplate"
@click="requestTemplate('update')"
>
{{ isRequestingTemplate ? '요청중...' : '템플릿 업데이트 요청' }}
</button>
<div class="editorSidebar__utilityLinks">
<button v-if="canEdit && !isNewTierList" class="editorSidebar__utilityLink editorSidebar__utilityLink--danger" @click="openDeleteModal">삭제하기</button>
<button
v-if="canRequestTemplateCreate"
class="editorSidebar__utilityLink"
:disabled="isRequestingTemplate"
@click="openTemplateRequestModal"
>
템플릿 등록 요청
</button>
<button
v-if="canRequestTemplateUpdate"
class="editorSidebar__utilityLink"
:disabled="isRequestingTemplate"
@click="openTemplateUpdateModal"
>
{{ isRequestingTemplate ? '요청중...' : '템플릿 업데이트 요청' }}
</button>
</div>
</div>
</template>
</Teleport>
@@ -886,7 +1002,7 @@ onUnmounted(() => {
}
.editorCanvas {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
grid-template-columns: minmax(0, clamp(680px, 58vw, 960px)) minmax(280px, 1fr);
gap: 16px;
align-items: start;
}
@@ -958,6 +1074,7 @@ onUnmounted(() => {
}
.previewOnly__cell {
display: inline-flex;
position: relative;
}
.previewOnly__pool {
display: grid;
@@ -975,6 +1092,7 @@ onUnmounted(() => {
}
.previewOnly__poolItem {
display: inline-flex;
position: relative;
}
.toggle {
display: inline-flex;
@@ -1051,6 +1169,7 @@ onUnmounted(() => {
background: rgba(239, 68, 68, 0.12);
}
.board {
width: min(100%, 960px);
border: 1px solid rgba(255, 255, 255, 0.1);
background: linear-gradient(180deg, rgba(55, 55, 55, 0.86), rgba(42, 42, 42, 0.82));
border-radius: 22px;
@@ -1092,6 +1211,7 @@ onUnmounted(() => {
justify-content: flex-end;
margin-top: 8px;
flex-wrap: wrap;
gap: 8px;
}
.modalCard__actions .btn {
width: auto;
@@ -1309,6 +1429,22 @@ onUnmounted(() => {
flex: 0 0 auto;
position: relative;
}
.itemNameOverlay {
position: absolute;
inset: auto 0 0 0;
padding: 16px 8px 6px;
border-radius: 0 0 10px 10px;
background: linear-gradient(180deg, rgba(7, 10, 18, 0), rgba(7, 10, 18, 0.92));
color: rgba(255, 255, 255, 0.96);
font-size: 11px;
line-height: 1.25;
font-weight: 800;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.cellRemoveBtn {
position: absolute;
top: -6px;
@@ -1340,6 +1476,7 @@ onUnmounted(() => {
object-fit: cover;
}
.sidebar {
min-width: 0;
border: 1px solid rgba(255, 255, 255, 0.1);
background: linear-gradient(180deg, rgba(52, 52, 52, 0.84), rgba(36, 36, 36, 0.8));
border-radius: 22px;
@@ -1348,6 +1485,25 @@ onUnmounted(() => {
position: sticky;
top: 14px;
}
.dropzone--board {
margin-top: 18px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.dropzone__actions {
display: flex;
align-items: center;
gap: 12px;
flex: 0 0 auto;
}
.dropzone__button {
min-width: 148px;
}
.editorSidebar__section {
display: grid;
gap: 10px;
@@ -1381,11 +1537,13 @@ onUnmounted(() => {
font-size: 12px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.56);
word-break: keep-all;
}
.editorSidebar__hint--warn {
color: rgba(251, 191, 36, 0.92);
}
.editorSidebar__thumbFrame {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 16px;
@@ -1393,6 +1551,11 @@ onUnmounted(() => {
border: 1px solid rgba(255, 255, 255, 0.08);
background: #4c4c4c;
}
.editorSidebar__thumbFrame--active {
border-color: rgba(96, 165, 250, 0.8);
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.18);
}
.editorSidebar__thumbImage {
width: 100%;
height: 100%;
@@ -1406,6 +1569,16 @@ onUnmounted(() => {
color: rgba(255, 255, 255, 0.36);
font-size: 13px;
}
.editorSidebar__thumbOverlay {
position: absolute;
inset: auto 0 0 0;
padding: 10px 12px;
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.72));
color: rgba(255, 255, 255, 0.82);
font-size: 12px;
font-weight: 700;
}
.editorSidebar__button {
width: 100%;
margin-top: 0;
@@ -1437,6 +1610,33 @@ onUnmounted(() => {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.editorSidebar__utilityLinks {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding-top: 4px;
}
.editorSidebar__utilityLink {
border: 0;
padding: 0;
background: transparent;
color: rgba(255, 255, 255, 0.74);
font-size: 14px;
cursor: pointer;
}
.editorSidebar__utilityLink:disabled {
cursor: default;
opacity: 0.5;
}
.editorSidebar__utilityLink--danger {
color: rgba(248, 113, 113, 0.96);
}
.sidebar__title {
font-weight: 900;
margin-bottom: 8px;
@@ -1448,6 +1648,7 @@ onUnmounted(() => {
font-size: 13px;
margin-bottom: 12px;
line-height: 1.5;
word-break: keep-all;
}
.customItemEditor {
margin-top: 0;
@@ -1504,7 +1705,6 @@ onUnmounted(() => {
border-radius: 16px;
border: 1px dashed rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.04);
text-align: center;
}
.dropzone--active {
border-color: rgba(110, 231, 183, 0.6);
@@ -1521,21 +1721,35 @@ onUnmounted(() => {
}
.pool {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
gap: 10px;
align-content: start;
}
.poolItem {
min-width: 0;
display: grid;
grid-template-columns: var(--thumb-size, 80px) minmax(0, 1fr);
gap: 10px;
align-items: center;
padding: 10px;
grid-template-columns: 1fr;
justify-items: center;
align-content: start;
gap: 8px;
padding: 10px 8px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.10);
background: rgba(0, 0, 0, 0.18);
}
.poolItem .thumb {
width: 100%;
max-width: var(--thumb-size, 80px);
height: auto;
aspect-ratio: 1 / 1;
}
.poolItem__label {
width: 100%;
min-width: 0;
font-size: 11px;
line-height: 1.35;
font-weight: 800;
text-align: center;
opacity: 0.9;
overflow: hidden;
text-overflow: ellipsis;
@@ -1567,9 +1781,17 @@ onUnmounted(() => {
.sidebar {
position: static;
}
.pool {
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 8px;
}
.editorSidebar__actionGrid {
grid-template-columns: 1fr;
}
.editorSidebar__utilityLinks {
flex-direction: column;
align-items: flex-start;
}
.requestChecklist__item {
grid-template-columns: 1fr;
}
@@ -1587,5 +1809,13 @@ onUnmounted(() => {
.previewOnly__row {
grid-template-columns: 1fr;
}
.pool {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.pool {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
</style>