Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6576dc661 | |||
| fd2969c780 |
@@ -95,6 +95,7 @@ function mapImageAssetRow(row) {
|
||||
id: row.id,
|
||||
contentHash: row.content_hash,
|
||||
src: row.src || '',
|
||||
labelOverride: row.label_override || '',
|
||||
mimeType: row.mime_type || 'image/webp',
|
||||
byteSize: Number(row.byte_size || 0),
|
||||
originalByteSize: Number(row.original_byte_size || 0),
|
||||
@@ -342,6 +343,7 @@ async function ensureSchema() {
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
content_hash CHAR(64) NOT NULL UNIQUE,
|
||||
src VARCHAR(255) NOT NULL UNIQUE,
|
||||
label_override VARCHAR(120) NOT NULL DEFAULT '',
|
||||
mime_type VARCHAR(32) NOT NULL DEFAULT 'image/webp',
|
||||
byte_size INT UNSIGNED NOT NULL,
|
||||
original_byte_size INT UNSIGNED NOT NULL,
|
||||
@@ -352,6 +354,11 @@ async function ensureSchema() {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
const imageAssetLabelColumns = await query("SHOW COLUMNS FROM image_assets LIKE 'label_override'")
|
||||
if (!imageAssetLabelColumns.length) {
|
||||
await query("ALTER TABLE image_assets ADD COLUMN label_override VARCHAR(120) NOT NULL DEFAULT '' AFTER src")
|
||||
}
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS image_optimization_jobs (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
@@ -679,7 +686,7 @@ async function updateGameThumbnail(gameId, thumbnailSrc) {
|
||||
|
||||
async function findImageAssetByHash(contentHash) {
|
||||
const rows = await query(
|
||||
'SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE content_hash = ? LIMIT 1',
|
||||
'SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE content_hash = ? LIMIT 1',
|
||||
[contentHash]
|
||||
)
|
||||
return mapImageAssetRow(rows[0])
|
||||
@@ -687,7 +694,7 @@ async function findImageAssetByHash(contentHash) {
|
||||
|
||||
async function findImageAssetBySrc(src) {
|
||||
const rows = await query(
|
||||
'SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE src = ? LIMIT 1',
|
||||
'SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE src = ? LIMIT 1',
|
||||
[src]
|
||||
)
|
||||
return mapImageAssetRow(rows[0])
|
||||
@@ -765,7 +772,7 @@ async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
|
||||
const cutoff = now() - safeMinAgeHours * 60 * 60 * 1000
|
||||
|
||||
const assets = (await query(
|
||||
`SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE created_at <= ? ORDER BY created_at ASC LIMIT ${safeLimit}`,
|
||||
`SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE created_at <= ? ORDER BY created_at ASC LIMIT ${safeLimit}`,
|
||||
[cutoff]
|
||||
)).map(mapImageAssetRow)
|
||||
|
||||
@@ -806,7 +813,7 @@ async function deleteImageAssets(ids) {
|
||||
if (!uniqueIds.length) return []
|
||||
const placeholders = uniqueIds.map(() => '?').join(', ')
|
||||
const rows = await query(
|
||||
`SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id IN (${placeholders})`,
|
||||
`SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id IN (${placeholders})`,
|
||||
uniqueIds
|
||||
)
|
||||
await query(`DELETE FROM image_assets WHERE id IN (${placeholders})`, uniqueIds)
|
||||
@@ -931,14 +938,14 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
|
||||
|
||||
async function listImageAssets() {
|
||||
const rows = await query(
|
||||
'SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets ORDER BY created_at DESC'
|
||||
'SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets ORDER BY created_at DESC'
|
||||
)
|
||||
return rows.map(mapImageAssetRow)
|
||||
}
|
||||
|
||||
async function findImageAssetById(id) {
|
||||
const rows = await query(
|
||||
'SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id = ? LIMIT 1',
|
||||
'SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id = ? LIMIT 1',
|
||||
[id]
|
||||
)
|
||||
return mapImageAssetRow(rows[0])
|
||||
@@ -1069,6 +1076,34 @@ async function updateGameItemLabel(itemId, label) {
|
||||
return mapGameItemRow(rows[0])
|
||||
}
|
||||
|
||||
async function updateCustomItemLabel(itemId, label) {
|
||||
await query('UPDATE custom_items SET label = ? WHERE id = ?', [label, itemId])
|
||||
const rows = await query(`
|
||||
SELECT c.id, c.owner_id, c.src, c.label, c.created_at, u.nickname, u.email
|
||||
FROM custom_items c
|
||||
INNER JOIN users u ON u.id = c.owner_id
|
||||
WHERE c.id = ?
|
||||
LIMIT 1
|
||||
`, [itemId])
|
||||
const row = rows[0]
|
||||
if (!row) return null
|
||||
return {
|
||||
id: row.id,
|
||||
ownerId: row.owner_id,
|
||||
src: row.src,
|
||||
label: row.label,
|
||||
createdAt: Number(row.created_at),
|
||||
ownerName: row.nickname || row.email,
|
||||
ownerEmail: row.email,
|
||||
}
|
||||
}
|
||||
|
||||
async function updateImageAssetLabel(assetId, label) {
|
||||
await query('UPDATE image_assets SET label_override = ? WHERE id = ?', [label, assetId])
|
||||
const rows = await query('SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id = ? LIMIT 1', [assetId])
|
||||
return mapImageAssetRow(rows[0])
|
||||
}
|
||||
|
||||
async function deleteGameItem(itemId) {
|
||||
const gameItemRows = await query('SELECT game_id FROM game_items WHERE id = ? LIMIT 1', [itemId])
|
||||
const gameId = gameItemRows[0]?.game_id
|
||||
@@ -1261,7 +1296,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT ia.id, ia.src, ia.created_at
|
||||
SELECT ia.id, ia.src, ia.label_override, ia.created_at
|
||||
FROM image_assets ia
|
||||
WHERE ia.src LIKE '/uploads/assets/%'
|
||||
${hasQuery ? 'AND ia.src LIKE ?' : ''}
|
||||
@@ -1309,7 +1344,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl
|
||||
assetId: row.id,
|
||||
ownerId: '',
|
||||
src: row.src,
|
||||
label: (row.src.split('/').pop() || '').replace(/\.[^.]+$/, '') || '이름 없음',
|
||||
label: row.label_override || (row.src.split('/').pop() || '').replace(/\.[^.]+$/, '') || '이름 없음',
|
||||
createdAt: Number(row.created_at || 0),
|
||||
ownerName: '관리자 보관 자산',
|
||||
ownerEmail: '',
|
||||
@@ -2057,6 +2092,8 @@ module.exports = {
|
||||
getImageAssetStats,
|
||||
createGameItem,
|
||||
updateGameItemLabel,
|
||||
updateCustomItemLabel,
|
||||
updateImageAssetLabel,
|
||||
deleteGameItem,
|
||||
deleteGame,
|
||||
updateGameDisplayOrder,
|
||||
|
||||
@@ -15,6 +15,8 @@ const {
|
||||
updateGameThumbnail,
|
||||
createGameItem,
|
||||
updateGameItemLabel,
|
||||
updateCustomItemLabel,
|
||||
updateImageAssetLabel,
|
||||
deleteGameItem,
|
||||
deleteGame,
|
||||
updateGameDisplayOrder,
|
||||
@@ -192,6 +194,32 @@ router.delete('/games/:gameId', requireAdmin, async (req, res) => {
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
router.patch('/custom-items/:itemId/label', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
label: z.string().trim().min(1).max(60),
|
||||
sourceType: z.enum(['template', 'user']).optional().default('user'),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const itemId = req.params.itemId
|
||||
if (itemId.startsWith('asset:')) {
|
||||
const updated = await updateImageAssetLabel(itemId.slice(6), parsed.data.label)
|
||||
if (!updated) return res.status(404).json({ error: 'not_found' })
|
||||
return res.json({ item: updated })
|
||||
}
|
||||
|
||||
if (parsed.data.sourceType === 'template') {
|
||||
const updated = await updateGameItemLabel(itemId, parsed.data.label)
|
||||
if (!updated) return res.status(404).json({ error: 'not_found' })
|
||||
return res.json({ item: updated })
|
||||
}
|
||||
|
||||
const updated = await updateCustomItemLabel(itemId, parsed.data.label)
|
||||
if (!updated) return res.status(404).json({ error: 'not_found' })
|
||||
return res.json({ item: updated })
|
||||
})
|
||||
|
||||
router.get('/custom-items', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
@@ -480,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 })
|
||||
|
||||
@@ -18,3 +18,5 @@
|
||||
- 관리자 아이템 라이브러리는 보관 자산까지 노출되므로, 이후에는 `활성 템플릿 / 보관 자산` 분리 필터나 그룹 보기까지 검토한다.
|
||||
|
||||
- 가이드 모달과 관리자 아이템 모달은 현재 같은 톤의 큰 셸을 쓰므로, 이후 공통 모달 레이아웃 컴포넌트로 분리할지 검토한다.
|
||||
|
||||
- 관리자 아이템 라이브러리 이름 변경은 템플릿·사용자 업로드·보관 자산까지 모두 가능하므로, 이후에는 일괄 이름 정리나 중복 이름 감지 보조 기능까지 검토한다.
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-01 v1.3.40
|
||||
- 관리자 아이템 상세 모달은 내부 스크롤바를 숨기고 본문 스크롤이 배경으로 전파되지 않도록 body scroll lock과 ESC 닫기를 추가해, 검수 중 배경 화면이 함께 움직이던 불편을 줄임.
|
||||
- 관리자 티어표 관리에서는 `완성본 보기` 흐름을 제거하고, 전체 티어표의 추가 아이템을 클릭하면 같은 아이템 관리 모달로 열어 게임 검색·템플릿 추가·새 템플릿 생성까지 같은 문법으로 처리할 수 있게 통일함.
|
||||
- 템플릿 요청 관리의 `요청 미리보기`는 단순 썸네일이 아니라 행·열 구조, 열 이름, 배치된 아이템, 미사용 아이템까지 함께 보이는 실제 보드형 미리보기로 다시 구성해 요청 내용을 한 번에 검수할 수 있게 함.
|
||||
|
||||
## 2026-04-01 v1.3.39
|
||||
- 관리자 템플릿 요청 미리보기는 요청 시 저장된 보드 스냅샷이 비어 있을 경우 요청 아이템 배열을 fallback으로 사용해, 대표 썸네일만 보이는 상황을 줄이고 요청 내용을 더 안정적으로 확인할 수 있게 보정함.
|
||||
- 관리자 아이템 상세 모달에는 아이템 이름 입력과 저장 버튼을 추가해, 템플릿 아이템·사용자 업로드·보관 자산 모두 파일명과 무관하게 사람이 읽기 좋은 이름으로 다시 정리할 수 있게 함.
|
||||
- 보관 자산용 image asset에는 이름 override 컬럼을 추가해, 무작위 WebP 파일명을 그대로 노출하지 않고 라이브러리 표시명만 따로 관리할 수 있게 확장함.
|
||||
|
||||
## 2026-04-01 v1.3.38
|
||||
- Settings 화면 오른쪽 사이드의 테마 설정 패널은 다시 쓰기 전까지 숨김 처리하고, 현재 기본 다크모드를 유지한 채 다른 화면과 동일하게 스폰서 광고만 노출되도록 정리함.
|
||||
- 관리자 아이템 모달에서 템플릿에 사용 중인 게임 배지는 다크모드에서도 읽히는 텍스트 색으로 맞추고, hover/focus 전환 효과를 추가해 상호작용이 더 분명하게 보이도록 보강함.
|
||||
|
||||
@@ -55,6 +55,8 @@ export const api = {
|
||||
cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }),
|
||||
promoteAdminCustomItem: (itemId, payload) =>
|
||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
|
||||
updateAdminCustomItemLabel: (itemId, payload) =>
|
||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/label`, { method: 'PATCH', body: payload }),
|
||||
promoteAdminTierListItems: (tierListId, payload) =>
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/promote-items`, { method: 'POST', body: payload }),
|
||||
createAdminGameTemplateFromTierList: (tierListId, payload) =>
|
||||
|
||||
@@ -64,6 +64,8 @@ const modalUserDraftEmail = ref('')
|
||||
const modalUserDraftNickname = ref('')
|
||||
const modalUserDraftIsAdmin = ref(false)
|
||||
const modalTargetCustomItem = ref(null)
|
||||
const customItemModalDraftLabel = ref('')
|
||||
const customItemModalLabelSaving = ref(false)
|
||||
|
||||
const users = ref([])
|
||||
const userQuery = ref('')
|
||||
@@ -96,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)
|
||||
@@ -194,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) {
|
||||
@@ -206,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()
|
||||
@@ -265,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 = ''
|
||||
@@ -678,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 = '게임이 생성됐어요. 이어서 썸네일과 기본 아이템을 관리할 수 있어요.'
|
||||
@@ -1076,6 +1134,7 @@ function pushCustomItemModalHistoryState() {
|
||||
|
||||
function openCustomItemModal(item) {
|
||||
modalTargetCustomItem.value = item || null
|
||||
customItemModalDraftLabel.value = item?.label || ''
|
||||
customItemModalTargetGameId.value = ''
|
||||
customItemModalGameQuery.value = ''
|
||||
customItemModalGameSort.value = 'recent'
|
||||
@@ -1087,6 +1146,8 @@ function closeCustomItemModal({ fromPopState = false } = {}) {
|
||||
customItemModalOpen.value = false
|
||||
customItemDeleteModalOpen.value = false
|
||||
modalTargetCustomItem.value = null
|
||||
customItemModalDraftLabel.value = ''
|
||||
customItemModalLabelSaving.value = false
|
||||
customItemModalTargetGameId.value = ''
|
||||
customItemModalGameQuery.value = ''
|
||||
customItemModalGameSort.value = 'recent'
|
||||
@@ -1158,6 +1219,25 @@ async function removeUnusedCustomItems() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCustomItemModalLabel() {
|
||||
const item = modalTargetCustomItem.value
|
||||
const nextLabel = customItemModalDraftLabel.value.trim().slice(0, 60)
|
||||
if (!item || !nextLabel || nextLabel === item.label || customItemModalLabelSaving.value) return
|
||||
|
||||
try {
|
||||
customItemModalLabelSaving.value = true
|
||||
const data = await api.updateAdminCustomItemLabel(item.id, { label: nextLabel, sourceType: item.sourceType })
|
||||
item.label = data.item?.label || nextLabel
|
||||
customItemModalDraftLabel.value = item.label
|
||||
customItems.value = customItems.value.map((entry) => (entry.id === item.id ? { ...entry, label: item.label } : entry))
|
||||
toast.success('아이템 이름을 변경했어요.')
|
||||
} catch (e) {
|
||||
error.value = '아이템 이름 변경에 실패했어요.'
|
||||
} finally {
|
||||
customItemModalLabelSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function promoteCustomItem(item) {
|
||||
resetMessages()
|
||||
if (!customItemModalTargetGameId.value) {
|
||||
@@ -1179,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) : ''
|
||||
}
|
||||
@@ -1209,12 +1315,45 @@ 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))
|
||||
}
|
||||
|
||||
function openTemplateRequestPreview(request) {
|
||||
const snapshotItems = Array.isArray(request.snapshotItems) && request.snapshotItems.length ? request.snapshotItems : Array.isArray(request.items) ? request.items : []
|
||||
previewTierList.value = {
|
||||
id: request.id,
|
||||
title: request.sourceTierListTitle || '템플릿 요청 미리보기',
|
||||
@@ -1222,7 +1361,7 @@ function openTemplateRequestPreview(request) {
|
||||
thumbnailSrc: request.thumbnailSrc || '',
|
||||
requestPreview: true,
|
||||
snapshotGroups: request.snapshotGroups || [],
|
||||
snapshotItems: request.snapshotItems || [],
|
||||
snapshotItems,
|
||||
snapshotShowCharacterNames: !!request.snapshotShowCharacterNames,
|
||||
}
|
||||
previewModalOpen.value = true
|
||||
@@ -1684,7 +1823,7 @@ 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)">
|
||||
<div class="tierAdminCard__preview">
|
||||
<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>
|
||||
@@ -1699,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">
|
||||
@@ -1710,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>
|
||||
@@ -1984,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 검색" />
|
||||
@@ -2014,12 +2153,21 @@ async function saveFeaturedOrder() {
|
||||
<button class="customItemModal__close" type="button" @click="closeCustomItemModal">닫기</button>
|
||||
<div class="customItemModal__content">
|
||||
<div class="customItemModal__titleRow">
|
||||
<div>
|
||||
<div class="customItemModal__title">{{ modalTargetCustomItem.label }}</div>
|
||||
<div class="customItemModal__source">{{ modalTargetCustomItem.sourceLabel }}</div>
|
||||
<div>
|
||||
<div class="customItemModal__title">{{ modalTargetCustomItem.label }}</div>
|
||||
<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>
|
||||
<input v-model="customItemModalDraftLabel" class="field__input" type="text" maxlength="60" placeholder="아이템 이름" />
|
||||
</label>
|
||||
<button class="btn btn--ghost customItemModal__renameButton" type="button" :disabled="customItemModalLabelSaving || !customItemModalDraftLabel.trim() || customItemModalDraftLabel.trim() === modalTargetCustomItem.label" @click="saveCustomItemModalLabel">
|
||||
{{ customItemModalLabelSaving ? '저장중...' : '이름 저장' }}
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
@@ -2076,32 +2224,64 @@ 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__summary">
|
||||
<div class="requestPreview__summaryBody">
|
||||
<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>
|
||||
<img
|
||||
v-if="previewTierList.thumbnailSrc"
|
||||
class="requestPreview__summaryThumb"
|
||||
:src="toApiUrl(previewTierList.thumbnailSrc)"
|
||||
:alt="previewTierList.title"
|
||||
/>
|
||||
</div>
|
||||
<div class="requestPreview__board">
|
||||
<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"
|
||||
@@ -3185,8 +3365,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 {
|
||||
@@ -3200,7 +3380,7 @@ async function saveFeaturedOrder() {
|
||||
}
|
||||
.customItemModal__pickerHead {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
gap: 10px;
|
||||
}
|
||||
.customItemModal__pickerEyebrow {
|
||||
font-size: 11px;
|
||||
@@ -3222,6 +3402,9 @@ async function saveFeaturedOrder() {
|
||||
max-height: 440px;
|
||||
overflow: auto;
|
||||
}
|
||||
.customItemModal__createGameButton {
|
||||
justify-self: start;
|
||||
}
|
||||
.customItemModal__gameItem {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
@@ -3264,7 +3447,21 @@ 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: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.customItemModal__renameButton {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.customItemModal__titleRow,
|
||||
.customItemModal__linked {
|
||||
@@ -3354,9 +3551,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;
|
||||
@@ -3710,41 +3907,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;
|
||||
@@ -3791,7 +4028,7 @@ async function saveFeaturedOrder() {
|
||||
padding: 16px;
|
||||
}
|
||||
.tierAdminCard__preview {
|
||||
cursor: pointer;
|
||||
cursor: default;
|
||||
}
|
||||
.tierAdminCard__thumb {
|
||||
width: 100%;
|
||||
@@ -4000,8 +4237,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 {
|
||||
@@ -4016,6 +4259,9 @@ async function saveFeaturedOrder() {
|
||||
.customItemModal__content {
|
||||
min-height: 0;
|
||||
}
|
||||
.customItemModal__labelEditor {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.customItemModal__actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user