릴리스: v0.1.11 관리자 레이아웃과 자유 티어표 흐름 정리

This commit is contained in:
2026-03-19 16:20:06 +09:00
parent f9ae036890
commit c71a19873d
11 changed files with 268 additions and 230 deletions

View File

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

View File

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

View File

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