릴리스: v0.1.38 아이템 이름 수정과 티어표 썸네일 추가
This commit is contained in:
@@ -33,6 +33,8 @@ export const api = {
|
||||
listGames: () => request('/api/games'),
|
||||
getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`),
|
||||
updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }),
|
||||
updateAdminGameItem: (gameId, itemId, payload) =>
|
||||
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
|
||||
listAdminCustomItems: ({ q = '', page = 1, limit = 50, orphanOnly = false } = {}) =>
|
||||
request(
|
||||
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&orphanOnly=${encodeURIComponent(orphanOnly)}`
|
||||
@@ -50,6 +52,23 @@ export const api = {
|
||||
getTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`),
|
||||
deleteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
saveTierList: (payload) => request('/api/tierlists', { method: 'POST', body: payload }),
|
||||
uploadTierListThumbnail: async (file) => {
|
||||
const fd = new FormData()
|
||||
fd.append('thumbnail', file)
|
||||
const res = await fetch(toApiUrl('/api/tierlists/thumbnail'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: fd,
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
const err = new Error('request_failed')
|
||||
err.status = res.status
|
||||
err.data = data
|
||||
throw err
|
||||
}
|
||||
return data
|
||||
},
|
||||
deleteAdminCustomItem: (itemId) => request(`/api/admin/custom-items/${encodeURIComponent(itemId)}`, { method: 'DELETE' }),
|
||||
deleteAdminUnusedCustomItems: ({ q = '' } = {}) =>
|
||||
request(`/api/admin/custom-items?q=${encodeURIComponent(q)}`, { method: 'DELETE' }),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user