릴리스: 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

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