Compare commits

...

5 Commits

7 changed files with 303 additions and 45 deletions

View File

@@ -403,6 +403,22 @@ async function ensureSchema() {
if (templateRequestSourceTierListColumns[0]?.Null !== 'YES') {
await query('ALTER TABLE template_requests MODIFY source_tierlist_id VARCHAR(64) NULL')
}
const templateRequestTypeColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'request_type'")
if (!templateRequestTypeColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN request_type VARCHAR(20) NOT NULL DEFAULT 'create' AFTER id")
}
const templateRequestSourceGameColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'source_game_id'")
if (!templateRequestSourceGameColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN source_game_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER source_tierlist_id")
}
const templateRequestTargetGameColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'target_game_id'")
if (!templateRequestTargetGameColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN target_game_id VARCHAR(120) NOT NULL DEFAULT '' AFTER source_game_id")
}
const templateRequestStatusColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'status'")
if (!templateRequestStatusColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending' AFTER target_game_id")
}
const templateRequestGroupsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'groups_json'")
if (!templateRequestGroupsColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN groups_json LONGTEXT NOT NULL AFTER items_json")
@@ -427,6 +443,8 @@ async function ensureSchema() {
const tierListSourceIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_tierlist_id'")
if (!tierListSourceIdColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN source_tierlist_id VARCHAR(64) NULL DEFAULT NULL AFTER show_character_names")
} else if (tierListSourceIdColumns[0]?.Null !== 'YES') {
await query('ALTER TABLE tierlists MODIFY source_tierlist_id VARCHAR(64) NULL DEFAULT NULL')
}
const tierListSourceTitleColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_snapshot_title'")
if (!tierListSourceTitleColumns.length) {

View File

@@ -508,7 +508,17 @@ router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
const customItem = await findCustomItemById(req.params.itemId)
const gameItem = customItem ? null : await findGameItemById(req.params.itemId)
const sourceItem = customItem || gameItem
const assetItemId = String(req.params.itemId || '')
const imageAsset = !customItem && !gameItem && assetItemId.startsWith('asset:') ? await findImageAssetById(assetItemId.slice(6)) : null
const sourceItem =
customItem ||
gameItem ||
(imageAsset
? {
src: imageAsset.src || '',
label: imageAsset.labelOverride || path.basename(imageAsset.src || '', path.extname(imageAsset.src || '')) || 'item',
}
: null)
if (!sourceItem) return res.status(404).json({ error: 'not_found' })
const item = await promoteLibraryItemToGameItem({ item: sourceItem, gameId: game.id })

View File

@@ -1,5 +1,9 @@
# 의사결정 이력
## 2026-04-01 v1.3.44
- 관리자 티어표 목록에서는 `보기` 버튼을 없애더라도 완성본 확인 기능 자체는 유지해야 하므로, 별도 액션 버튼보다 카드 썸네일 클릭을 미리보기 진입점으로 쓰는 편이 더 자연스럽다고 정리했다.
- 템플릿 요청 미리보기도 별도 요약 카드보다 실제 보드 구조를 우선 보여주는 쪽이 관리자 검수 흐름에 더 맞으므로, 일반 티어표 미리보기와 가까운 방향으로 통일하기로 했다.
## 2026-03-30 v1.2.25
- 홈 게임 카드는 메인 썸네일까지 없애는 것보다, 큰 썸네일은 유지하고 ID 옆의 작은 보조 표시만 제거하는 편이 원래 의도와 맞다고 정리했다.
- 좌우 하단 액션 여백은 `margin`으로 푸터 전체를 밀기보다, 푸터 내부 `padding-bottom`으로 확보해야 버튼 자체는 항상 보이고 여백만 남는다고 판단했다.

View File

@@ -1,6 +1,4 @@
# 할 일 및 이슈
- 아이템 관리, 티어표 관리, 전체 티어표 관리 등에서 아이템의 이름을 관리자가 직접 수정할 수 있어야 한다.
(사용자가 이름없이 파일을 그대로 올렸을 경우 해당 아이템을 사용 할 수 없기 때문)
## 중기 개선
- 라이트모드/다크모드 2차 보정까지 반영했으므로, 남은 작업은 전체 화면을 실제 사용 흐름으로 돌려 보며 대비·명도·아이콘 가독성을 미세하게 QA하는 최종 테마 점검 단계로 가져간다.
@@ -22,3 +20,6 @@
- 가이드 모달과 관리자 아이템 모달은 현재 같은 톤의 큰 셸을 쓰므로, 이후 공통 모달 레이아웃 컴포넌트로 분리할지 검토한다.
- 관리자 아이템 라이브러리 이름 변경은 템플릿·사용자 업로드·보관 자산까지 모두 가능하므로, 이후에는 일괄 이름 정리나 중복 이름 감지 보조 기능까지 검토한다.
- 템플릿 요청 500 오류 대응으로 `template_requests``tierlists` 레거시 컬럼 자동 마이그레이션은 반영했으므로, 이후에는 실제 운영 DB에서 백엔드 재시작 후 요청/승인 흐름을 한 번 더 확인한다.
- 템플릿 요청 저장 분기 500은 백엔드 재시작 후 다시 확인하고, 여전히 실패하면 실제 서버 로그 기준으로 saveTierList/createTemplateRequest 어느 단계에서 터지는지 추가 추적한다.
- 관리자 템플릿 요청 미리보기는 실제 완성본 모달과 더 가까운 체감이 되도록, 이후에도 보드 여백·행/열 헤더·남은 아이템 밀도를 한 번 더 비교 QA한다.

View File

@@ -1,5 +1,26 @@
# 업데이트 로그
## 2026-04-01 v1.3.44
- 관리자 `전체 티어표 관리`에서는 별도 `완성본 보기` 버튼은 다시 두지 않되, 카드 썸네일 자체를 눌러 기존처럼 완성본 미리보기 모달을 열 수 있게 복구함.
- `템플릿 요청 관리`의 요청 미리보기는 요약 썸네일 중심 레이아웃을 줄이고, 실제 보드 구조를 먼저 읽는 방향으로 정리해 일반 티어표 완성본을 보는 흐름과 더 비슷하게 맞춤.
## 2026-04-01 v1.3.43
- 템플릿 요청 모달은 `내 티어리스트에도 저장` 토글 상태를 요청 직전에 별도로 고정해 사용하도록 바꿔, 모달이 닫히며 draft가 초기화된 뒤 성공 토스트가 반대로 나오던 문제를 바로잡음.
- 따라서 저장을 끈 상태에서는 `요청만 보냈어요` 문구가 정확히 유지되고, 저장을 켠 상태에서 500이 나는 경우에는 저장 단계에서 실패했다는 안내를 더 분명하게 보여주도록 보강함.
## 2026-04-01 v1.3.42
- 템플릿 요청 시 `내 티어리스트에도 저장`이 켜져 있을 때만 500 오류가 날 수 있던 레거시 `tierlists.source_tierlist_id` nullability 문제도 함께 보강해, 오래된 DB 스키마에서도 요청 전 저장 흐름이 막히지 않도록 정리함.
- 따라서 템플릿 요청 관련 레거시 호환 보정은 `template_requests``tierlists` 양쪽에 모두 반영됐고, 실제 적용을 위해서는 백엔드 재시작 후 재확인이 필요함.
## 2026-04-01 v1.3.41
- 템플릿 요청 등록 시 500 오류가 날 수 있던 레거시 DB 호환 문제를 보강해, 기존 `template_requests` 테이블에 `request_type`, `source_game_id`, `target_game_id`, `status` 컬럼이 빠져 있어도 서버 시작 시 자동으로 마이그레이션되도록 함.
- 따라서 저장 여부와 무관하게 템플릿 요청 흐름은 유지되고, 구버전 DB를 사용 중이더라도 백엔드 재시작 후 같은 요청이 정상 저장되도록 안정성을 높임.
## 2026-04-01 v1.3.40
- 관리자 아이템 상세 모달은 내부 스크롤바를 숨기고 본문 스크롤이 배경으로 전파되지 않도록 body scroll lock과 ESC 닫기를 추가해, 검수 중 배경 화면이 함께 움직이던 불편을 줄임.
- 관리자 티어표 관리에서는 `완성본 보기` 흐름을 제거하고, 전체 티어표의 추가 아이템을 클릭하면 같은 아이템 관리 모달로 열어 게임 검색·템플릿 추가·새 템플릿 생성까지 같은 문법으로 처리할 수 있게 통일함.
- 템플릿 요청 관리의 `요청 미리보기`는 단순 썸네일이 아니라 행·열 구조, 열 이름, 배치된 아이템, 미사용 아이템까지 함께 보이는 실제 보드형 미리보기로 다시 구성해 요청 내용을 한 번에 검수할 수 있게 함.
## 2026-04-01 v1.3.39
- 관리자 템플릿 요청 미리보기는 요청 시 저장된 보드 스냅샷이 비어 있을 경우 요청 아이템 배열을 fallback으로 사용해, 대표 썸네일만 보이는 상황을 줄이고 요청 내용을 더 안정적으로 확인할 수 있게 보정함.
- 관리자 아이템 상세 모달에는 아이템 이름 입력과 저장 버튼을 추가해, 템플릿 아이템·사용자 업로드·보관 자산 모두 파일명과 무관하게 사람이 읽기 좋은 이름으로 다시 정리할 수 있게 함.

View File

@@ -98,6 +98,7 @@ const featuredSortable = ref(null)
const userAvatarInputs = ref({})
const isGameLoading = ref(false)
const gameCreateModalOpen = ref(false)
const previousBodyOverflow = ref('')
const hasSelectedGame = computed(() => !!selectedGame.value?.game?.id)
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedGameId.value)
@@ -196,6 +197,19 @@ const adminOverviewStats = computed(() => {
{ label: '활동 계정', value: `${users.value.filter((user) => user.tierListCount > 0).length}` },
]
})
const isAnyModalOpen = computed(
() =>
gameCreateModalOpen.value ||
userEditModalOpen.value ||
userPasswordModalOpen.value ||
userDeleteModalOpen.value ||
userRoleModalOpen.value ||
importModalOpen.value ||
customItemModalOpen.value ||
customItemDeleteModalOpen.value ||
imageResetModalOpen.value ||
previewModalOpen.value
)
function handleAdminPopState() {
if (customItemDeleteModalOpen.value) {
@@ -208,15 +222,40 @@ function handleAdminPopState() {
}
}
function handleAdminKeydown(event) {
if (event.key !== 'Escape') return
if (customItemDeleteModalOpen.value) {
event.preventDefault()
closeCustomItemDeleteModal()
return
}
if (customItemModalOpen.value) {
event.preventDefault()
closeCustomItemModal()
return
}
if (previewModalOpen.value) {
event.preventDefault()
closePreviewModal()
}
}
onMounted(async () => {
if (typeof window !== 'undefined') window.addEventListener('popstate', handleAdminPopState)
if (typeof window !== 'undefined') {
window.addEventListener('popstate', handleAdminPopState)
window.addEventListener('keydown', handleAdminKeydown)
}
await auth.refresh()
await Promise.all([refreshGames(), refreshCustomItems(), refreshAdminTierLists(), refreshUsers(), refreshTemplateRequests(), refreshImageDiagnostics()])
await syncFeaturedSortable()
})
onUnmounted(() => {
if (typeof window !== 'undefined') window.removeEventListener('popstate', handleAdminPopState)
if (typeof window !== 'undefined') {
window.removeEventListener('popstate', handleAdminPopState)
window.removeEventListener('keydown', handleAdminKeydown)
}
if (typeof document !== 'undefined') document.body.style.overflow = previousBodyOverflow.value || ''
clearPreviewUrl('item')
clearPreviewUrl('thumb')
destroyFeaturedSortable()
@@ -267,6 +306,22 @@ watch(
}
)
watch(
() => isAnyModalOpen.value,
(open) => {
if (typeof document === 'undefined') return
if (open) {
if (!previousBodyOverflow.value) previousBodyOverflow.value = document.body.style.overflow || ''
document.body.style.overflow = 'hidden'
return
}
document.body.style.overflow = previousBodyOverflow.value || ''
previousBodyOverflow.value = ''
},
{ immediate: true }
)
function resetMessages() {
error.value = ''
success.value = ''
@@ -680,6 +735,7 @@ async function createGame() {
const data = await res.json()
await refreshGames()
selectedGameId.value = data.game.id
if (customItemModalOpen.value) customItemModalTargetGameId.value = data.game.id
closeGameCreateModal()
await loadGame()
success.value = '게임이 생성됐어요. 이어서 썸네일과 기본 아이템을 관리할 수 있어요.'
@@ -1203,6 +1259,32 @@ async function promoteCustomItem(item) {
}
}
function buildModalItemFromTierListItem(item, tierList) {
const matchedItem = customItems.value.find((entry) => entry.id === item?.id || entry.src === item?.src)
const id = matchedItem?.id || item?.id || ''
return {
...matchedItem,
...item,
id,
label: item?.label || matchedItem?.label || '이름 없음',
src: item?.src || matchedItem?.src || '',
sourceType: matchedItem?.sourceType || (String(id).startsWith('asset:') ? 'template' : 'user'),
sourceLabel: matchedItem?.sourceLabel || '티어표 추가 아이템',
ownerName: matchedItem?.ownerName || tierListAuthorDisplayName(tierList),
linkedGames: Array.isArray(matchedItem?.linkedGames) ? matchedItem.linkedGames : [],
usageCount: matchedItem?.usageCount || 0,
canDelete: typeof matchedItem?.canDelete === 'boolean' ? matchedItem.canDelete : false,
isPromoting: false,
createdAt: matchedItem?.createdAt || item?.createdAt || tierList?.updatedAt || tierList?.createdAt || Date.now(),
}
}
function openTierListExtraItemModal(item, tierList) {
if (!item) return
openCustomItemModal(buildModalItemFromTierListItem(item, tierList))
}
function tierListThumbUrl(tierList) {
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
}
@@ -1233,6 +1315,38 @@ function previewRequestGroupItems(preview, group) {
return (group?.itemIds || []).map((itemId) => itemsById[itemId]).filter(Boolean)
}
function previewRequestColumns(preview) {
const groups = Array.isArray(preview?.snapshotGroups) ? preview.snapshotGroups : []
const columnSource = groups.find((group) => Array.isArray(group?.columnNames) && group.columnNames.length) || null
const namedColumns = Array.isArray(columnSource?.columnNames) ? columnSource.columnNames : []
const cellCount = Math.max(1, namedColumns.length, ...groups.map((group) => (Array.isArray(group?.cells) ? group.cells.length : 0)))
return Array.from({ length: cellCount }, (_, index) => ({
id: namedColumns[index]?.id || ('column-' + index),
name: namedColumns[index]?.name || '',
}))
}
function previewRequestHasColumns(preview) {
const columns = previewRequestColumns(preview)
return columns.length > 1 || columns.some((column) => column.name)
}
function previewRequestGridStyle(preview) {
const count = previewRequestColumns(preview).length
return { gridTemplateColumns: 'repeat(' + count + ', minmax(0, 1fr))' }
}
function previewRequestGroupCellItems(preview, group, columnIndex) {
const itemsById = previewRequestItemsById(preview)
if (Array.isArray(group?.cells?.[columnIndex])) {
return group.cells[columnIndex].map((itemId) => itemsById[itemId]).filter(Boolean)
}
if (columnIndex === 0) return previewRequestGroupItems(preview, group)
return []
}
function previewRequestPoolItems(preview) {
const groupedIds = new Set((preview?.snapshotGroups || []).flatMap((group) => group.itemIds || []))
return (preview?.snapshotItems || []).filter((item) => !groupedIds.has(item.id))
@@ -1709,10 +1823,10 @@ async function saveFeaturedOrder() {
<div v-if="!adminTierLists.length" class="hint">조건에 맞는 티어표가 없어요.</div>
<div v-else class="tierAdminList">
<article v-for="tierList in adminTierLists" :key="tierList.id" class="tierAdminCard">
<div class="tierAdminCard__preview" @click="openAdminTierList(tierList)">
<button class="tierAdminCard__preview" type="button" @click="openAdminTierList(tierList)">
<img v-if="tierListThumbUrl(tierList)" class="tierAdminCard__thumb" :src="tierListThumbUrl(tierList)" :alt="tierList.title" />
<div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
</div>
</button>
<div class="tierAdminCard__body">
<div class="tierAdminCard__head">
@@ -1724,7 +1838,6 @@ async function saveFeaturedOrder() {
</div>
<div class="tierAdminCard__meta">{{ fmt(tierList.updatedAt) }}</div>
</div>
<button class="btn btn--ghost btn--small" @click="openAdminTierList(tierList)">완성본 보기</button>
</div>
<div class="tierAdminCard__stats">
@@ -1735,7 +1848,7 @@ async function saveFeaturedOrder() {
<div v-if="tierList.extraItems?.length" class="tierAdminSection">
<div class="tierAdminSection__title">추가로 넣은 아이템</div>
<div class="tierAdminItemList">
<button v-for="item in tierList.extraItems" :key="item.id" class="tierAdminItem" @click="openTierListImportModal(tierList, [item])">
<button v-for="item in tierList.extraItems" :key="item.id" class="tierAdminItem" @click="openTierListExtraItemModal(item, tierList)">
<img class="tierAdminItem__thumb" :src="toApiUrl(item.src)" :alt="item.label" />
<div class="tierAdminItem__title">{{ item.label }}</div>
</button>
@@ -2009,6 +2122,7 @@ async function saveFeaturedOrder() {
<div class="customItemModal__pickerHead">
<div class="customItemModal__pickerEyebrow">GAME PICKER</div>
<div class="customItemModal__pickerTitle">템플릿으로 추가할 게임</div>
<button class="btn btn--ghost btn--small customItemModal__createGameButton" type="button" @click="openGameCreateModal"> 템플릿 만들기</button>
</div>
<div class="customItemModal__pickerControls">
<input v-model="customItemModalGameQuery" class="input input--dense" placeholder="게임 이름, ID 검색" />
@@ -2044,6 +2158,7 @@ async function saveFeaturedOrder() {
<div class="customItemModal__source">{{ modalTargetCustomItem.sourceLabel }}</div>
</div>
</div>
<img class="customItemModal__image" :src="toApiUrl(modalTargetCustomItem.src)" :alt="modalTargetCustomItem.label" />
<div class="customItemModal__labelEditor">
<label class="field">
<span class="field__label">아이템 이름</span>
@@ -2053,7 +2168,6 @@ async function saveFeaturedOrder() {
{{ customItemModalLabelSaving ? '저장중...' : '이름 저장' }}
</button>
</div>
<img class="customItemModal__image" :src="toApiUrl(modalTargetCustomItem.src)" :alt="modalTargetCustomItem.label" />
<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>
@@ -2110,32 +2224,58 @@ async function saveFeaturedOrder() {
<div class="modalCard__title">{{ previewTierList?.title || '티어표 미리보기' }}</div>
<button class="btn btn--ghost btn--small" @click="closePreviewModal">닫기</button>
</div>
<div v-if="previewTierList?.requestPreview" class="requestPreview">
<img
v-if="previewTierList.thumbnailSrc"
class="requestPreview__thumb"
:src="toApiUrl(previewTierList.thumbnailSrc)"
:alt="previewTierList.title"
/>
<div v-if="previewTierList.description" class="requestPreview__desc">{{ previewTierList.description }}</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__rowItems">
<div class="requestPreview__hero">
<div class="requestPreview__heroBody">
<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>
</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-for="item in previewRequestGroupItems(previewTierList, group)"
:key="item.id"
class="requestPreview__item"
v-for="(column, columnIndex) in previewRequestColumns(previewTierList)"
:key="column.id"
class="requestPreview__columnLabel"
>
<img class="requestPreview__itemThumb" :src="toApiUrl(item.src)" :alt="item.label" />
<div v-if="previewTierList.snapshotShowCharacterNames" class="requestPreview__itemLabel">{{ item.label }}</div>
{{ column.name || ('열 ' + (columnIndex + 1)) }}
</div>
</div>
</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
v-for="(column, columnIndex) in previewRequestColumns(previewTierList)"
:key="group.id + '-' + column.id"
class="requestPreview__cell"
>
<div class="requestPreview__rowItems">
<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>
</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">
<div class="requestPreview__rowItems requestPreview__rowItems--pool">
<div
v-for="item in previewRequestPoolItems(previewTierList)"
:key="item.id"
@@ -3219,8 +3359,8 @@ async function saveFeaturedOrder() {
}
.customItemModal {
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
min-height: min(820px, calc(100dvh - 48px));
grid-template-columns: 340px minmax(0, 1fr);
min-height: min(860px, calc(100dvh - 40px));
align-items: stretch;
}
.customItemModal__pickerPanel {
@@ -3234,7 +3374,7 @@ async function saveFeaturedOrder() {
}
.customItemModal__pickerHead {
display: grid;
gap: 4px;
gap: 10px;
}
.customItemModal__pickerEyebrow {
font-size: 11px;
@@ -3256,6 +3396,9 @@ async function saveFeaturedOrder() {
max-height: 440px;
overflow: auto;
}
.customItemModal__createGameButton {
justify-self: start;
}
.customItemModal__gameItem {
display: grid;
gap: 4px;
@@ -3298,13 +3441,18 @@ async function saveFeaturedOrder() {
align-content: start;
gap: 18px;
overflow: auto;
padding-right: 2px;
padding-right: 6px;
scrollbar-width: none;
-ms-overflow-style: none;
}
.customItemModal__content::-webkit-scrollbar {
width: 0;
height: 0;
}
.customItemModal__labelEditor {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
display: flex;
flex-direction: column;
gap: 12px;
align-items: end;
}
.customItemModal__renameButton {
white-space: nowrap;
@@ -3397,9 +3545,9 @@ async function saveFeaturedOrder() {
text-align: center;
}
.modalCard--customItem {
width: min(1360px, calc(100vw - 40px));
min-width: min(800px, calc(100vw - 40px));
height: min(820px, calc(100dvh - 40px));
width: min(1480px, calc(100vw - 40px));
min-width: min(980px, calc(100vw - 40px));
height: min(860px, calc(100dvh - 40px));
max-height: calc(100dvh - 40px);
padding: 0;
overflow: hidden;
@@ -3753,41 +3901,81 @@ async function saveFeaturedOrder() {
display: grid;
gap: 18px;
}
.requestPreview__thumb {
.requestPreview__summary {
display: grid;
grid-template-columns: minmax(0, 1fr) 220px;
gap: 16px;
align-items: start;
}
.requestPreview__summaryBody {
display: grid;
gap: 8px;
}
.requestPreview__summaryThumb {
width: 100%;
max-height: 240px;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: 18px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
}
.requestPreview__meta {
color: var(--theme-text-soft);
font-size: 13px;
}
.requestPreview__desc {
color: var(--theme-text-muted);
line-height: 1.6;
white-space: pre-line;
}
.requestPreview__rows,
.requestPreview__board,
.requestPreview__pool {
display: grid;
gap: 12px;
}
.requestPreview__boardHead,
.requestPreview__row {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
grid-template-columns: 92px minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.requestPreview__rowLabel,
.requestPreview__poolLabel {
.requestPreview__poolLabel,
.requestPreview__columnLabel {
font-size: 13px;
font-weight: 800;
color: rgba(255, 255, 255, 0.86);
}
.requestPreview__rowLabel--head {
color: var(--theme-text-faint);
}
.requestPreview__columnLabels,
.requestPreview__cells {
display: grid;
gap: 12px;
}
.requestPreview__columnLabel,
.requestPreview__cell {
min-width: 0;
}
.requestPreview__cell {
padding: 10px;
border-radius: 16px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
}
.requestPreview__rowItems {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
gap: 10px;
}
.requestPreview__rowItems--pool {
grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
}
.requestPreview__rowItems--pool {
grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
}
.requestPreview__item {
position: relative;
overflow: hidden;
@@ -3835,6 +4023,11 @@ async function saveFeaturedOrder() {
}
.tierAdminCard__preview {
cursor: pointer;
appearance: none;
border: 0;
padding: 0;
background: transparent;
text-align: left;
}
.tierAdminCard__thumb {
width: 100%;
@@ -4043,8 +4236,14 @@ async function saveFeaturedOrder() {
grid-template-columns: 1fr;
min-height: auto;
}
.requestPreview__summary,
.requestPreview__boardHead,
.requestPreview__row {
grid-template-columns: 1fr;
}
.modalCard--customItem {
width: min(100%, calc(100vw - 24px));
min-width: 0;
height: min(100%, calc(100dvh - 24px));
}
.customItemModal__pickerPanel {

View File

@@ -762,6 +762,7 @@ async function toggleFavorite() {
}
async function requestTemplate(type) {
const shouldSaveToMyTierList = !!templateRequestSaveToMyTierList.value
try {
isRequestingTemplate.value = true
await uploadPendingCustomItems()
@@ -775,7 +776,7 @@ async function requestTemplate(type) {
thumbnailSrc: uploadedThumbnailSrc || thumbnailSrc.value || '',
isPublic: !!isPublic.value,
showCharacterNames: !!showCharacterNames.value,
saveToMyTierList: !!templateRequestSaveToMyTierList.value,
saveToMyTierList: shouldSaveToMyTierList,
groups: buildGroupPayload(),
boardItems: Object.values(itemsById.value),
})
@@ -798,10 +799,10 @@ async function requestTemplate(type) {
if (type === 'update') closeTemplateUpdateModal()
toast.success(
type === 'create'
? templateRequestSaveToMyTierList.value
? shouldSaveToMyTierList
? '템플릿 등록 요청과 내 티어표 저장을 함께 완료했어요.'
: '템플릿 등록 요청을 보냈어요.'
: templateRequestSaveToMyTierList.value
: shouldSaveToMyTierList
? '템플릿 업데이트 요청과 내 티어표 저장을 함께 완료했어요.'
: '템플릿 업데이트 요청을 보냈어요.'
)
@@ -822,6 +823,10 @@ async function requestTemplate(type) {
toast.error('요청 제목, 설명, 아이템 이름 중 길이 제한을 넘긴 값이 없는지 확인해주세요.')
return
}
if (e?.status === 500 && shouldSaveToMyTierList) {
toast.error('템플릿 요청 중 내 티어리스트 저장 단계에서 서버 오류가 발생했어요. 백엔드를 재시작한 뒤 다시 시도해주세요.')
return
}
toast.error(type === 'create' ? '템플릿 등록 요청에 실패했어요.' : '템플릿 업데이트 요청에 실패했어요.')
} finally {
isRequestingTemplate.value = false