Compare commits

...

6 Commits

8 changed files with 313 additions and 56 deletions

View File

@@ -403,6 +403,22 @@ async function ensureSchema() {
if (templateRequestSourceTierListColumns[0]?.Null !== 'YES') { if (templateRequestSourceTierListColumns[0]?.Null !== 'YES') {
await query('ALTER TABLE template_requests MODIFY source_tierlist_id VARCHAR(64) NULL') 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'") const templateRequestGroupsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'groups_json'")
if (!templateRequestGroupsColumns.length) { if (!templateRequestGroupsColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN groups_json LONGTEXT NOT NULL AFTER items_json") 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'") const tierListSourceIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_tierlist_id'")
if (!tierListSourceIdColumns.length) { if (!tierListSourceIdColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN source_tierlist_id VARCHAR(64) NULL DEFAULT NULL AFTER show_character_names") 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'") const tierListSourceTitleColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_snapshot_title'")
if (!tierListSourceTitleColumns.length) { if (!tierListSourceTitleColumns.length) {
@@ -2003,6 +2021,7 @@ async function saveTierList({
return findTierListById(existing.id, authorId) return findTierListById(existing.id, authorId)
} }
const nextId = id || nanoid()
const createdAt = now() const createdAt = now()
await query( await query(
` `
@@ -2011,9 +2030,9 @@ async function saveTierList({
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
[id, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), createdAt, createdAt] [nextId, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), createdAt, createdAt]
) )
return findTierListById(id, authorId) return findTierListById(nextId, authorId)
} }
async function duplicateTierListForUser({ tierList, targetUserId }) { async function duplicateTierListForUser({ tierList, targetUserId }) {

View File

@@ -508,7 +508,17 @@ router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
const customItem = await findCustomItemById(req.params.itemId) const customItem = await findCustomItemById(req.params.itemId)
const gameItem = customItem ? null : await findGameItemById(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' }) if (!sourceItem) return res.status(404).json({ error: 'not_found' })
const item = await promoteLibraryItemToGameItem({ item: sourceItem, gameId: game.id }) const item = await promoteLibraryItemToGameItem({ item: sourceItem, gameId: game.id })

View File

@@ -1,5 +1,13 @@
# 의사결정 이력 # 의사결정 이력
## 2026-04-01 v1.3.45
- 템플릿 요청에서 `내 티어리스트에도 저장`은 별도 부가 기능이 아니라 실제 저장본 생성 경로를 타므로, 새 저장본 ID는 호출자에 기대지 말고 저장 함수 내부에서 항상 보장하는 편이 더 안전하다고 정리했다.
- 개발 단계의 내부 조치 문구인 `백엔드 재시작` 같은 표현은 사용자 토스트에 직접 노출하지 않고, 운영형 재시도 안내로 낮추는 편이 맞다고 판단했다.
## 2026-04-01 v1.3.44
- 관리자 티어표 목록에서는 `보기` 버튼을 없애더라도 완성본 확인 기능 자체는 유지해야 하므로, 별도 액션 버튼보다 카드 썸네일 클릭을 미리보기 진입점으로 쓰는 편이 더 자연스럽다고 정리했다.
- 템플릿 요청 미리보기도 별도 요약 카드보다 실제 보드 구조를 우선 보여주는 쪽이 관리자 검수 흐름에 더 맞으므로, 일반 티어표 미리보기와 가까운 방향으로 통일하기로 했다.
## 2026-03-30 v1.2.25 ## 2026-03-30 v1.2.25
- 홈 게임 카드는 메인 썸네일까지 없애는 것보다, 큰 썸네일은 유지하고 ID 옆의 작은 보조 표시만 제거하는 편이 원래 의도와 맞다고 정리했다. - 홈 게임 카드는 메인 썸네일까지 없애는 것보다, 큰 썸네일은 유지하고 ID 옆의 작은 보조 표시만 제거하는 편이 원래 의도와 맞다고 정리했다.
- 좌우 하단 액션 여백은 `margin`으로 푸터 전체를 밀기보다, 푸터 내부 `padding-bottom`으로 확보해야 버튼 자체는 항상 보이고 여백만 남는다고 판단했다. - 좌우 하단 액션 여백은 `margin`으로 푸터 전체를 밀기보다, 푸터 내부 `padding-bottom`으로 확보해야 버튼 자체는 항상 보이고 여백만 남는다고 판단했다.

View File

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

View File

@@ -1,5 +1,31 @@
# 업데이트 로그 # 업데이트 로그
## 2026-04-01 v1.3.45
- 템플릿 요청에서 `내 티어리스트에도 저장`이 켜져 있을 때 발생하던 500 오류는 새 저장본 생성 시 `tierlists.id``undefined`가 들어가던 문제였고, 이제 `saveTierList()`가 생성 시 자동으로 `nanoid()`를 부여하도록 고쳐 저장 분기 자체를 안정화함.
- 사용자에게 노출되던 `백엔드를 재시작해주세요` 문구는 제거하고, 저장 분기 실패 시에도 일반적인 재시도 안내만 보이도록 조정함.
- 루트에 잘못 남아 있던 `update.md` 진입점 파일은 제거하고, 업데이트 기록은 다시 `docs/update.md` 한 곳으로 정리함.
## 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 ## 2026-04-01 v1.3.39
- 관리자 템플릿 요청 미리보기는 요청 시 저장된 보드 스냅샷이 비어 있을 경우 요청 아이템 배열을 fallback으로 사용해, 대표 썸네일만 보이는 상황을 줄이고 요청 내용을 더 안정적으로 확인할 수 있게 보정함. - 관리자 템플릿 요청 미리보기는 요청 시 저장된 보드 스냅샷이 비어 있을 경우 요청 아이템 배열을 fallback으로 사용해, 대표 썸네일만 보이는 상황을 줄이고 요청 내용을 더 안정적으로 확인할 수 있게 보정함.
- 관리자 아이템 상세 모달에는 아이템 이름 입력과 저장 버튼을 추가해, 템플릿 아이템·사용자 업로드·보관 자산 모두 파일명과 무관하게 사람이 읽기 좋은 이름으로 다시 정리할 수 있게 함. - 관리자 아이템 상세 모달에는 아이템 이름 입력과 저장 버튼을 추가해, 템플릿 아이템·사용자 업로드·보관 자산 모두 파일명과 무관하게 사람이 읽기 좋은 이름으로 다시 정리할 수 있게 함.

View File

@@ -98,6 +98,7 @@ const featuredSortable = ref(null)
const userAvatarInputs = ref({}) const userAvatarInputs = ref({})
const isGameLoading = ref(false) const isGameLoading = ref(false)
const gameCreateModalOpen = ref(false) const gameCreateModalOpen = ref(false)
const previousBodyOverflow = ref('')
const hasSelectedGame = computed(() => !!selectedGame.value?.game?.id) const hasSelectedGame = computed(() => !!selectedGame.value?.game?.id)
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedGameId.value) const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedGameId.value)
@@ -196,6 +197,19 @@ const adminOverviewStats = computed(() => {
{ label: '활동 계정', value: `${users.value.filter((user) => user.tierListCount > 0).length}` }, { 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() { function handleAdminPopState() {
if (customItemDeleteModalOpen.value) { 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 () => { 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 auth.refresh()
await Promise.all([refreshGames(), refreshCustomItems(), refreshAdminTierLists(), refreshUsers(), refreshTemplateRequests(), refreshImageDiagnostics()]) await Promise.all([refreshGames(), refreshCustomItems(), refreshAdminTierLists(), refreshUsers(), refreshTemplateRequests(), refreshImageDiagnostics()])
await syncFeaturedSortable() await syncFeaturedSortable()
}) })
onUnmounted(() => { 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('item')
clearPreviewUrl('thumb') clearPreviewUrl('thumb')
destroyFeaturedSortable() 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() { function resetMessages() {
error.value = '' error.value = ''
success.value = '' success.value = ''
@@ -680,6 +735,7 @@ async function createGame() {
const data = await res.json() const data = await res.json()
await refreshGames() await refreshGames()
selectedGameId.value = data.game.id selectedGameId.value = data.game.id
if (customItemModalOpen.value) customItemModalTargetGameId.value = data.game.id
closeGameCreateModal() closeGameCreateModal()
await loadGame() await loadGame()
success.value = '게임이 생성됐어요. 이어서 썸네일과 기본 아이템을 관리할 수 있어요.' 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) { function tierListThumbUrl(tierList) {
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : '' return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
} }
@@ -1233,6 +1315,38 @@ function previewRequestGroupItems(preview, group) {
return (group?.itemIds || []).map((itemId) => itemsById[itemId]).filter(Boolean) 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) { function previewRequestPoolItems(preview) {
const groupedIds = new Set((preview?.snapshotGroups || []).flatMap((group) => group.itemIds || [])) const groupedIds = new Set((preview?.snapshotGroups || []).flatMap((group) => group.itemIds || []))
return (preview?.snapshotItems || []).filter((item) => !groupedIds.has(item.id)) 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-if="!adminTierLists.length" class="hint">조건에 맞는 티어표가 없어요.</div>
<div v-else class="tierAdminList"> <div v-else class="tierAdminList">
<article v-for="tierList in adminTierLists" :key="tierList.id" class="tierAdminCard"> <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" /> <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 v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
</div> </button>
<div class="tierAdminCard__body"> <div class="tierAdminCard__body">
<div class="tierAdminCard__head"> <div class="tierAdminCard__head">
@@ -1724,7 +1838,6 @@ async function saveFeaturedOrder() {
</div> </div>
<div class="tierAdminCard__meta">{{ fmt(tierList.updatedAt) }}</div> <div class="tierAdminCard__meta">{{ fmt(tierList.updatedAt) }}</div>
</div> </div>
<button class="btn btn--ghost btn--small" @click="openAdminTierList(tierList)">완성본 보기</button>
</div> </div>
<div class="tierAdminCard__stats"> <div class="tierAdminCard__stats">
@@ -1735,7 +1848,7 @@ async function saveFeaturedOrder() {
<div v-if="tierList.extraItems?.length" class="tierAdminSection"> <div v-if="tierList.extraItems?.length" class="tierAdminSection">
<div class="tierAdminSection__title">추가로 넣은 아이템</div> <div class="tierAdminSection__title">추가로 넣은 아이템</div>
<div class="tierAdminItemList"> <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" /> <img class="tierAdminItem__thumb" :src="toApiUrl(item.src)" :alt="item.label" />
<div class="tierAdminItem__title">{{ item.label }}</div> <div class="tierAdminItem__title">{{ item.label }}</div>
</button> </button>
@@ -2009,6 +2122,7 @@ async function saveFeaturedOrder() {
<div class="customItemModal__pickerHead"> <div class="customItemModal__pickerHead">
<div class="customItemModal__pickerEyebrow">GAME PICKER</div> <div class="customItemModal__pickerEyebrow">GAME PICKER</div>
<div class="customItemModal__pickerTitle">템플릿으로 추가할 게임</div> <div class="customItemModal__pickerTitle">템플릿으로 추가할 게임</div>
<button class="btn btn--ghost btn--small customItemModal__createGameButton" type="button" @click="openGameCreateModal"> 템플릿 만들기</button>
</div> </div>
<div class="customItemModal__pickerControls"> <div class="customItemModal__pickerControls">
<input v-model="customItemModalGameQuery" class="input input--dense" placeholder="게임 이름, ID 검색" /> <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 class="customItemModal__source">{{ modalTargetCustomItem.sourceLabel }}</div>
</div> </div>
</div> </div>
<img class="customItemModal__image" :src="toApiUrl(modalTargetCustomItem.src)" :alt="modalTargetCustomItem.label" />
<div class="customItemModal__labelEditor"> <div class="customItemModal__labelEditor">
<label class="field"> <label class="field">
<span class="field__label">아이템 이름</span> <span class="field__label">아이템 이름</span>
@@ -2053,7 +2168,6 @@ async function saveFeaturedOrder() {
{{ customItemModalLabelSaving ? '저장중...' : '이름 저장' }} {{ customItemModalLabelSaving ? '저장중...' : '이름 저장' }}
</button> </button>
</div> </div>
<img class="customItemModal__image" :src="toApiUrl(modalTargetCustomItem.src)" :alt="modalTargetCustomItem.label" />
<div class="customItemModal__metaList"> <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.src.split('/').pop()">{{ modalTargetCustomItem.src.split('/').pop() }}</strong></div>
<div class="customItemModal__metaRow"><span>업로더/출처</span><strong :title="modalTargetCustomItem.ownerName">{{ modalTargetCustomItem.ownerName }}</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> <div class="modalCard__title">{{ previewTierList?.title || '티어표 미리보기' }}</div>
<button class="btn btn--ghost btn--small" @click="closePreviewModal">닫기</button> <button class="btn btn--ghost btn--small" @click="closePreviewModal">닫기</button>
</div> </div>
<div v-if="previewTierList?.requestPreview" class="requestPreview"> <div v-if="previewTierList?.requestPreview" class="requestPreview">
<img <div class="requestPreview__hero">
v-if="previewTierList.thumbnailSrc" <div class="requestPreview__heroBody">
class="requestPreview__thumb" <div v-if="previewTierList.description" class="requestPreview__desc">{{ previewTierList.description }}</div>
:src="toApiUrl(previewTierList.thumbnailSrc)" <div class="requestPreview__meta">
:alt="previewTierList.title" {{ previewRequestHasColumns(previewTierList) ? (previewRequestColumns(previewTierList).length + '열 구성') : '단일 열 구성' }} ·
/> {{ previewTierList.snapshotGroups?.length || 0 }} ·
<div v-if="previewTierList.description" class="requestPreview__desc">{{ previewTierList.description }}</div> {{ previewTierList.snapshotItems?.length || 0 }} 아이템
<div class="requestPreview__rows"> </div>
<div v-for="group in previewTierList.snapshotGroups" :key="group.id" class="requestPreview__row"> </div>
<div class="requestPreview__rowLabel">{{ group.name }}</div> </div>
<div class="requestPreview__rowItems"> <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 <div
v-for="item in previewRequestGroupItems(previewTierList, group)" v-for="(column, columnIndex) in previewRequestColumns(previewTierList)"
:key="item.id" :key="column.id"
class="requestPreview__item" class="requestPreview__columnLabel"
> >
<img class="requestPreview__itemThumb" :src="toApiUrl(item.src)" :alt="item.label" /> {{ column.name || ('열 ' + (columnIndex + 1)) }}
<div v-if="previewTierList.snapshotShowCharacterNames" class="requestPreview__itemLabel">{{ item.label }}</div> </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> </div>
</div> </div>
<div v-if="previewRequestPoolItems(previewTierList).length" class="requestPreview__pool"> <div v-if="previewRequestPoolItems(previewTierList).length" class="requestPreview__pool">
<div class="requestPreview__poolLabel">남은 아이템</div> <div class="requestPreview__poolLabel">남은 아이템</div>
<div class="requestPreview__rowItems"> <div class="requestPreview__rowItems requestPreview__rowItems--pool">
<div <div
v-for="item in previewRequestPoolItems(previewTierList)" v-for="item in previewRequestPoolItems(previewTierList)"
:key="item.id" :key="item.id"
@@ -3219,8 +3359,8 @@ async function saveFeaturedOrder() {
} }
.customItemModal { .customItemModal {
display: grid; display: grid;
grid-template-columns: 300px minmax(0, 1fr); grid-template-columns: 340px minmax(0, 1fr);
min-height: min(820px, calc(100dvh - 48px)); min-height: min(860px, calc(100dvh - 40px));
align-items: stretch; align-items: stretch;
} }
.customItemModal__pickerPanel { .customItemModal__pickerPanel {
@@ -3234,7 +3374,7 @@ async function saveFeaturedOrder() {
} }
.customItemModal__pickerHead { .customItemModal__pickerHead {
display: grid; display: grid;
gap: 4px; gap: 10px;
} }
.customItemModal__pickerEyebrow { .customItemModal__pickerEyebrow {
font-size: 11px; font-size: 11px;
@@ -3256,6 +3396,9 @@ async function saveFeaturedOrder() {
max-height: 440px; max-height: 440px;
overflow: auto; overflow: auto;
} }
.customItemModal__createGameButton {
justify-self: start;
}
.customItemModal__gameItem { .customItemModal__gameItem {
display: grid; display: grid;
gap: 4px; gap: 4px;
@@ -3298,13 +3441,18 @@ async function saveFeaturedOrder() {
align-content: start; align-content: start;
gap: 18px; gap: 18px;
overflow: auto; 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 { .customItemModal__labelEditor {
display: grid; display: flex;
grid-template-columns: minmax(0, 1fr) auto; flex-direction: column;
gap: 12px; gap: 12px;
align-items: end;
} }
.customItemModal__renameButton { .customItemModal__renameButton {
white-space: nowrap; white-space: nowrap;
@@ -3397,9 +3545,9 @@ async function saveFeaturedOrder() {
text-align: center; text-align: center;
} }
.modalCard--customItem { .modalCard--customItem {
width: min(1360px, calc(100vw - 40px)); width: min(1480px, calc(100vw - 40px));
min-width: min(800px, calc(100vw - 40px)); min-width: min(980px, calc(100vw - 40px));
height: min(820px, calc(100dvh - 40px)); height: min(860px, calc(100dvh - 40px));
max-height: calc(100dvh - 40px); max-height: calc(100dvh - 40px);
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
@@ -3753,41 +3901,81 @@ async function saveFeaturedOrder() {
display: grid; display: grid;
gap: 18px; 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%; width: 100%;
max-height: 240px; aspect-ratio: 16 / 9;
object-fit: cover; object-fit: cover;
border-radius: 18px; border-radius: 18px;
border: 1px solid var(--theme-border); border: 1px solid var(--theme-border);
background: var(--theme-surface-soft); background: var(--theme-surface-soft);
} }
.requestPreview__meta {
color: var(--theme-text-soft);
font-size: 13px;
}
.requestPreview__desc { .requestPreview__desc {
color: var(--theme-text-muted); color: var(--theme-text-muted);
line-height: 1.6; line-height: 1.6;
white-space: pre-line; white-space: pre-line;
} }
.requestPreview__rows, .requestPreview__board,
.requestPreview__pool { .requestPreview__pool {
display: grid; display: grid;
gap: 12px; gap: 12px;
} }
.requestPreview__boardHead,
.requestPreview__row { .requestPreview__row {
display: grid; display: grid;
grid-template-columns: 88px minmax(0, 1fr); grid-template-columns: 92px minmax(0, 1fr);
gap: 12px; gap: 12px;
align-items: start; align-items: start;
} }
.requestPreview__rowLabel, .requestPreview__rowLabel,
.requestPreview__poolLabel { .requestPreview__poolLabel,
.requestPreview__columnLabel {
font-size: 13px; font-size: 13px;
font-weight: 800; font-weight: 800;
color: rgba(255, 255, 255, 0.86); 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 { .requestPreview__rowItems {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
gap: 10px; 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 { .requestPreview__item {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@@ -3835,6 +4023,11 @@ async function saveFeaturedOrder() {
} }
.tierAdminCard__preview { .tierAdminCard__preview {
cursor: pointer; cursor: pointer;
appearance: none;
border: 0;
padding: 0;
background: transparent;
text-align: left;
} }
.tierAdminCard__thumb { .tierAdminCard__thumb {
width: 100%; width: 100%;
@@ -4043,8 +4236,14 @@ async function saveFeaturedOrder() {
grid-template-columns: 1fr; grid-template-columns: 1fr;
min-height: auto; min-height: auto;
} }
.requestPreview__summary,
.requestPreview__boardHead,
.requestPreview__row {
grid-template-columns: 1fr;
}
.modalCard--customItem { .modalCard--customItem {
width: min(100%, calc(100vw - 24px)); width: min(100%, calc(100vw - 24px));
min-width: 0;
height: min(100%, calc(100dvh - 24px)); height: min(100%, calc(100dvh - 24px));
} }
.customItemModal__pickerPanel { .customItemModal__pickerPanel {

View File

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

View File

@@ -1,9 +0,0 @@
# Update Log Entry Point
이 프로젝트의 상세 업데이트 로그는 [docs/update.md](/Users/bicute/Desktop/zenn.dev/tier-cursor/docs/update.md)에 계속 누적됩니다.
## 2026-03-30
- 루트 `package.json`에 공용 실행 스크립트(`dev:frontend`, `dev:backend`, `build`, `start`)를 추가했습니다.
- 루트에서도 바로 `npm run build` 같은 공용 명령을 사용할 수 있게 정리했습니다.
- 업데이트 로그 진입점을 루트 `update.md`로 추가해, 이후 작업 시 파일 위치를 바로 찾을 수 있게 했습니다.