릴리스: v0.1.38 아이템 이름 수정과 티어표 썸네일 추가

This commit is contained in:
2026-03-26 18:14:54 +09:00
parent f729b6fa82
commit 5d778e9c20
14 changed files with 293 additions and 22 deletions

View File

@@ -196,7 +196,13 @@ async function loadGame() {
try {
const data = await api.getGame(selectedGameId.value)
selectedGame.value = data
selectedGame.value = {
...data,
items: (data.items || []).map((item) => ({
...item,
draftLabel: item.label,
})),
}
} catch (e) {
error.value = '게임 정보를 불러오지 못했어요.'
}
@@ -346,6 +352,24 @@ async function removeGameItem(itemId) {
}
}
async function saveGameItemLabel(item) {
resetMessages()
if (!selectedGameId.value) return
const nextLabel = (item.draftLabel || '').trim()
if (!nextLabel) {
error.value = '아이템 이름을 입력해주세요.'
return
}
try {
await api.updateAdminGameItem(selectedGameId.value, item.id, { label: nextLabel })
await loadGame()
success.value = '기본 아이템 이름을 수정했어요.'
} catch (e) {
error.value = '기본 아이템 이름 수정에 실패했어요.'
}
}
async function removeGame() {
resetMessages()
if (!selectedGameId.value || !selectedGame.value?.game) return
@@ -708,8 +732,11 @@ async function saveFeaturedOrder() {
<div v-else class="thumbGrid">
<div v-for="item in selectedGame.items" :key="item.id" class="thumbCard">
<img class="thumb thumb--game" :src="toApiUrl(item.src)" :alt="item.label" />
<div class="thumbLabel">{{ item.label }}</div>
<button class="btn btn--danger btn--small" @click="removeGameItem(item.id)">아이템 삭제</button>
<input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" />
<div class="thumbCard__actions">
<button class="btn btn--ghost btn--small" @click="saveGameItemLabel(item)">이름 저장</button>
<button class="btn btn--danger btn--small" @click="removeGameItem(item.id)">아이템 삭제</button>
</div>
</div>
</div>
</div>
@@ -1047,6 +1074,9 @@ async function saveFeaturedOrder() {
.input--compact {
max-width: 320px;
}
.input--labelEdit {
margin-top: 10px;
}
.hint {
margin-top: 10px;
opacity: 0.78;
@@ -1251,6 +1281,11 @@ async function saveFeaturedOrder() {
opacity: 0.9;
word-break: break-word;
}
.thumbCard__actions {
margin-top: 10px;
display: grid;
gap: 8px;
}
.thumbLabel--preview {
text-align: center;
}

View File

@@ -37,6 +37,10 @@ function avatarFallbackOf(tierList) {
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
}
function tierListThumbnailUrl(tierList) {
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
}
onMounted(async () => {
try {
const [gameRes, listRes] = await Promise.all([api.getGame(gameId.value), api.listPublicTierLists(gameId.value)])
@@ -78,6 +82,10 @@ function openTierList(id) {
<div v-if="tierLists.length === 0" class="empty">아직 공개 티어표가 없어요.</div>
<div v-else class="list">
<button v-for="t in tierLists" :key="t.id" class="row" @click="openTierList(t.id)">
<div class="row__thumbWrap">
<img v-if="tierListThumbnailUrl(t)" class="row__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
<div v-else class="row__thumbPlaceholder"></div>
</div>
<div class="row__head">
<div class="row__title">{{ t.title }}</div>
<div class="row__author">
@@ -153,7 +161,7 @@ function openTierList(id) {
}
.row {
text-align: left;
padding: 14px;
padding: 0;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
@@ -164,10 +172,28 @@ function openTierList(id) {
gap: 10px;
align-content: start;
min-height: 168px;
overflow: hidden;
}
.row:hover {
background: rgba(255, 255, 255, 0.05);
}
.row__thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
background: rgba(255, 255, 255, 0.03);
}
.row__thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.row__thumbPlaceholder {
width: 100%;
height: 100%;
background:
linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
}
.row__title {
font-weight: 800;
min-width: 0;
@@ -175,6 +201,7 @@ function openTierList(id) {
line-height: 1.35;
}
.row__head {
padding: 14px 14px 0;
display: grid;
gap: 12px;
align-content: start;
@@ -202,6 +229,7 @@ function openTierList(id) {
font-weight: 900;
}
.row__meta {
padding: 0 14px 14px;
opacity: 0.78;
font-size: 13px;
margin-top: auto;

View File

@@ -30,6 +30,10 @@ function avatarFallbackOf(tierList) {
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
}
function tierListThumbnailUrl(tierList) {
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
}
onMounted(async () => {
try {
const data = await api.listMyTierLists()
@@ -68,6 +72,10 @@ async function removeList(t) {
<div v-else class="list">
<article v-for="t in myLists" :key="t.id" class="row">
<button class="row__body" @click="openList(t)">
<div class="row__thumbWrap">
<img v-if="tierListThumbnailUrl(t)" class="row__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
<div v-else class="row__thumbPlaceholder"></div>
</div>
<div class="row__head">
<div class="row__title">{{ t.title }}</div>
<div class="row__author">
@@ -125,18 +133,17 @@ async function removeList(t) {
}
.list {
display: grid;
gap: 10px;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.row {
display: flex;
display: grid;
gap: 10px;
justify-content: space-between;
align-items: center;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.10);
background: rgba(0, 0, 0, 0.16);
color: rgba(255, 255, 255, 0.92);
overflow: hidden;
}
.row__body {
flex: 1 1 auto;
@@ -147,12 +154,32 @@ async function removeList(t) {
background: transparent;
color: inherit;
padding: 0;
display: grid;
gap: 10px;
}
.row__thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
background: rgba(255, 255, 255, 0.03);
}
.row__thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.row__thumbPlaceholder {
width: 100%;
height: 100%;
background:
linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
}
.row__title {
font-weight: 900;
min-width: 0;
}
.row__head {
padding: 0 14px;
display: flex;
gap: 12px;
align-items: center;
@@ -181,6 +208,7 @@ async function removeList(t) {
font-weight: 900;
}
.row__meta {
padding: 0 14px;
margin-top: 6px;
opacity: 0.76;
font-size: 13px;
@@ -188,5 +216,16 @@ async function removeList(t) {
.link--danger {
background: rgba(239, 68, 68, 0.14);
border-color: rgba(239, 68, 68, 0.28);
margin: 0 14px 14px;
}
@media (max-width: 1100px) {
.list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.list {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -26,6 +26,9 @@ const pool = ref([])
const itemsById = ref({})
const title = ref('')
const thumbnailSrc = ref('')
const pendingThumbnailFile = ref(null)
const thumbnailPreviewUrl = ref('')
const description = ref('')
const isPublic = ref(true)
const error = ref('')
@@ -45,6 +48,7 @@ const groupListEl = ref(null)
const poolEl = ref(null)
const groupDropEls = ref({})
const fileEl = ref(null)
const thumbnailFileEl = ref(null)
const groupSortable = ref(null)
const poolSortable = ref(null)
const dropSortables = ref([])
@@ -67,6 +71,7 @@ const effectiveTitle = computed(() => {
if (customTitle) return customTitle
return (gameName.value || gameId.value || 'Tier Maker').trim()
})
const displayThumbnailUrl = computed(() => thumbnailPreviewUrl.value || (thumbnailSrc.value ? resolveItemSrc({ src: thumbnailSrc.value }) : ''))
const untitledWarning = computed(
() =>
canEdit.value &&
@@ -237,6 +242,31 @@ function openFile() {
fileEl.value?.click()
}
function openThumbnailFile() {
if (!canEdit.value) return
thumbnailFileEl.value?.click()
}
function onThumbnailChange(event) {
const file = event.target.files?.[0]
if (thumbnailPreviewUrl.value) {
URL.revokeObjectURL(thumbnailPreviewUrl.value)
thumbnailPreviewUrl.value = ''
}
pendingThumbnailFile.value = file || null
if (file) thumbnailPreviewUrl.value = URL.createObjectURL(file)
event.target.value = ''
}
function clearThumbnail() {
if (thumbnailPreviewUrl.value) {
URL.revokeObjectURL(thumbnailPreviewUrl.value)
thumbnailPreviewUrl.value = ''
}
pendingThumbnailFile.value = null
thumbnailSrc.value = ''
}
function onFileChange(e) {
const files = Array.from(e.target.files || [])
if (!files.length) return
@@ -322,12 +352,25 @@ async function uploadPendingCustomItems() {
}
}
async function uploadPendingThumbnail() {
if (!pendingThumbnailFile.value) return thumbnailSrc.value || ''
const data = await api.uploadTierListThumbnail(pendingThumbnailFile.value)
if (thumbnailPreviewUrl.value) {
URL.revokeObjectURL(thumbnailPreviewUrl.value)
thumbnailPreviewUrl.value = ''
}
pendingThumbnailFile.value = null
thumbnailSrc.value = data.thumbnailSrc || ''
return thumbnailSrc.value
}
function buildPayload(existingId) {
const finalTitle = effectiveTitle.value
return {
id: existingId || undefined,
gameId: gameId.value,
title: finalTitle,
thumbnailSrc: thumbnailSrc.value || '',
description: (description.value || '').trim(),
isPublic: !!isPublic.value,
groups: groups.value.map((g) => ({ id: g.id, name: g.name, itemIds: g.itemIds })),
@@ -340,6 +383,7 @@ async function save() {
isSaving.value = true
try {
await uploadPendingCustomItems()
await uploadPendingThumbnail()
const payload = buildPayload(tierListId.value && tierListId.value !== 'new' ? tierListId.value : null)
const res = await api.saveTierList(payload)
if (tierListId.value === 'new') history.replaceState({}, '', `/editor/${gameId.value}/${res.tierList.id}`)
@@ -405,6 +449,7 @@ onMounted(() => {
const t = res.tierList
ownerId.value = t.authorId
title.value = t.title
thumbnailSrc.value = t.thumbnailSrc || ''
description.value = t.description || ''
isPublic.value = !!t.isPublic
authorName.value = t.authorName || ''
@@ -430,6 +475,7 @@ onMounted(() => {
})
onUnmounted(() => {
if (thumbnailPreviewUrl.value) URL.revokeObjectURL(thumbnailPreviewUrl.value)
destroySortables()
})
</script>
@@ -440,6 +486,17 @@ onUnmounted(() => {
<div class="kicker">{{ gameName || gameId }}</div>
<input v-model="title" class="titleInput" placeholder="티어표 이름을 입력하세요" :readonly="!canEdit" />
<div v-if="untitledWarning" class="titleNotice">{{ untitledWarning }}</div>
<div v-if="canEdit" class="thumbComposer">
<input ref="thumbnailFileEl" type="file" accept="image/*" class="hidden" @change="onThumbnailChange" />
<div class="thumbComposer__preview">
<img v-if="displayThumbnailUrl" class="thumbComposer__image" :src="displayThumbnailUrl" alt="썸네일 미리보기" />
<div v-else class="thumbComposer__empty">썸네일 없음</div>
</div>
<div class="thumbComposer__actions">
<button class="btn btn--ghost" @click="openThumbnailFile">썸네일 선택</button>
<button class="btn btn--danger" :disabled="!pendingThumbnailFile && !thumbnailSrc" @click="clearThumbnail">썸네일 제거</button>
</div>
</div>
<input
v-model="description"
class="descInput"
@@ -612,6 +669,36 @@ onUnmounted(() => {
line-height: 1.5;
color: rgba(251, 191, 36, 0.94);
}
.thumbComposer {
display: grid;
gap: 10px;
width: min(100%, 920px);
}
.thumbComposer__preview {
width: min(100%, 360px);
aspect-ratio: 16 / 9;
border-radius: 16px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
}
.thumbComposer__image {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbComposer__empty {
width: 100%;
height: 100%;
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.62);
}
.thumbComposer__actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.actions {
display: flex;
gap: 14px;