Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6576dc661 | |||
| fd2969c780 | |||
| 8aa60231a3 | |||
| 64b3e3e3df | |||
| 5f6f01942e | |||
| 7e80320e9f |
@@ -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,11 +938,19 @@ 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, 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])
|
||||
}
|
||||
|
||||
async function getReferencedUploadFootprint() {
|
||||
const [referencedSrcs, assets] = await Promise.all([listReferencedUploadSources(), listImageAssets()])
|
||||
const assetMap = new Map(assets.map((asset) => [asset.src, asset]))
|
||||
@@ -1061,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
|
||||
@@ -1217,7 +1260,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl
|
||||
const hasQuery = !!searchText
|
||||
const search = `%${searchText}%`
|
||||
|
||||
const [customRows, gameItemRows, usageMeta] = await Promise.all([
|
||||
const [customRows, gameItemRows, assetRows, usageMeta] = await Promise.all([
|
||||
query(
|
||||
`
|
||||
SELECT
|
||||
@@ -1251,6 +1294,16 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl
|
||||
`,
|
||||
hasQuery ? [search, search, search, search] : []
|
||||
),
|
||||
query(
|
||||
`
|
||||
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 ?' : ''}
|
||||
ORDER BY ia.created_at DESC
|
||||
`,
|
||||
hasQuery ? [search] : []
|
||||
),
|
||||
getCustomItemUsageMeta(),
|
||||
])
|
||||
|
||||
@@ -1282,6 +1335,29 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl
|
||||
}
|
||||
})
|
||||
|
||||
const templateSrcSet = new Set(gameItemRows.map((row) => row.src).filter(Boolean))
|
||||
const customSrcSet = new Set(customRows.map((row) => row.src).filter(Boolean))
|
||||
const assetLibraryItems = assetRows
|
||||
.filter((row) => row?.src && !templateSrcSet.has(row.src) && !customSrcSet.has(row.src))
|
||||
.map((row) => ({
|
||||
id: `asset:${row.id}`,
|
||||
assetId: row.id,
|
||||
ownerId: '',
|
||||
src: row.src,
|
||||
label: row.label_override || (row.src.split('/').pop() || '').replace(/\.[^.]+$/, '') || '이름 없음',
|
||||
createdAt: Number(row.created_at || 0),
|
||||
ownerName: '관리자 보관 자산',
|
||||
ownerEmail: '',
|
||||
usageCount: 0,
|
||||
linkedGames: [],
|
||||
sourceType: 'template',
|
||||
sourceLabel: '관리자 템플릿',
|
||||
canDelete: true,
|
||||
sourceGameId: '',
|
||||
sourceGameName: '',
|
||||
isAssetLibraryItem: true,
|
||||
}))
|
||||
|
||||
const templateItems = gameItemRows.map((row) => ({
|
||||
id: row.id,
|
||||
ownerId: '',
|
||||
@@ -1294,12 +1370,12 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl
|
||||
linkedGames: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()),
|
||||
sourceType: 'template',
|
||||
sourceLabel: '관리자 템플릿',
|
||||
canDelete: false,
|
||||
canDelete: true,
|
||||
sourceGameId: row.game_id,
|
||||
sourceGameName: row.game_name || row.game_id,
|
||||
}))
|
||||
|
||||
const allItems = [...customItems, ...templateItems]
|
||||
const allItems = [...customItems, ...templateItems, ...assetLibraryItems]
|
||||
.filter((item) => {
|
||||
if (!orphanOnly) return true
|
||||
return item.sourceType === 'user' && item.usageCount === 0 && item.linkedGames.length === 0
|
||||
@@ -2001,6 +2077,7 @@ module.exports = {
|
||||
updateGameThumbnail,
|
||||
findImageAssetByHash,
|
||||
findImageAssetBySrc,
|
||||
findImageAssetById,
|
||||
createImageAsset,
|
||||
createImageOptimizationJob,
|
||||
findImageOptimizationJobById,
|
||||
@@ -2015,6 +2092,8 @@ module.exports = {
|
||||
getImageAssetStats,
|
||||
createGameItem,
|
||||
updateGameItemLabel,
|
||||
updateCustomItemLabel,
|
||||
updateImageAssetLabel,
|
||||
deleteGameItem,
|
||||
deleteGame,
|
||||
updateGameDisplayOrder,
|
||||
|
||||
@@ -9,11 +9,14 @@ const {
|
||||
findUserById,
|
||||
findGameById,
|
||||
findGameItemById,
|
||||
findImageAssetById,
|
||||
createGame,
|
||||
listGames,
|
||||
updateGameThumbnail,
|
||||
createGameItem,
|
||||
updateGameItemLabel,
|
||||
updateCustomItemLabel,
|
||||
updateImageAssetLabel,
|
||||
deleteGameItem,
|
||||
deleteGame,
|
||||
updateGameDisplayOrder,
|
||||
@@ -191,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(''),
|
||||
@@ -309,6 +338,20 @@ router.post('/image-assets/stats/reset', requireAdmin, async (req, res) => {
|
||||
res.json({ deletedCount })
|
||||
})
|
||||
|
||||
async function removeUploadFiles(srcs) {
|
||||
await Promise.all(
|
||||
(srcs || []).map(async (src) => {
|
||||
if (!src || !src.startsWith('/uploads/')) return
|
||||
const absolutePath = path.join(__dirname, '..', '..', src.replace(/^\//, ''))
|
||||
try {
|
||||
await fs.unlink(absolutePath)
|
||||
} catch (e) {
|
||||
if (e?.code !== 'ENOENT') throw e
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async function removeCustomItemFiles(items) {
|
||||
await Promise.all(
|
||||
items.map(async (item) => {
|
||||
@@ -426,9 +469,23 @@ async function createGameTemplateFromRequest({ templateRequest, gameId, gameName
|
||||
}
|
||||
|
||||
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
||||
const result = await listCustomItems({ page: 1, limit: 200, orphanOnly: false })
|
||||
const result = await listCustomItems({ page: 1, limit: 10000, orphanOnly: false })
|
||||
const target = result.items.find((item) => item.id === req.params.itemId)
|
||||
if (!target) return res.status(404).json({ error: 'not_found' })
|
||||
if (target.sourceType === 'template') {
|
||||
if (String(target.id || '').startsWith('asset:')) {
|
||||
const assetId = String(target.id).slice('asset:'.length)
|
||||
const asset = await findImageAssetById(assetId)
|
||||
if (!asset) return res.status(404).json({ error: 'not_found' })
|
||||
await deleteImageAssets([assetId])
|
||||
await removeUploadFiles([asset.src])
|
||||
return res.json({ ok: true, sourceType: 'template-asset' })
|
||||
}
|
||||
|
||||
await deleteGameItem(target.id)
|
||||
return res.json({ ok: true, sourceType: 'template' })
|
||||
}
|
||||
|
||||
if (!target.canDelete) return res.status(409).json({ error: 'item_locked' })
|
||||
if (target.linkedGames.length > 0) return res.status(409).json({ error: 'item_linked' })
|
||||
if (target.usageCount > 0) return res.status(409).json({ error: 'item_in_use' })
|
||||
@@ -436,7 +493,7 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
||||
const items = await findCustomItemsByIds([target.id])
|
||||
await deleteCustomItems([target.id])
|
||||
await removeCustomItemFiles(items)
|
||||
res.json({ ok: true })
|
||||
res.json({ ok: true, sourceType: 'user' })
|
||||
})
|
||||
|
||||
router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
|
||||
@@ -451,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 })
|
||||
|
||||
@@ -12,3 +12,11 @@
|
||||
- 책 아이콘 기반 사용법 모달은 제작 흐름뿐 아니라 복사, 템플릿 업데이트 요청, 새 템플릿 요청까지 확장했으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다.
|
||||
|
||||
- 관리자 아이템 라이브러리에서 동일 이미지(src)를 여러 템플릿이 공유하는 경우, 필요하면 묶어서 보거나 대표 카드로 합쳐 보는 후속 정리 옵션을 검토한다.
|
||||
|
||||
- 라이트모드 최종 QA 시 홈/설정/관리자/에디터를 실제 사용 흐름으로 돌리며, 남아 있는 하드코딩 텍스트 색과 플레이스홀더 배경을 한 번 더 점검한다.
|
||||
|
||||
- 관리자 아이템 라이브러리는 보관 자산까지 노출되므로, 이후에는 `활성 템플릿 / 보관 자산` 분리 필터나 그룹 보기까지 검토한다.
|
||||
|
||||
- 가이드 모달과 관리자 아이템 모달은 현재 같은 톤의 큰 셸을 쓰므로, 이후 공통 모달 레이아웃 컴포넌트로 분리할지 검토한다.
|
||||
|
||||
- 관리자 아이템 라이브러리 이름 변경은 템플릿·사용자 업로드·보관 자산까지 모두 가능하므로, 이후에는 일괄 이름 정리나 중복 이름 감지 보조 기능까지 검토한다.
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 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 전환 효과를 추가해 상호작용이 더 분명하게 보이도록 보강함.
|
||||
- 관리자 아이템 모달은 데스크톱에서 최소 폭을 800px로 늘리고 최대 높이를 뷰포트 안으로 제한했으며, 16:9 이미지는 높이 상한을 둬서 모달이 넓어질 때도 이미지와 하단 버튼이 과하게 뭉개지지 않도록 정리함.
|
||||
|
||||
## 2026-04-01 v1.3.37
|
||||
- 가이드 모달은 모바일에서 왼쪽 단계 목록 대신 현재 단계만 선택하는 셀렉트형 피커를 중심으로 쓰도록 높이와 내부 스크롤 구조를 다시 잡아, 작은 화면에서도 내용이 잘리지 않고 조작할 수 있게 정리함.
|
||||
- 관리자 아이템 상세 모달은 가이드 모달과 같은 큰 2단 셸 문법으로 다시 묶어, 왼쪽 게임 선택 패널과 오른쪽 이미지·메타·액션 영역이 더 넓고 여유 있게 보이도록 재구성함.
|
||||
- 아이템 상세 모달 내부 정보 카드와 액션 영역도 같은 톤의 패널형 블록으로 정리해, 가이드와 관리자 모달 사이의 시각적 통일감을 높임.
|
||||
|
||||
## 2026-04-01 v1.3.36
|
||||
- `내 티어표` 화면 헤더를 공통 `pageHead` 문법으로 통일하고, 라이트모드에서는 공통 `railHeader` 배경을 사이드 레일과 같은 톤으로 맞춰 화면 간 상단 밀도 차를 줄임.
|
||||
- 관리자 아이템 상세 모달은 더 넓은 비율로 키우고, 템플릿에 연결된 게임 이름은 hover 가능한 버튼으로 바꿔 클릭 시 해당 게임이 선택된 `게임 관리` 탭으로 바로 이동할 수 있게 함.
|
||||
- 관리자 아이템 라이브러리는 이제 게임에 연결된 템플릿 이미지뿐 아니라 연결이 해제된 `/uploads/assets/` 보관 자산도 함께 보여줘, 게임 목록에서 아이템을 제거해도 아이템 관리에서는 계속 검수·재연결할 수 있게 정리함.
|
||||
- 아이템 관리 탭은 다른 탭으로 이동했다가 돌아오면 검색어와 필터를 초기화해, 결과가 남아 있어 목록이 비어 보이는 오해를 줄이도록 조정함.
|
||||
|
||||
## 2026-04-01 v1.3.35
|
||||
- 라이트모드에서 홈 게임 카드의 메타 텍스트와 대표 썸네일 플레이스홀더, 브랜드 타이틀 색을 다시 정리하고, 전체 밝기도 약간 눌러 눈부심이 덜한 회백색 톤으로 보정함.
|
||||
- 관리자 아이템 상세 모달은 더 넓은 2단 레이아웃으로 키우고, 브라우저 뒤로가기 시 페이지 이탈 대신 모달이 먼저 닫히도록 히스토리 동작을 보강함.
|
||||
- 아이템 라이브러리의 삭제 기준을 다시 정리해, 사용자 업로드는 어디에도 연결되지 않았을 때만 삭제하고 관리자 템플릿 이미지는 라이브러리에서도 해당 템플릿 항목을 제거할 수 있게 확장함.
|
||||
|
||||
## 2026-04-01 v1.3.34
|
||||
- 관리자 아이템 관리 오른쪽 사이드에서는 `가져올 게임` 셀렉트를 제거하고, 사용자 업로드와 관리자 템플릿 이미지를 함께 검수하는 라이브러리 흐름으로 단순화함.
|
||||
- 아이템 상세 모달은 좌측에 검색/정렬 가능한 게임 리스트를 두고 우측에 이미지·메타·액션을 배치하는 2단 레이아웃으로 재구성해, 많은 게임 속에서도 직접 검수 후 템플릿에 연결하기 쉽게 정리함.
|
||||
|
||||
@@ -128,7 +128,7 @@ const isGuidePrevDisabled = computed(() => guideStepIndex.value <= 0)
|
||||
const isGuideNextDisabled = computed(() => guideStepIndex.value >= guideSteps.length - 1)
|
||||
const isLightTheme = computed(() => themeMode.value === 'light')
|
||||
const themeToggleLabel = computed(() => (isLightTheme.value ? '다크 모드' : '라이트 모드'))
|
||||
const showSettingsThemePanel = computed(() => route.name === 'profile')
|
||||
const showSettingsThemePanel = computed(() => false && route.name === 'profile')
|
||||
const showGameHubViewToggle = computed(() => route.name === 'gameHub')
|
||||
const gameHubViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
|
||||
const leftBottomPrimaryAction = computed(() => {
|
||||
@@ -526,6 +526,12 @@ function submitGlobalSearch() {
|
||||
<div class="guideModal__sidebar">
|
||||
<div class="guideModal__eyebrow">Guide</div>
|
||||
<div class="guideModal__title">티어 메이커 기능 안내</div>
|
||||
<div class="guideModal__mobilePicker">
|
||||
<label class="guideModal__mobileLabel" for="guide-step-select">단계 선택</label>
|
||||
<select id="guide-step-select" class="guideModal__mobileSelect" :value="guideStepIndex" @change="selectGuideStep(Number($event.target.value))">
|
||||
<option v-for="(step, index) in guideSteps" :key="step.id + '-select'" :value="index">{{ index + 1 }}. {{ step.title }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="guideModal__list">
|
||||
<button
|
||||
v-for="(step, index) in guideSteps"
|
||||
@@ -684,6 +690,7 @@ function submitGlobalSearch() {
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--theme-border);
|
||||
background: var(--theme-rail-bg);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -1065,10 +1072,7 @@ function submitGlobalSearch() {
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.05em;
|
||||
background-image: linear-gradient(90deg, #ff75c3 0%, #ffa647 20%, #ffe83f 40%, #9fff5b 60%, #70e2ff 80%, #cd93ff 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
color: var(--theme-text-strong);
|
||||
}
|
||||
|
||||
.workspaceHead__brandSub {
|
||||
@@ -1268,7 +1272,7 @@ function submitGlobalSearch() {
|
||||
|
||||
.guideModal__dialog {
|
||||
width: min(1180px, calc(100vw - 40px));
|
||||
min-height: min(760px, calc(100dvh - 64px));
|
||||
height: min(760px, calc(100dvh - 64px));
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
border-radius: 28px;
|
||||
@@ -1301,6 +1305,28 @@ function submitGlobalSearch() {
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.guideModal__mobilePicker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.guideModal__mobileLabel {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--theme-text-faint);
|
||||
}
|
||||
|
||||
.guideModal__mobileSelect {
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
padding: 0 18px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(77, 127, 233, 0.46);
|
||||
background: rgba(77, 127, 233, 0.14);
|
||||
color: var(--theme-text-strong);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.guideModal__list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -1343,8 +1369,10 @@ function submitGlobalSearch() {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
padding: 24px 28px 28px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
|
||||
.guideModal__close {
|
||||
justify-self: end;
|
||||
border: 0;
|
||||
@@ -1356,6 +1384,7 @@ function submitGlobalSearch() {
|
||||
|
||||
.guideModal__content {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 52px minmax(0, 1fr) 52px;
|
||||
gap: 16px;
|
||||
@@ -1364,6 +1393,7 @@ function submitGlobalSearch() {
|
||||
|
||||
.guideModal__body {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
@@ -1619,7 +1649,7 @@ function submitGlobalSearch() {
|
||||
@media (max-width: 1200px) {
|
||||
.guideModal__dialog {
|
||||
grid-template-columns: 1fr;
|
||||
min-height: auto;
|
||||
height: min(860px, calc(100dvh - 40px));
|
||||
}
|
||||
|
||||
.guideModal__sidebar {
|
||||
@@ -1671,20 +1701,55 @@ function submitGlobalSearch() {
|
||||
|
||||
.guideModal__dialog {
|
||||
width: min(100%, calc(100vw - 24px));
|
||||
height: min(100%, calc(100dvh - 24px));
|
||||
}
|
||||
|
||||
.guideModal__sidebar {
|
||||
gap: 14px;
|
||||
padding: 20px 18px;
|
||||
}
|
||||
|
||||
.guideModal__mobilePicker {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.guideModal__list {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.guideModal__main {
|
||||
padding: 20px 18px 18px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.guideModal__content {
|
||||
grid-template-columns: 1fr;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.guideModal__arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.guideModal__body {
|
||||
align-content: start;
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.guideModal__mediaPlaceholder {
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.guideModal__stepTitle {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.guideModal__stepSummary {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.guideModal__footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
@@ -1697,10 +1762,6 @@ function submitGlobalSearch() {
|
||||
.guideDockButton {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.guideModal__list {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -38,28 +38,28 @@
|
||||
}
|
||||
|
||||
:root[data-theme='light'] {
|
||||
--theme-body-bg: #edf1f7;
|
||||
--theme-shell-bg: rgba(244, 247, 252, 0.98);
|
||||
--theme-rail-bg: rgba(248, 250, 253, 0.96);
|
||||
--theme-main-bg: rgba(241, 244, 249, 0.98);
|
||||
--theme-workspace-bg: rgba(250, 252, 255, 0.95);
|
||||
--theme-card-bg: rgba(255, 255, 255, 0.98);
|
||||
--theme-card-bg-hover: rgba(245, 248, 255, 0.98);
|
||||
--theme-card-border: rgba(26, 32, 44, 0.1);
|
||||
--theme-card-shadow: 0 18px 34px rgba(31, 41, 55, 0.08);
|
||||
--theme-surface-soft: rgba(15, 23, 42, 0.05);
|
||||
--theme-surface-soft-2: rgba(15, 23, 42, 0.07);
|
||||
--theme-surface-soft-3: rgba(15, 23, 42, 0.1);
|
||||
--theme-pill-bg: rgba(15, 23, 42, 0.04);
|
||||
--theme-border: rgba(15, 23, 42, 0.1);
|
||||
--theme-border-strong: rgba(15, 23, 42, 0.14);
|
||||
--theme-text: rgba(20, 27, 40, 0.9);
|
||||
--theme-body-bg: #e7ebf2;
|
||||
--theme-shell-bg: rgba(237, 241, 247, 0.98);
|
||||
--theme-rail-bg: rgba(243, 246, 251, 0.97);
|
||||
--theme-main-bg: rgba(232, 236, 243, 0.98);
|
||||
--theme-workspace-bg: rgba(247, 249, 252, 0.96);
|
||||
--theme-card-bg: rgba(252, 253, 255, 0.98);
|
||||
--theme-card-bg-hover: rgba(244, 247, 251, 0.98);
|
||||
--theme-card-border: rgba(31, 41, 55, 0.11);
|
||||
--theme-card-shadow: 0 18px 34px rgba(31, 41, 55, 0.07);
|
||||
--theme-surface-soft: rgba(30, 41, 59, 0.055);
|
||||
--theme-surface-soft-2: rgba(30, 41, 59, 0.075);
|
||||
--theme-surface-soft-3: rgba(30, 41, 59, 0.105);
|
||||
--theme-pill-bg: rgba(30, 41, 59, 0.045);
|
||||
--theme-border: rgba(30, 41, 59, 0.11);
|
||||
--theme-border-strong: rgba(30, 41, 59, 0.16);
|
||||
--theme-text: rgba(20, 27, 40, 0.92);
|
||||
--theme-text-strong: rgba(10, 15, 28, 0.98);
|
||||
--theme-text-muted: rgba(55, 65, 81, 0.74);
|
||||
--theme-text-soft: rgba(75, 85, 99, 0.64);
|
||||
--theme-text-faint: rgba(100, 116, 139, 0.82);
|
||||
--theme-thumb-fallback-bg: #d8dde8;
|
||||
--theme-select-arrow: rgba(55, 65, 81, 0.72);
|
||||
--theme-text-muted: rgba(55, 65, 81, 0.76);
|
||||
--theme-text-soft: rgba(75, 85, 99, 0.72);
|
||||
--theme-text-faint: rgba(100, 116, 139, 0.88);
|
||||
--theme-thumb-fallback-bg: #f6f8fb;
|
||||
--theme-select-arrow: rgba(55, 65, 81, 0.74);
|
||||
--theme-danger-bg: rgba(239, 68, 68, 0.1);
|
||||
--theme-danger-border: rgba(239, 68, 68, 0.22);
|
||||
--theme-accent-bg: rgba(64, 110, 226, 0.94);
|
||||
|
||||
@@ -56,6 +56,7 @@ const userDeleteModalOpen = ref(false)
|
||||
const userRoleModalOpen = ref(false)
|
||||
const customItemModalOpen = ref(false)
|
||||
const customItemDeleteModalOpen = ref(false)
|
||||
const customItemModalHistoryActive = ref(false)
|
||||
const modalTargetUser = ref(null)
|
||||
const modalPasswordDraft = ref('')
|
||||
const modalRoleNextAdmin = ref(false)
|
||||
@@ -63,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('')
|
||||
@@ -95,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)
|
||||
@@ -193,14 +197,65 @@ 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) {
|
||||
customItemDeleteModalOpen.value = false
|
||||
if (customItemModalOpen.value) pushCustomItemModalHistoryState()
|
||||
return
|
||||
}
|
||||
if (customItemModalOpen.value) {
|
||||
closeCustomItemModal({ fromPopState: true })
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
window.removeEventListener('keydown', handleAdminKeydown)
|
||||
}
|
||||
if (typeof document !== 'undefined') document.body.style.overflow = previousBodyOverflow.value || ''
|
||||
clearPreviewUrl('item')
|
||||
clearPreviewUrl('thumb')
|
||||
destroyFeaturedSortable()
|
||||
@@ -251,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 = ''
|
||||
@@ -386,6 +457,12 @@ function setTab(tab) {
|
||||
if (tab === 'tierlists') {
|
||||
tierlistsMode.value = 'requests'
|
||||
}
|
||||
if (tab === 'items') {
|
||||
customItemQuery.value = ''
|
||||
customItemOrphanOnly.value = false
|
||||
customItemPage.value = 1
|
||||
refreshCustomItems()
|
||||
}
|
||||
}
|
||||
|
||||
function setTierlistsMode(mode) {
|
||||
@@ -658,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 = '게임이 생성됐어요. 이어서 썸네일과 기본 아이템을 관리할 수 있어요.'
|
||||
@@ -1048,26 +1126,47 @@ function moveCustomItemPage(direction) {
|
||||
refreshCustomItems()
|
||||
}
|
||||
|
||||
function pushCustomItemModalHistoryState() {
|
||||
if (typeof window === 'undefined') return
|
||||
window.history.pushState({ ...(window.history.state || {}), adminCustomItemModal: true }, '', window.location.href)
|
||||
customItemModalHistoryActive.value = true
|
||||
}
|
||||
|
||||
function openCustomItemModal(item) {
|
||||
modalTargetCustomItem.value = item || null
|
||||
customItemModalDraftLabel.value = item?.label || ''
|
||||
customItemModalTargetGameId.value = ''
|
||||
customItemModalGameQuery.value = ''
|
||||
customItemModalGameSort.value = 'recent'
|
||||
customItemModalOpen.value = true
|
||||
pushCustomItemModalHistoryState()
|
||||
}
|
||||
|
||||
function closeCustomItemModal() {
|
||||
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'
|
||||
|
||||
if (fromPopState) {
|
||||
customItemModalHistoryActive.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (customItemModalHistoryActive.value && typeof window !== 'undefined') {
|
||||
customItemModalHistoryActive.value = false
|
||||
window.history.back()
|
||||
}
|
||||
}
|
||||
|
||||
function openCustomItemDeleteModal(item) {
|
||||
if (!item) return
|
||||
if (item.usageCount > 0) {
|
||||
error.value = '사용 중인 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
|
||||
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedGames.length > 0)) {
|
||||
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
|
||||
return
|
||||
}
|
||||
modalTargetCustomItem.value = item
|
||||
@@ -1078,11 +1177,20 @@ function closeCustomItemDeleteModal() {
|
||||
customItemDeleteModalOpen.value = false
|
||||
}
|
||||
|
||||
function jumpToGameAdmin(gameId) {
|
||||
if (!gameId) return
|
||||
closeCustomItemModal()
|
||||
setTab('game-admin')
|
||||
nextTick(() => {
|
||||
selectAdminGame(gameId)
|
||||
})
|
||||
}
|
||||
|
||||
async function removeCustomItem(item = modalTargetCustomItem.value) {
|
||||
resetMessages()
|
||||
if (!item) return
|
||||
if (item.usageCount > 0) {
|
||||
error.value = '사용 중인 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
|
||||
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedGames.length > 0)) {
|
||||
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1091,9 +1199,9 @@ async function removeCustomItem(item = modalTargetCustomItem.value) {
|
||||
closeCustomItemDeleteModal()
|
||||
closeCustomItemModal()
|
||||
await refreshCustomItems()
|
||||
success.value = '미사용 사용자 업로드 이미지를 삭제했어요.'
|
||||
success.value = item.sourceType === 'template' ? '선택한 템플릿 아이템을 제거했어요.' : '사용자 업로드 이미지를 삭제했어요.'
|
||||
} catch (e) {
|
||||
error.value = '커스텀 이미지 삭제에 실패했어요.'
|
||||
error.value = item.sourceType === 'template' ? '템플릿 아이템 제거에 실패했어요.' : '사용자 업로드 이미지 삭제에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1111,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) {
|
||||
@@ -1132,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) : ''
|
||||
}
|
||||
@@ -1162,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 || '템플릿 요청 미리보기',
|
||||
@@ -1175,7 +1361,7 @@ function openTemplateRequestPreview(request) {
|
||||
thumbnailSrc: request.thumbnailSrc || '',
|
||||
requestPreview: true,
|
||||
snapshotGroups: request.snapshotGroups || [],
|
||||
snapshotItems: request.snapshotItems || [],
|
||||
snapshotItems,
|
||||
snapshotShowCharacterNames: !!request.snapshotShowCharacterNames,
|
||||
}
|
||||
previewModalOpen.value = true
|
||||
@@ -1637,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>
|
||||
@@ -1652,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">
|
||||
@@ -1663,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>
|
||||
@@ -1932,15 +2117,12 @@ async function saveFeaturedOrder() {
|
||||
|
||||
<div v-if="customItemModalOpen" class="modalOverlay" @click.self="closeCustomItemModal">
|
||||
<div class="modalCard modalCard--customItem" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__titleRow">
|
||||
<div class="modalCard__title">아이템 상세</div>
|
||||
<button class="btn btn--ghost btn--small" @click="closeCustomItemModal">닫기</button>
|
||||
</div>
|
||||
<div v-if="modalTargetCustomItem" class="customItemModal">
|
||||
<aside class="customItemModal__pickerPanel">
|
||||
<div class="customItemModal__pickerHead">
|
||||
<div class="customItemModal__pickerEyebrow">GAME LIBRARY</div>
|
||||
<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 검색" />
|
||||
@@ -1968,13 +2150,24 @@ async function saveFeaturedOrder() {
|
||||
</div>
|
||||
</aside>
|
||||
<div class="customItemModal__body">
|
||||
<div class="customItemModal__titleRow">
|
||||
<div>
|
||||
<div class="customItemModal__title">{{ modalTargetCustomItem.label }}</div>
|
||||
<div class="customItemModal__source">{{ modalTargetCustomItem.sourceLabel }}</div>
|
||||
<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>
|
||||
<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>
|
||||
@@ -1984,16 +2177,17 @@ async function saveFeaturedOrder() {
|
||||
<div class="customItemModal__linked">
|
||||
<span class="customItemModal__label">템플릿에 사용 중인 게임</span>
|
||||
<div v-if="visibleLinkedGames.length" class="customItemModal__chips">
|
||||
<span v-for="game in visibleLinkedGames" :key="game.id" class="pill">{{ game.name }}</span>
|
||||
<button v-for="game in visibleLinkedGames" :key="game.id" type="button" class="pill pill--link" @click="jumpToGameAdmin(game.id)">{{ game.name }}</button>
|
||||
</div>
|
||||
<div v-else class="hint hint--tight">아직 템플릿에 연결된 게임이 없어요.</div>
|
||||
</div>
|
||||
<div class="customItemModal__actions">
|
||||
<a class="btn btn--ghost customItemModal__action" :href="toApiUrl(modalTargetCustomItem.src)" :download="modalTargetCustomItem.label">이미지 다운로드</a>
|
||||
<button class="btn btn--ghost customItemModal__action" :disabled="!customItemModalTargetGameId || modalTargetCustomItem.isPromoting" @click="promoteCustomItem(modalTargetCustomItem)">
|
||||
{{ modalTargetCustomItem.isPromoting ? '추가중...' : '기본 템플릿에 추가' }}
|
||||
</button>
|
||||
<button v-if="modalTargetCustomItem.canDelete" class="btn btn--danger customItemModal__action" :disabled="modalTargetCustomItem.usageCount > 0 || visibleLinkedGames.length > 0" @click="openCustomItemDeleteModal(modalTargetCustomItem)">삭제</button>
|
||||
<div class="customItemModal__actions">
|
||||
<a class="btn btn--ghost customItemModal__action" :href="toApiUrl(modalTargetCustomItem.src)" :download="modalTargetCustomItem.label">이미지 다운로드</a>
|
||||
<button class="btn btn--ghost customItemModal__action" :disabled="!customItemModalTargetGameId || modalTargetCustomItem.isPromoting" @click="promoteCustomItem(modalTargetCustomItem)">
|
||||
{{ modalTargetCustomItem.isPromoting ? '추가중...' : '기본 템플릿에 추가' }}
|
||||
</button>
|
||||
<button v-if="modalTargetCustomItem.canDelete" class="btn btn--danger customItemModal__action" :disabled="modalTargetCustomItem.sourceType === 'user' && (modalTargetCustomItem.usageCount > 0 || visibleLinkedGames.length > 0)" @click="openCustomItemDeleteModal(modalTargetCustomItem)">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2002,8 +2196,8 @@ async function saveFeaturedOrder() {
|
||||
|
||||
<div v-if="customItemDeleteModalOpen" class="modalOverlay" @click.self="closeCustomItemDeleteModal">
|
||||
<div class="modalCard" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__title">커스텀 아이템 삭제</div>
|
||||
<div class="modalCard__desc">{{ modalTargetCustomItem ? '"' + modalTargetCustomItem.label + '" 이미지를 삭제할까요? 사용자 업로드이면서 어디에도 연결되지 않은 이미지에만 삭제를 허용합니다.' : '' }}</div>
|
||||
<div class="modalCard__title">아이템 삭제</div>
|
||||
<div class="modalCard__desc">{{ !modalTargetCustomItem ? '' : modalTargetCustomItem.sourceType === 'template' ? '"' + modalTargetCustomItem.label + '" 항목을 정리할까요? 게임에 연결된 항목이면 해당 템플릿과 저장된 같은 게임의 티어표에서도 함께 빠질 수 있고, 보관 자산이면 라이브러리에서만 제거됩니다.' : '"' + modalTargetCustomItem.label + '" 이미지를 삭제할까요? 사용자 업로드이면서 어디에도 연결되지 않은 이미지에만 삭제를 허용합니다.' }}</div>
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--ghost" @click="closeCustomItemDeleteModal">취소</button>
|
||||
<button class="btn btn--danger" @click="removeCustomItem()">삭제</button>
|
||||
@@ -2030,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"
|
||||
@@ -2894,6 +3120,7 @@ async function saveFeaturedOrder() {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--theme-text-soft);
|
||||
background: var(--theme-card-bg);
|
||||
}
|
||||
.selectedThumb--sidebar {
|
||||
width: 100%;
|
||||
@@ -3134,26 +3361,26 @@ async function saveFeaturedOrder() {
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
color: #ffffff;
|
||||
color: var(--theme-text);
|
||||
}
|
||||
.customItemModal {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
grid-template-columns: 340px minmax(0, 1fr);
|
||||
min-height: min(860px, calc(100dvh - 40px));
|
||||
align-items: stretch;
|
||||
}
|
||||
.customItemModal__pickerPanel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--theme-border);
|
||||
padding: 28px 22px;
|
||||
border-right: 1px solid var(--theme-border);
|
||||
background: var(--theme-pill-bg);
|
||||
}
|
||||
.customItemModal__pickerHead {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
gap: 10px;
|
||||
}
|
||||
.customItemModal__pickerEyebrow {
|
||||
font-size: 11px;
|
||||
@@ -3172,9 +3399,12 @@ async function saveFeaturedOrder() {
|
||||
.customItemModal__gameList {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 360px;
|
||||
max-height: 440px;
|
||||
overflow: auto;
|
||||
}
|
||||
.customItemModal__createGameButton {
|
||||
justify-self: start;
|
||||
}
|
||||
.customItemModal__gameItem {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
@@ -3203,22 +3433,63 @@ async function saveFeaturedOrder() {
|
||||
color: var(--theme-text-soft);
|
||||
}
|
||||
.customItemModal__body {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
padding: 24px 28px 28px;
|
||||
}
|
||||
.customItemModal__content {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 18px;
|
||||
overflow: auto;
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.customItemModal__linked {
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-surface-soft);
|
||||
}
|
||||
.customItemModal__close {
|
||||
justify-self: end;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--theme-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.customItemModal__image {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
max-height: min(360px, 34dvh);
|
||||
object-fit: cover;
|
||||
border-radius: 20px;
|
||||
background: var(--theme-surface-soft);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 24px;
|
||||
background: radial-gradient(circle at top, rgba(77, 127, 233, 0.18), rgba(255, 255, 255, 0.02) 52%), rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--theme-border);
|
||||
}
|
||||
.customItemModal__label {
|
||||
font-size: 11px;
|
||||
@@ -3243,6 +3514,10 @@ async function saveFeaturedOrder() {
|
||||
.customItemModal__metaList {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-surface-soft);
|
||||
}
|
||||
.customItemModal__metaRow {
|
||||
display: grid;
|
||||
@@ -3264,7 +3539,8 @@ async function saveFeaturedOrder() {
|
||||
.customItemModal__actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
align-self: end;
|
||||
}
|
||||
.customItemModal__action {
|
||||
width: 100%;
|
||||
@@ -3275,7 +3551,16 @@ async function saveFeaturedOrder() {
|
||||
text-align: center;
|
||||
}
|
||||
.modalCard--customItem {
|
||||
width: min(980px, 100%);
|
||||
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;
|
||||
border-radius: 28px;
|
||||
border: 1px solid var(--theme-border-strong);
|
||||
background: linear-gradient(180deg, rgba(34, 34, 34, 0.98), rgba(18, 18, 18, 0.98));
|
||||
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.42);
|
||||
}
|
||||
.pager {
|
||||
margin-top: 16px;
|
||||
@@ -3622,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;
|
||||
@@ -3703,7 +4028,7 @@ async function saveFeaturedOrder() {
|
||||
padding: 16px;
|
||||
}
|
||||
.tierAdminCard__preview {
|
||||
cursor: pointer;
|
||||
cursor: default;
|
||||
}
|
||||
.tierAdminCard__thumb {
|
||||
width: 100%;
|
||||
@@ -3758,6 +4083,7 @@ async function saveFeaturedOrder() {
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: var(--theme-surface-soft);
|
||||
color: var(--theme-text);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
@@ -3766,6 +4092,22 @@ async function saveFeaturedOrder() {
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
color: rgba(253, 230, 138, 0.96);
|
||||
}
|
||||
.pill--link {
|
||||
color: var(--theme-text);
|
||||
cursor: pointer;
|
||||
transition: background 160ms ease, border-color 160ms ease, transform 160ms ease, color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
.pill--link:hover {
|
||||
color: var(--theme-text-strong);
|
||||
border-color: rgba(96, 165, 250, 0.4);
|
||||
background: color-mix(in srgb, var(--theme-surface-soft) 76%, rgba(96, 165, 250, 0.2));
|
||||
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.18);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.pill--link:focus-visible {
|
||||
outline: 2px solid rgba(96, 165, 250, 0.42);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.tierAdminSection {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -3893,6 +4235,32 @@ async function saveFeaturedOrder() {
|
||||
}
|
||||
.customItemModal {
|
||||
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 {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--theme-border);
|
||||
padding: 20px 18px;
|
||||
}
|
||||
.customItemModal__body {
|
||||
min-height: 0;
|
||||
padding: 20px 18px 18px;
|
||||
}
|
||||
.customItemModal__content {
|
||||
min-height: 0;
|
||||
}
|
||||
.customItemModal__labelEditor {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.customItemModal__actions {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -215,7 +215,7 @@ function thumbUrl(g) {
|
||||
font-size: 18px;
|
||||
}
|
||||
.libraryCard__meta {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
color: var(--theme-text-soft);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -66,11 +66,11 @@ function openList(t) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="dashboardHero">
|
||||
<div class="dashboardHero__left">
|
||||
<div class="dashboardHero__eyebrow">Library</div>
|
||||
<h2 class="dashboardHero__title">내 티어표</h2>
|
||||
<p class="dashboardHero__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 수 있어요.</p>
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Library</div>
|
||||
<h2 class="pageHead__title">내 티어표</h2>
|
||||
<div class="pageHead__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 수 있어요.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -110,35 +110,6 @@ function openList(t) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboardHero {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px 2px 18px;
|
||||
}
|
||||
.dashboardHero__left {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.dashboardHero__eyebrow {
|
||||
font-size: 12px;
|
||||
color: var(--theme-text-soft);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.dashboardHero__title {
|
||||
margin: 4px 0 6px;
|
||||
font-size: 32px;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--theme-text-strong);
|
||||
}
|
||||
.dashboardHero__desc {
|
||||
margin: 0;
|
||||
color: var(--theme-text-muted);
|
||||
max-width: 720px;
|
||||
}
|
||||
.panel {
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
|
||||
Reference in New Issue
Block a user