릴리스: v1.3.49 템플릿 요청 저장 흐름과 관리자 미리보기 정리

This commit is contained in:
2026-04-01 19:01:07 +09:00
parent d5b4de1629
commit 66d408dca8
8 changed files with 184 additions and 177 deletions

View File

@@ -1381,7 +1381,14 @@ function previewRequestGroupCellItems(preview, group, columnIndex) {
}
function previewRequestPoolItems(preview) {
const groupedIds = new Set((preview?.snapshotGroups || []).flatMap((group) => group.itemIds || []))
const groupedIds = new Set(
(preview?.snapshotGroups || []).flatMap((group) => {
if (Array.isArray(group?.cells) && group.cells.length) {
return group.cells.flatMap((cell) => (Array.isArray(cell) ? cell : []))
}
return group.itemIds || []
})
)
return (preview?.snapshotItems || []).filter((item) => !groupedIds.has(item.id))
}
@@ -2278,24 +2285,21 @@ async function saveFeaturedOrder() {
</div>
<div v-if="previewTierList?.requestPreview" class="requestPreview">
<div class="requestPreview__frame">
<div class="requestPreview__header">
<div class="requestPreview__heroTitle">{{ previewTierList.title || '티어표 미리보기' }}</div>
<div v-if="previewTierList.description" class="requestPreview__desc">{{ previewTierList.description }}</div>
<div class="requestPreview__meta">
{{ previewRequestHasColumns(previewTierList) ? (previewRequestColumns(previewTierList).length + '열 구성') : '단일 열 구성' }} ·
{{ previewTierList.snapshotGroups?.length || 0 }} ·
{{ previewTierList.snapshotItems?.length || 0 }} 아이템
</div>
<div class="requestPreview__sheet">
<div class="requestPreview__title">{{ previewTierList.title || '티어표 미리보기' }}</div>
<div v-if="previewTierList.description" class="requestPreview__description">{{ previewTierList.description }}</div>
<div class="requestPreview__meta">
{{ previewRequestHasColumns(previewTierList) ? (previewRequestColumns(previewTierList).length + '열 구성') : '단일 열 구성' }} ·
{{ previewTierList.snapshotGroups?.length || 0 }} ·
{{ previewTierList.snapshotItems?.length || 0 }} 아이템
</div>
<div class="requestPreview__board requestPreview__board--full">
<div v-if="previewRequestHasColumns(previewTierList)" class="requestPreview__boardHead">
<div class="requestPreview__rowLabel requestPreview__rowLabel--head"></div>
<div class="requestPreview__columnLabels" :style="previewRequestGridStyle(previewTierList)">
<div v-if="previewRequestHasColumns(previewTierList)" class="requestPreview__columns">
<div class="requestPreview__columnsSpacer" aria-hidden="true"></div>
<div class="requestPreview__columnsGrid" :style="previewRequestGridStyle(previewTierList)">
<div
v-for="(column, columnIndex) in previewRequestColumns(previewTierList)"
:key="column.id"
class="requestPreview__columnLabel"
class="requestPreview__columnHeader"
>
{{ column.name || ('열 ' + (columnIndex + 1)) }}
</div>
@@ -2303,38 +2307,37 @@ async function saveFeaturedOrder() {
</div>
<div class="requestPreview__rows">
<div v-for="group in previewTierList.snapshotGroups" :key="group.id" class="requestPreview__row">
<div class="requestPreview__rowLabel">{{ group.name }}</div>
<div class="requestPreview__cells" :style="previewRequestGridStyle(previewTierList)">
<div class="requestPreview__label">{{ group.name }}</div>
<div class="requestPreview__dropGrid" :style="previewRequestGridStyle(previewTierList)">
<div
v-for="(column, columnIndex) in previewRequestColumns(previewTierList)"
:key="group.id + '-' + column.id"
class="requestPreview__cell"
class="requestPreview__dropColumn"
>
<div class="requestPreview__rowItems">
<div class="requestPreview__drop">
<div
v-for="item in previewRequestGroupCellItems(previewTierList, group, columnIndex)"
:key="item.id"
class="requestPreview__item"
>
<img class="requestPreview__itemThumb" :src="toApiUrl(item.src)" :alt="item.label" />
<div v-if="previewTierList.snapshotShowCharacterNames" class="requestPreview__itemLabel">{{ item.label }}</div>
<img class="thumb requestPreview__itemThumb" :src="toApiUrl(item.src)" :alt="item.label" />
<div v-if="previewTierList.snapshotShowCharacterNames" class="itemNameOverlay requestPreview__itemLabel">{{ item.label }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="previewRequestPoolItems(previewTierList).length" class="requestPreview__pool">
<div class="requestPreview__poolLabel">남은 아이템</div>
<div class="requestPreview__rowItems requestPreview__rowItems--pool">
<div class="requestPreview__poolTitle">남은 아이템</div>
<div class="requestPreview__poolGrid">
<div
v-for="item in previewRequestPoolItems(previewTierList)"
:key="item.id"
class="requestPreview__item requestPreview__item--muted"
class="requestPreview__poolItem requestPreview__item requestPreview__item--muted"
>
<img class="requestPreview__itemThumb" :src="toApiUrl(item.src)" :alt="item.label" />
<div v-if="previewTierList.snapshotShowCharacterNames" class="requestPreview__itemLabel">{{ item.label }}</div>
<img class="thumb requestPreview__itemThumb" :src="toApiUrl(item.src)" :alt="item.label" />
<div v-if="previewTierList.snapshotShowCharacterNames" class="itemNameOverlay requestPreview__itemLabel">{{ item.label }}</div>
</div>
</div>
</div>
@@ -3887,9 +3890,14 @@ async function saveFeaturedOrder() {
display: grid;
gap: 12px;
align-self: start;
align-content: start;
}
.templateRequestCard__preview {
align-self: start;
display: block;
width: 100%;
line-height: 0;
vertical-align: top;
}
.templateRequestCard__thumbMeta {
display: grid;
@@ -3916,97 +3924,103 @@ async function saveFeaturedOrder() {
}
.requestPreview {
display: grid;
gap: 18px;
}
.requestPreview__frame {
.requestPreview__sheet {
display: grid;
gap: 26px;
gap: 16px;
width: 100%;
max-width: 1280px;
margin: 0 auto;
padding: 28px;
border-radius: 24px;
border: 1px solid var(--theme-border);
background: color-mix(in srgb, var(--theme-main-bg) 92%, transparent);
max-height: min(78vh, 980px);
overflow: auto;
overscroll-behavior: contain;
}
.requestPreview__header {
display: grid;
gap: 10px;
}
.requestPreview__heroTitle {
font-size: clamp(30px, 3vw, 48px);
line-height: 1.08;
.requestPreview__title {
font-size: 28px;
font-weight: 900;
letter-spacing: -0.04em;
letter-spacing: -0.03em;
}
.requestPreview__description {
margin-top: -8px;
font-size: 14px;
line-height: 1.6;
color: var(--theme-text-muted);
}
.requestPreview__meta {
color: var(--theme-text-soft);
font-size: 13px;
}
.requestPreview__desc {
color: var(--theme-text-muted);
line-height: 1.7;
white-space: pre-line;
font-size: 15px;
}
.requestPreview__board,
.requestPreview__pool {
.requestPreview__columns {
display: grid;
gap: 14px;
grid-template-columns: 132px 1fr;
gap: 10px;
margin-bottom: 10px;
}
.requestPreview__boardHead,
.requestPreview__row {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.requestPreview__rowLabel,
.requestPreview__poolLabel,
.requestPreview__columnLabel {
font-size: 15px;
font-weight: 900;
color: var(--theme-text-strong);
}
.requestPreview__rowLabel--head {
color: var(--theme-text-faint);
}
.requestPreview__columnLabels,
.requestPreview__cells {
display: grid;
gap: 14px;
}
.requestPreview__columnLabel,
.requestPreview__cell {
.requestPreview__columnsSpacer {
min-width: 0;
}
.requestPreview__columnLabel {
padding: 8px 4px;
text-align: center;
}
.requestPreview__cell {
min-height: 134px;
padding: 14px;
border-radius: 18px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
}
.requestPreview__rowItems {
.requestPreview__columnsGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
gap: 12px;
gap: 10px;
}
.requestPreview__rowItems--pool {
grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
.requestPreview__columnHeader {
min-height: 20px;
font-size: 12px;
font-weight: 800;
text-align: center;
opacity: 0.72;
}
.requestPreview__rows {
display: grid;
gap: 10px;
}
.requestPreview__row {
display: grid;
grid-template-columns: 132px 1fr;
gap: 10px;
}
.requestPreview__label {
display: grid;
place-items: center;
padding: 10px 12px;
text-align: center;
font-weight: 900;
border-radius: 14px;
background: var(--theme-surface-soft-2);
border: 1px solid var(--theme-border-strong);
}
.requestPreview__dropGrid {
display: grid;
gap: 10px;
}
.requestPreview__dropColumn {
display: grid;
gap: 8px;
}
.requestPreview__drop {
border-radius: 14px;
background: var(--theme-pill-bg);
border: 1px solid var(--theme-border);
min-height: calc(var(--thumb-size, 80px) + 24px);
padding: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-content: flex-start;
}
.requestPreview__item {
display: inline-flex;
position: relative;
overflow: hidden;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: var(--theme-surface-soft);
min-height: 84px;
}
.requestPreview__item--muted {
opacity: 0.52;
filter: grayscale(0.2) brightness(0.78);
filter: grayscale(0.22) brightness(0.78);
}
.requestPreview__itemThumb {
width: 100%;
@@ -4027,6 +4041,24 @@ async function saveFeaturedOrder() {
text-align: center;
line-height: 1.3;
}
.requestPreview__pool {
display: grid;
gap: 10px;
padding-top: 8px;
}
.requestPreview__poolTitle {
font-weight: 900;
opacity: 0.82;
}
.requestPreview__poolGrid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.requestPreview__poolItem {
display: inline-flex;
position: relative;
}
.tierAdminList {
margin-top: 14px;
display: grid;
@@ -4051,11 +4083,13 @@ async function saveFeaturedOrder() {
align-self: start;
display: block;
width: 100%;
line-height: 0;
}
.tierAdminCard__thumb {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
object-position: top center;
display: block;
border-radius: 14px;
background: var(--theme-surface-soft);
@@ -4200,6 +4234,9 @@ async function saveFeaturedOrder() {
}
.modalCard--preview {
width: min(1200px, 100%);
max-height: calc(100dvh - 40px);
overflow: auto;
overscroll-behavior: contain;
}
.modalCard__titleRow {
display: flex;

View File

@@ -34,6 +34,7 @@ const pool = ref([])
const itemsById = ref({})
const title = ref('')
const persistedTierListId = ref('')
const thumbnailSrc = ref('')
const pendingThumbnailFile = ref(null)
const thumbnailPreviewUrl = ref('')
@@ -48,7 +49,6 @@ const isTemplateRequestModalOpen = ref(false)
const isTemplateUpdateModalOpen = ref(false)
const templateRequestDraftTitle = ref('')
const templateRequestDraftDescription = ref('')
const templateRequestSaveToMyTierList = ref(true)
const isDeleteModalOpen = ref(false)
const isGroupDeleteModalOpen = ref(false)
const isColumnDeleteModalOpen = ref(false)
@@ -94,10 +94,13 @@ const effectiveAuthorName = computed(() => {
if (currentEmail) return currentEmail.split('@')[0] || currentEmail
return (authorAccountName.value || '').trim() || 'unknown'
})
const autoGeneratedTitle = ref(createAutoTierListTitle())
const effectiveTitle = computed(() => {
const customTitle = (title.value || '').trim()
if (customTitle) return customTitle
return (gameName.value || gameId.value || 'Tier Maker').trim()
if (persistedTierListId.value) return persistedTierListId.value
if (tierListId.value && tierListId.value !== 'new') return tierListId.value
return autoGeneratedTitle.value
})
const displayThumbnailUrl = computed(() => thumbnailPreviewUrl.value || (thumbnailSrc.value ? resolveItemSrc({ src: thumbnailSrc.value }) : ''))
const untitledWarning = computed(
@@ -120,11 +123,12 @@ const customItems = computed(() =>
.filter((item) => item?.origin === 'custom')
.sort((a, b) => (a.label || '').localeCompare(b.label || '', 'ko'))
)
const hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new')))
const canRequestTemplateCreate = computed(
() => canEdit.value && !isNewTierList.value && gameId.value === 'freeform' && customItems.value.length > 0
() => canEdit.value && hasSavedTierList.value && gameId.value === 'freeform' && customItems.value.length > 0
)
const canRequestTemplateUpdate = computed(
() => canEdit.value && !isNewTierList.value && gameId.value !== 'freeform' && customItems.value.length > 0
() => canEdit.value && hasSavedTierList.value && gameId.value !== 'freeform' && customItems.value.length > 0
)
const canSubmitTemplateCreateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
const canSubmitTemplateUpdateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
@@ -136,6 +140,12 @@ watch(error, (message) => {
error.value = ''
})
function createAutoTierListTitle() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
const pick = (size) => Array.from({ length: size }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
return pick(10) + '-' + pick(10)
}
function formatTitleDate(ts) {
const date = new Date(ts)
const year = date.getFullYear()
@@ -652,9 +662,12 @@ function buildPayload(existingId) {
async function persistTierList({ showModal = false } = {}) {
await uploadPendingCustomItems()
await uploadPendingThumbnail()
const payload = buildPayload(tierListId.value && tierListId.value !== 'new' ? tierListId.value : null)
const currentTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
const payload = buildPayload(currentTierListId || null)
const res = await api.saveTierList(payload)
const savedTierListId = res.tierList?.id || tierListId.value
const savedTierListId = res.tierList?.id || currentTierListId || tierListId.value
persistedTierListId.value = savedTierListId || ''
title.value = res.tierList?.title || payload.title
if (tierListId.value === 'new' && res.tierList?.id) {
await router.replace(`/editor/${gameId.value}/${res.tierList.id}`)
}
@@ -686,7 +699,6 @@ function closeSaveModal() {
function resetTemplateRequestDrafts() {
templateRequestDraftTitle.value = (title.value || '').trim()
templateRequestDraftDescription.value = (description.value || '').trim()
templateRequestSaveToMyTierList.value = true
}
function openTemplateRequestModal() {
@@ -762,55 +774,39 @@ async function toggleFavorite() {
}
async function requestTemplate(type) {
const shouldSaveToMyTierList = !!templateRequestSaveToMyTierList.value
try {
isRequestingTemplate.value = true
await uploadPendingCustomItems()
const uploadedThumbnailSrc = await uploadPendingThumbnail()
const response = await api.requestTierListTemplate({
title.value = templateRequestDraftTitle.value.trim()
description.value = templateRequestDraftDescription.value.trim()
const saved = await persistTierList({ showModal: false })
const sourceId = saved.savedTierListId || persistedTierListId.value || ''
if (!sourceId) throw new Error('save_required')
await api.requestTierListTemplate({
type,
sourceTierListId: tierListId.value !== 'new' ? tierListId.value : '',
sourceTierListId: sourceId,
gameId: gameId.value,
requestTitle: templateRequestDraftTitle.value.trim(),
requestDescription: templateRequestDraftDescription.value.trim(),
thumbnailSrc: uploadedThumbnailSrc || thumbnailSrc.value || '',
thumbnailSrc: saved.tierList?.thumbnailSrc || thumbnailSrc.value || '',
isPublic: !!isPublic.value,
showCharacterNames: !!showCharacterNames.value,
saveToMyTierList: shouldSaveToMyTierList,
groups: buildGroupPayload(),
boardItems: Object.values(itemsById.value),
})
const savedTierList = response?.savedTierList
if (savedTierList) {
title.value = savedTierList.title || title.value
description.value = savedTierList.description || ''
updatedAt.value = Number(savedTierList.updatedAt || Date.now())
authorName.value = savedTierList.authorName || effectiveAuthorName.value
authorAccountName.value = savedTierList.authorAccountName || authorAccountName.value
favoriteCount.value = Number(savedTierList.favoriteCount || favoriteCount.value || 0)
isFavorited.value = !!savedTierList.isFavorited
if (tierListId.value === 'new' && savedTierList.id) {
await router.replace(`/editor/${gameId.value}/${savedTierList.id}`)
}
}
if (type === 'create') closeTemplateRequestModal()
if (type === 'update') closeTemplateUpdateModal()
toast.success(
type === 'create'
? shouldSaveToMyTierList
? '템플릿 등록 요청과 내 티어표 저장을 함께 완료했어요.'
: '템플릿 등록 요청을 보냈어요.'
: shouldSaveToMyTierList
? '템플릿 업데이트 요청과 내 티어표 저장을 함께 완료했어요.'
: '템플릿 업데이트 요청을 보냈어요.'
)
toast.success(type === 'create' ? '템플릿 등록 요청을 보냈어요.' : '템플릿 업데이트 요청을 보냈어요.')
} catch (e) {
if (e?.message === 'custom_upload_failed') {
toast.error('커스텀 이미지 이름이 너무 길거나 업로드 조건에 맞지 않아 요청 전에 저장하지 못했어요. 아이템 이름을 60자 이하로 줄인 뒤 다시 시도해주세요.')
return
}
if (e?.message === 'save_required') {
toast.error('먼저 현재 티어표를 저장한 뒤 다시 요청해주세요.')
return
}
if (e?.status === 409) {
toast.error('이미 처리 대기 중인 같은 요청이 있어요.')
return
@@ -819,12 +815,12 @@ async function requestTemplate(type) {
toast.error('먼저 커스텀 아이템을 추가한 뒤 요청해주세요.')
return
}
if (e?.status === 400 && e?.data?.error === 'bad_request') {
toast.error('요청 제목, 설명, 아이템 이름 중 길이 제한을 넘긴 값이 없는지 확인해주세요.')
if (e?.status === 400 && e?.data?.error === 'source_tierlist_required') {
toast.error('저장된 티어표에서만 템플릿 요청을 보낼 수 있어요.')
return
}
if (e?.status === 500 && shouldSaveToMyTierList) {
toast.error('템플릿 요청 중 내 티어리스트 저장에 실패했어요. 잠시 후 다시 시도해주세요.')
if (e?.status === 400 && e?.data?.error === 'bad_request') {
toast.error('요청 제목, 설명, 아이템 이름 중 길이 제한을 넘긴 값이 없는지 확인해주세요.')
return
}
toast.error(type === 'create' ? '템플릿 등록 요청에 실패했어요.' : '템플릿 업데이트 요청에 실패했어요.')
@@ -866,6 +862,7 @@ onMounted(() => {
const res = await api.getTierList(tierListId.value)
const t = res.tierList
ownerId.value = t.authorId
persistedTierListId.value = t.id || ''
title.value = t.title
thumbnailSrc.value = t.thumbnailSrc || ''
description.value = t.description || ''
@@ -975,14 +972,6 @@ onUnmounted(() => {
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="1000" placeholder="예: 여름 이벤트 한정 캐릭터 추가용으로 신규 템플릿이 필요합니다." />
<span class="templateRequestDraft__hint">{{ templateRequestDraftDescription.length }}/1000</span>
</label>
<label class="toggleSwitch">
<input v-model="templateRequestSaveToMyTierList" type="checkbox" />
<span class="toggleSwitch__label"> 티어 리스트에도 저장</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
</label>
<div class="templateRequestDraft__note">
저장을 끄면 요청 시점 스냅샷만 관리자에게 전달되고, 티어 리스트에는 별도로 남기지 않아요.
</div>
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeTemplateRequestModal">취소</button>
@@ -1014,14 +1003,6 @@ onUnmounted(() => {
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="1000" placeholder="예: 여름 이벤트 한정 캐릭터 추가" />
<span class="templateRequestDraft__hint">{{ templateRequestDraftDescription.length }}/1000</span>
</label>
<label class="toggleSwitch">
<input v-model="templateRequestSaveToMyTierList" type="checkbox" />
<span class="toggleSwitch__label"> 티어 리스트에도 저장</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
</label>
<div class="templateRequestDraft__note">
저장을 끄면 관리자 확인용 요청 스냅샷만 남고, 현재 작업 중인 티어표는 따로 저장하지 않아요.
</div>
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeTemplateUpdateModal">요청 취소</button>
@@ -1285,7 +1266,7 @@ onUnmounted(() => {
<div class="editorSidebar__label">커스텀 이름 정리</div>
<div class="customItemEditor customItemEditor--sidebar">
<div class="customItemEditor__desc">
아래에서 이름 정리해두면 관리자 요청 그대로 전달됩니다.
아래에서 이름 정리 저장하면, 템플릿 요청 그대로 전달됩니다.
</div>
<div class="customItemEditor__list">
<label v-for="item in customItems" :key="item.id" class="customItemEditor__row">