Compare commits

..

1 Commits

Author SHA1 Message Date
b5ec579e5d 릴리스: v1.2.58 관리자 아이템 관리 compact 카드 전환 2026-03-31 15:00:07 +09:00
2 changed files with 150 additions and 70 deletions

View File

@@ -1,5 +1,9 @@
# 업데이트 로그
## 2026-03-31 v1.2.58
- 관리자 아이템 관리 카드를 썸네일과 제목만 보이는 compact 카드로 줄여, 대량 업로드된 이미지도 훨씬 높은 밀도로 탐색할 수 있게 정리함.
- 카드 클릭 시 상세 정보를 모달로 열고 이미지 다운로드, 기본 템플릿 추가, 삭제를 모달 안에서 결정하는 흐름으로 바꿈.
## 2026-03-31 v1.2.57
- 관리자 오른쪽 사이드에서 Featured, Game Summary, Users 패널을 완전히 제거하고, 티어표 요청 모드에는 모드 전환 탭만 남기도록 정리함.

View File

@@ -48,9 +48,12 @@ const previewTierList = ref(null)
const userPasswordModalOpen = ref(false)
const userDeleteModalOpen = ref(false)
const userRoleModalOpen = ref(false)
const customItemModalOpen = ref(false)
const customItemDeleteModalOpen = ref(false)
const modalTargetUser = ref(null)
const modalPasswordDraft = ref('')
const modalRoleNextAdmin = ref(false)
const modalTargetCustomItem = ref(null)
const users = ref([])
@@ -855,18 +858,42 @@ function moveCustomItemPage(direction) {
refreshCustomItems()
}
async function removeCustomItem(item) {
function openCustomItemModal(item) {
modalTargetCustomItem.value = item || null
customItemModalOpen.value = true
}
function closeCustomItemModal() {
customItemModalOpen.value = false
modalTargetCustomItem.value = null
}
function openCustomItemDeleteModal(item) {
if (!item) return
if (item.usageCount > 0) {
error.value = '사용 중인 커스텀 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
return
}
modalTargetCustomItem.value = item
customItemDeleteModalOpen.value = true
}
function closeCustomItemDeleteModal() {
customItemDeleteModalOpen.value = false
}
async function removeCustomItem(item = modalTargetCustomItem.value) {
resetMessages()
if (!item) return
if (item.usageCount > 0) {
error.value = '사용 중인 커스텀 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
return
}
const ok = window.confirm(`"${item.label}" 미사용 커스텀 이미지를 삭제할까요?`)
if (!ok) return
try {
await api.deleteAdminCustomItem(item.id)
closeCustomItemDeleteModal()
closeCustomItemModal()
await refreshCustomItems()
success.value = '미사용 커스텀 이미지를 삭제했어요.'
} catch (e) {
@@ -900,6 +927,7 @@ async function promoteCustomItem(item) {
await api.promoteAdminCustomItem(item.id, { gameId: customItemTargetGameId.value })
const targetGameName = games.value.find((game) => game.id === customItemTargetGameId.value)?.name || customItemTargetGameId.value
if (selectedGameId.value === customItemTargetGameId.value) await loadGame()
closeCustomItemModal()
success.value = `"${item.label}" 아이템을 ${targetGameName} 기본 템플릿으로 추가했어요.`
} catch (e) {
error.value = '커스텀 아이템을 기본 템플릿으로 가져오지 못했어요.'
@@ -1349,37 +1377,10 @@ async function saveFeaturedOrder() {
<div class="panel">
<div v-if="!customItems.length" class="hint">조건에 맞는 커스텀 아이템이 없어요.</div>
<div v-else class="customItemGrid">
<article v-for="item in customItems" :key="item.id" class="customItemCard">
<button v-for="item in customItems" :key="item.id" type="button" class="customItemCard" @click="openCustomItemModal(item)">
<img class="customItemCard__image" :src="toApiUrl(item.src)" :alt="item.label" />
<div class="customItemCard__body">
<div class="customItemCard__title" :title="item.label">{{ item.label }}</div>
<div class="customItemCard__metaList">
<div class="customItemCard__metaRow">
<span>파일</span>
<strong :title="item.src.split('/').pop()">{{ item.src.split('/').pop() }}</strong>
</div>
<div class="customItemCard__metaRow">
<span>업로더</span>
<strong :title="item.ownerName">{{ item.ownerName }}</strong>
</div>
<div class="customItemCard__metaRow">
<span>사용 </span>
<strong>{{ item.usageCount }} 티어표</strong>
</div>
<div class="customItemCard__metaRow">
<span>등록일</span>
<strong>{{ fmt(item.createdAt) }}</strong>
</div>
</div>
<div class="customItemCard__actions">
<a class="btn btn--small btn--ghost" :href="toApiUrl(item.src)" :download="item.label">이미지 다운로드</a>
<button class="btn btn--small btn--ghost" :disabled="!customItemTargetGameId || item.isPromoting" @click="promoteCustomItem(item)">
{{ item.isPromoting ? '추가중...' : '기본 템플릿에 추가' }}
</button>
<button class="btn btn--small btn--danger" :disabled="item.usageCount > 0" @click="removeCustomItem(item)">개별 삭제</button>
</div>
</div>
</article>
<div class="customItemCard__title" :title="item.label">{{ item.label }}</div>
</button>
</div>
<div class="pager">
@@ -1671,6 +1672,51 @@ async function saveFeaturedOrder() {
</div>
</div>
<div v-if="customItemModalOpen" class="modalOverlay" @click.self="closeCustomItemModal">
<div class="modalCard modalCard--customItem" role="dialog" aria-modal="true">
<div class="modalCard__titleRow">
<div class="modalCard__title">커스텀 아이템 상세</div>
<button class="btn btn--ghost btn--small" @click="closeCustomItemModal">닫기</button>
</div>
<div v-if="modalTargetCustomItem" class="customItemModal">
<img class="customItemModal__image" :src="toApiUrl(modalTargetCustomItem.src)" :alt="modalTargetCustomItem.label" />
<div class="customItemModal__body">
<div class="customItemModal__title">{{ modalTargetCustomItem.label }}</div>
<div class="customItemModal__metaList">
<div class="customItemModal__metaRow"><span>파일</span><strong :title="modalTargetCustomItem.src.split('/').pop()">{{ modalTargetCustomItem.src.split('/').pop() }}</strong></div>
<div class="customItemModal__metaRow"><span>업로더</span><strong :title="modalTargetCustomItem.ownerName">{{ modalTargetCustomItem.ownerName }}</strong></div>
<div class="customItemModal__metaRow"><span>사용 </span><strong>{{ modalTargetCustomItem.usageCount }} 티어표</strong></div>
<div class="customItemModal__metaRow"><span>등록일</span><strong>{{ fmt(modalTargetCustomItem.createdAt) }}</strong></div>
</div>
<div class="modalCard__form">
<select v-model="customItemTargetGameId" class="select">
<option value="">기본 템플릿에 추가할 게임 선택</option>
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }}</option>
</select>
</div>
<div class="customItemModal__actions">
<a class="btn btn--ghost" :href="toApiUrl(modalTargetCustomItem.src)" :download="modalTargetCustomItem.label">이미지 다운로드</a>
<button class="btn btn--ghost" :disabled="!customItemTargetGameId || modalTargetCustomItem.isPromoting" @click="promoteCustomItem(modalTargetCustomItem)">
{{ modalTargetCustomItem.isPromoting ? '추가중...' : '기본 템플릿에 추가' }}
</button>
<button class="btn btn--danger" :disabled="modalTargetCustomItem.usageCount > 0" @click="openCustomItemDeleteModal(modalTargetCustomItem)">삭제</button>
</div>
</div>
</div>
</div>
</div>
<div v-if="customItemDeleteModalOpen" class="modalOverlay" @click.self="closeCustomItemDeleteModal">
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title">커스텀 아이템 삭제</div>
<div class="modalCard__desc">{{ modalTargetCustomItem ? '"' + modalTargetCustomItem.label + '" 이미지를 삭제할까요? 미사용 상태의 이미지에만 삭제를 허용합니다.' : '' }}</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeCustomItemDeleteModal">취소</button>
<button class="btn btn--danger" @click="removeCustomItem()">삭제</button>
</div>
</div>
</div>
<div v-if="previewModalOpen" class="modalOverlay" @click.self="closePreviewModal">
<div class="modalCard modalCard--preview" role="dialog" aria-modal="true">
<div class="modalCard__titleRow">
@@ -2435,22 +2481,28 @@ async function saveFeaturedOrder() {
text-align: center;
}
.customItemGrid {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 280px));
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
gap: 12px;
justify-content: start;
}
.customItemCard {
appearance: none;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 18px;
background: rgba(255, 255, 255, 0.04);
overflow: hidden;
display: grid;
grid-template-rows: auto 1fr;
gap: 14px;
padding: 14px;
gap: 10px;
padding: 10px;
min-width: 0;
text-align: left;
cursor: pointer;
transition: border-color 0.18s ease, transform 0.18s ease, background 0.18s ease;
}
.customItemCard:hover {
border-color: rgba(126, 162, 255, 0.42);
background: rgba(255, 255, 255, 0.06);
transform: translateY(-1px);
}
.customItemCard__image {
width: 100%;
@@ -2460,51 +2512,69 @@ async function saveFeaturedOrder() {
border-radius: 14px;
background: rgba(0, 0, 0, 0.18);
}
.customItemCard__body {
display: grid;
gap: 12px;
min-width: 0;
align-content: start;
}
.customItemCard__actions {
display: grid;
gap: 8px;
margin-top: auto;
}
.customItemCard__actions > * {
width: 100%;
}
.customItemCard__title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-word;
font-weight: 900;
line-height: 1.35;
white-space: nowrap;
font-weight: 800;
font-size: 13px;
line-height: 1.3;
color: #ffffff;
}
.customItemCard__metaList {
.customItemModal {
display: grid;
gap: 8px;
grid-template-columns: minmax(180px, 220px) minmax(0, 1fr);
gap: 18px;
align-items: start;
}
.customItemCard__metaRow {
.customItemModal__image {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: 18px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.customItemModal__body {
display: grid;
gap: 3px;
gap: 14px;
min-width: 0;
}
.customItemCard__metaRow span {
.customItemModal__title {
font-size: 19px;
font-weight: 900;
line-height: 1.35;
word-break: break-word;
}
.customItemModal__metaList {
display: grid;
gap: 10px;
}
.customItemModal__metaRow {
display: grid;
gap: 4px;
min-width: 0;
}
.customItemModal__metaRow span {
font-size: 11px;
color: rgba(255, 255, 255, 0.46);
}
.customItemCard__metaRow strong {
.customItemModal__metaRow strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
color: rgba(255, 255, 255, 0.82);
color: rgba(255, 255, 255, 0.84);
}
.customItemModal__actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.modalCard--customItem {
width: min(760px, 100%);
}
.pager {
margin-top: 16px;
@@ -2991,6 +3061,9 @@ async function saveFeaturedOrder() {
.adminHero__stats {
grid-template-columns: 1fr;
}
.customItemModal {
grid-template-columns: 1fr;
}
.adminSidebar {
display: none;
}
@@ -3034,19 +3107,22 @@ async function saveFeaturedOrder() {
font-size: 24px;
}
.thumbGrid,
.customItemGrid,
.userList {
grid-template-columns: 1fr;
}
.customItemGrid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
.tierAdminCard__head {
display: grid;
}
.customItemCard {
align-items: stretch;
padding: 10px;
}
.customItemCard__image {
width: clamp(72px, 28vw, 120px);
flex-basis: 120px;
width: 100%;
aspect-ratio: 1 / 1;
}
}
</style>