Compare commits

...

9 Commits

10 changed files with 618 additions and 90 deletions

View File

@@ -95,6 +95,7 @@ function mapImageAssetRow(row) {
id: row.id, id: row.id,
contentHash: row.content_hash, contentHash: row.content_hash,
src: row.src || '', src: row.src || '',
labelOverride: row.label_override || '',
mimeType: row.mime_type || 'image/webp', mimeType: row.mime_type || 'image/webp',
byteSize: Number(row.byte_size || 0), byteSize: Number(row.byte_size || 0),
originalByteSize: Number(row.original_byte_size || 0), originalByteSize: Number(row.original_byte_size || 0),
@@ -342,6 +343,7 @@ async function ensureSchema() {
id VARCHAR(64) PRIMARY KEY, id VARCHAR(64) PRIMARY KEY,
content_hash CHAR(64) NOT NULL UNIQUE, content_hash CHAR(64) NOT NULL UNIQUE,
src VARCHAR(255) 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', mime_type VARCHAR(32) NOT NULL DEFAULT 'image/webp',
byte_size INT UNSIGNED NOT NULL, byte_size INT UNSIGNED NOT NULL,
original_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 ) 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(` await query(`
CREATE TABLE IF NOT EXISTS image_optimization_jobs ( CREATE TABLE IF NOT EXISTS image_optimization_jobs (
id VARCHAR(64) PRIMARY KEY, id VARCHAR(64) PRIMARY KEY,
@@ -396,6 +403,22 @@ async function ensureSchema() {
if (templateRequestSourceTierListColumns[0]?.Null !== 'YES') { if (templateRequestSourceTierListColumns[0]?.Null !== 'YES') {
await query('ALTER TABLE template_requests MODIFY source_tierlist_id VARCHAR(64) NULL') await query('ALTER TABLE template_requests MODIFY source_tierlist_id VARCHAR(64) NULL')
} }
const templateRequestTypeColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'request_type'")
if (!templateRequestTypeColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN request_type VARCHAR(20) NOT NULL DEFAULT 'create' AFTER id")
}
const templateRequestSourceGameColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'source_game_id'")
if (!templateRequestSourceGameColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN source_game_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER source_tierlist_id")
}
const templateRequestTargetGameColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'target_game_id'")
if (!templateRequestTargetGameColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN target_game_id VARCHAR(120) NOT NULL DEFAULT '' AFTER source_game_id")
}
const templateRequestStatusColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'status'")
if (!templateRequestStatusColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending' AFTER target_game_id")
}
const templateRequestGroupsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'groups_json'") const templateRequestGroupsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'groups_json'")
if (!templateRequestGroupsColumns.length) { if (!templateRequestGroupsColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN groups_json LONGTEXT NOT NULL AFTER items_json") await query("ALTER TABLE template_requests ADD COLUMN groups_json LONGTEXT NOT NULL AFTER items_json")
@@ -420,6 +443,8 @@ async function ensureSchema() {
const tierListSourceIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_tierlist_id'") const tierListSourceIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_tierlist_id'")
if (!tierListSourceIdColumns.length) { if (!tierListSourceIdColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN source_tierlist_id VARCHAR(64) NULL DEFAULT NULL AFTER show_character_names") await query("ALTER TABLE tierlists ADD COLUMN source_tierlist_id VARCHAR(64) NULL DEFAULT NULL AFTER show_character_names")
} else if (tierListSourceIdColumns[0]?.Null !== 'YES') {
await query('ALTER TABLE tierlists MODIFY source_tierlist_id VARCHAR(64) NULL DEFAULT NULL')
} }
const tierListSourceTitleColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_snapshot_title'") const tierListSourceTitleColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_snapshot_title'")
if (!tierListSourceTitleColumns.length) { if (!tierListSourceTitleColumns.length) {
@@ -679,7 +704,7 @@ async function updateGameThumbnail(gameId, thumbnailSrc) {
async function findImageAssetByHash(contentHash) { async function findImageAssetByHash(contentHash) {
const rows = await query( 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] [contentHash]
) )
return mapImageAssetRow(rows[0]) return mapImageAssetRow(rows[0])
@@ -687,7 +712,7 @@ async function findImageAssetByHash(contentHash) {
async function findImageAssetBySrc(src) { async function findImageAssetBySrc(src) {
const rows = await query( 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] [src]
) )
return mapImageAssetRow(rows[0]) return mapImageAssetRow(rows[0])
@@ -765,7 +790,7 @@ async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
const cutoff = now() - safeMinAgeHours * 60 * 60 * 1000 const cutoff = now() - safeMinAgeHours * 60 * 60 * 1000
const assets = (await query( 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] [cutoff]
)).map(mapImageAssetRow) )).map(mapImageAssetRow)
@@ -806,7 +831,7 @@ async function deleteImageAssets(ids) {
if (!uniqueIds.length) return [] if (!uniqueIds.length) return []
const placeholders = uniqueIds.map(() => '?').join(', ') const placeholders = uniqueIds.map(() => '?').join(', ')
const rows = await query( 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 uniqueIds
) )
await query(`DELETE FROM image_assets WHERE id IN (${placeholders})`, uniqueIds) await query(`DELETE FROM image_assets WHERE id IN (${placeholders})`, uniqueIds)
@@ -931,14 +956,14 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
async function listImageAssets() { async function listImageAssets() {
const rows = await query( 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) return rows.map(mapImageAssetRow)
} }
async function findImageAssetById(id) { async function findImageAssetById(id) {
const rows = await query( 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] [id]
) )
return mapImageAssetRow(rows[0]) return mapImageAssetRow(rows[0])
@@ -1069,6 +1094,34 @@ async function updateGameItemLabel(itemId, label) {
return mapGameItemRow(rows[0]) 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) { async function deleteGameItem(itemId) {
const gameItemRows = await query('SELECT game_id FROM game_items WHERE id = ? LIMIT 1', [itemId]) const gameItemRows = await query('SELECT game_id FROM game_items WHERE id = ? LIMIT 1', [itemId])
const gameId = gameItemRows[0]?.game_id const gameId = gameItemRows[0]?.game_id
@@ -1261,7 +1314,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl
), ),
query( query(
` `
SELECT ia.id, ia.src, ia.created_at SELECT ia.id, ia.src, ia.label_override, ia.created_at
FROM image_assets ia FROM image_assets ia
WHERE ia.src LIKE '/uploads/assets/%' WHERE ia.src LIKE '/uploads/assets/%'
${hasQuery ? 'AND ia.src LIKE ?' : ''} ${hasQuery ? 'AND ia.src LIKE ?' : ''}
@@ -1309,7 +1362,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl
assetId: row.id, assetId: row.id,
ownerId: '', ownerId: '',
src: row.src, src: row.src,
label: (row.src.split('/').pop() || '').replace(/\.[^.]+$/, '') || '이름 없음', label: row.label_override || (row.src.split('/').pop() || '').replace(/\.[^.]+$/, '') || '이름 없음',
createdAt: Number(row.created_at || 0), createdAt: Number(row.created_at || 0),
ownerName: '관리자 보관 자산', ownerName: '관리자 보관 자산',
ownerEmail: '', ownerEmail: '',
@@ -1968,6 +2021,7 @@ async function saveTierList({
return findTierListById(existing.id, authorId) return findTierListById(existing.id, authorId)
} }
const nextId = id || nanoid()
const createdAt = now() const createdAt = now()
await query( await query(
` `
@@ -1976,9 +2030,9 @@ async function saveTierList({
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
[id, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), createdAt, createdAt] [nextId, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), createdAt, createdAt]
) )
return findTierListById(id, authorId) return findTierListById(nextId, authorId)
} }
async function duplicateTierListForUser({ tierList, targetUserId }) { async function duplicateTierListForUser({ tierList, targetUserId }) {
@@ -2057,6 +2111,8 @@ module.exports = {
getImageAssetStats, getImageAssetStats,
createGameItem, createGameItem,
updateGameItemLabel, updateGameItemLabel,
updateCustomItemLabel,
updateImageAssetLabel,
deleteGameItem, deleteGameItem,
deleteGame, deleteGame,
updateGameDisplayOrder, updateGameDisplayOrder,

View File

@@ -15,6 +15,8 @@ const {
updateGameThumbnail, updateGameThumbnail,
createGameItem, createGameItem,
updateGameItemLabel, updateGameItemLabel,
updateCustomItemLabel,
updateImageAssetLabel,
deleteGameItem, deleteGameItem,
deleteGame, deleteGame,
updateGameDisplayOrder, updateGameDisplayOrder,
@@ -192,6 +194,32 @@ router.delete('/games/:gameId', requireAdmin, async (req, res) => {
res.json({ ok: true }) 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) => { router.get('/custom-items', requireAdmin, async (req, res) => {
const schema = z.object({ const schema = z.object({
q: z.string().trim().max(120).optional().default(''), 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 customItem = await findCustomItemById(req.params.itemId)
const gameItem = customItem ? null : await findGameItemById(req.params.itemId) const gameItem = customItem ? null : await findGameItemById(req.params.itemId)
const sourceItem = customItem || gameItem const assetItemId = String(req.params.itemId || '')
const imageAsset = !customItem && !gameItem && assetItemId.startsWith('asset:') ? await findImageAssetById(assetItemId.slice(6)) : null
const sourceItem =
customItem ||
gameItem ||
(imageAsset
? {
src: imageAsset.src || '',
label: imageAsset.labelOverride || path.basename(imageAsset.src || '', path.extname(imageAsset.src || '')) || 'item',
}
: null)
if (!sourceItem) return res.status(404).json({ error: 'not_found' }) if (!sourceItem) return res.status(404).json({ error: 'not_found' })
const item = await promoteLibraryItemToGameItem({ item: sourceItem, gameId: game.id }) const item = await promoteLibraryItemToGameItem({ item: sourceItem, gameId: game.id })

View File

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

View File

@@ -16,3 +16,8 @@
- 라이트모드 최종 QA 시 홈/설정/관리자/에디터를 실제 사용 흐름으로 돌리며, 남아 있는 하드코딩 텍스트 색과 플레이스홀더 배경을 한 번 더 점검한다. - 라이트모드 최종 QA 시 홈/설정/관리자/에디터를 실제 사용 흐름으로 돌리며, 남아 있는 하드코딩 텍스트 색과 플레이스홀더 배경을 한 번 더 점검한다.
- 관리자 아이템 라이브러리는 보관 자산까지 노출되므로, 이후에는 `활성 템플릿 / 보관 자산` 분리 필터나 그룹 보기까지 검토한다. - 관리자 아이템 라이브러리는 보관 자산까지 노출되므로, 이후에는 `활성 템플릿 / 보관 자산` 분리 필터나 그룹 보기까지 검토한다.
- 가이드 모달과 관리자 아이템 모달은 현재 같은 톤의 큰 셸을 쓰므로, 이후 공통 모달 레이아웃 컴포넌트로 분리할지 검토한다.
- 관리자 아이템 라이브러리 이름 변경은 템플릿·사용자 업로드·보관 자산까지 모두 가능하므로, 이후에는 일괄 이름 정리나 중복 이름 감지 보조 기능까지 검토한다.
- 관리자 템플릿 요청 미리보기는 실제 완성본 모달과 더 가까운 체감이 되도록, 이후에도 보드 여백·행/열 헤더·남은 아이템 밀도를 한 번 더 비교 QA한다.

View File

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

View File

@@ -128,7 +128,7 @@ const isGuidePrevDisabled = computed(() => guideStepIndex.value <= 0)
const isGuideNextDisabled = computed(() => guideStepIndex.value >= guideSteps.length - 1) const isGuideNextDisabled = computed(() => guideStepIndex.value >= guideSteps.length - 1)
const isLightTheme = computed(() => themeMode.value === 'light') const isLightTheme = computed(() => themeMode.value === 'light')
const themeToggleLabel = computed(() => (isLightTheme.value ? '다크 모드' : '라이트 모드')) 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 showGameHubViewToggle = computed(() => route.name === 'gameHub')
const gameHubViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid')) const gameHubViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
const leftBottomPrimaryAction = computed(() => { const leftBottomPrimaryAction = computed(() => {
@@ -526,6 +526,12 @@ function submitGlobalSearch() {
<div class="guideModal__sidebar"> <div class="guideModal__sidebar">
<div class="guideModal__eyebrow">Guide</div> <div class="guideModal__eyebrow">Guide</div>
<div class="guideModal__title">티어 메이커 기능 안내</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"> <div class="guideModal__list">
<button <button
v-for="(step, index) in guideSteps" v-for="(step, index) in guideSteps"
@@ -1266,7 +1272,7 @@ function submitGlobalSearch() {
.guideModal__dialog { .guideModal__dialog {
width: min(1180px, calc(100vw - 40px)); width: min(1180px, calc(100vw - 40px));
min-height: min(760px, calc(100dvh - 64px)); height: min(760px, calc(100dvh - 64px));
display: grid; display: grid;
grid-template-columns: 260px minmax(0, 1fr); grid-template-columns: 260px minmax(0, 1fr);
border-radius: 28px; border-radius: 28px;
@@ -1299,6 +1305,28 @@ function submitGlobalSearch() {
letter-spacing: -0.04em; 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 { .guideModal__list {
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -1341,8 +1369,10 @@ function submitGlobalSearch() {
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
padding: 24px 28px 28px; padding: 24px 28px 28px;
min-height: 0;
} }
.guideModal__close { .guideModal__close {
justify-self: end; justify-self: end;
border: 0; border: 0;
@@ -1354,6 +1384,7 @@ function submitGlobalSearch() {
.guideModal__content { .guideModal__content {
min-width: 0; min-width: 0;
min-height: 0;
display: grid; display: grid;
grid-template-columns: 52px minmax(0, 1fr) 52px; grid-template-columns: 52px minmax(0, 1fr) 52px;
gap: 16px; gap: 16px;
@@ -1362,6 +1393,7 @@ function submitGlobalSearch() {
.guideModal__body { .guideModal__body {
min-width: 0; min-width: 0;
min-height: 0;
display: grid; display: grid;
gap: 18px; gap: 18px;
} }
@@ -1617,7 +1649,7 @@ function submitGlobalSearch() {
@media (max-width: 1200px) { @media (max-width: 1200px) {
.guideModal__dialog { .guideModal__dialog {
grid-template-columns: 1fr; grid-template-columns: 1fr;
min-height: auto; height: min(860px, calc(100dvh - 40px));
} }
.guideModal__sidebar { .guideModal__sidebar {
@@ -1669,20 +1701,55 @@ function submitGlobalSearch() {
.guideModal__dialog { .guideModal__dialog {
width: min(100%, calc(100vw - 24px)); 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 { .guideModal__main {
padding: 20px 18px 18px; padding: 20px 18px 18px;
min-height: 0;
} }
.guideModal__content { .guideModal__content {
grid-template-columns: 1fr; grid-template-columns: 1fr;
min-height: 0;
} }
.guideModal__arrow { .guideModal__arrow {
display: none; 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 { .guideModal__footer {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
@@ -1695,10 +1762,6 @@ function submitGlobalSearch() {
.guideDockButton { .guideDockButton {
display: none; display: none;
} }
.guideModal__list {
grid-template-columns: 1fr 1fr;
}
} }
@media (max-width: 860px) { @media (max-width: 860px) {

View File

@@ -55,6 +55,8 @@ export const api = {
cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }), cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }),
promoteAdminCustomItem: (itemId, payload) => promoteAdminCustomItem: (itemId, payload) =>
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: 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) => promoteAdminTierListItems: (tierListId, payload) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/promote-items`, { method: 'POST', body: payload }), request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/promote-items`, { method: 'POST', body: payload }),
createAdminGameTemplateFromTierList: (tierListId, payload) => createAdminGameTemplateFromTierList: (tierListId, payload) =>

View File

@@ -64,6 +64,8 @@ const modalUserDraftEmail = ref('')
const modalUserDraftNickname = ref('') const modalUserDraftNickname = ref('')
const modalUserDraftIsAdmin = ref(false) const modalUserDraftIsAdmin = ref(false)
const modalTargetCustomItem = ref(null) const modalTargetCustomItem = ref(null)
const customItemModalDraftLabel = ref('')
const customItemModalLabelSaving = ref(false)
const users = ref([]) const users = ref([])
const userQuery = ref('') const userQuery = ref('')
@@ -96,6 +98,7 @@ const featuredSortable = ref(null)
const userAvatarInputs = ref({}) const userAvatarInputs = ref({})
const isGameLoading = ref(false) const isGameLoading = ref(false)
const gameCreateModalOpen = ref(false) const gameCreateModalOpen = ref(false)
const previousBodyOverflow = ref('')
const hasSelectedGame = computed(() => !!selectedGame.value?.game?.id) const hasSelectedGame = computed(() => !!selectedGame.value?.game?.id)
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedGameId.value) const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedGameId.value)
@@ -194,6 +197,19 @@ const adminOverviewStats = computed(() => {
{ label: '활동 계정', value: `${users.value.filter((user) => user.tierListCount > 0).length}` }, { label: '활동 계정', value: `${users.value.filter((user) => user.tierListCount > 0).length}` },
] ]
}) })
const isAnyModalOpen = computed(
() =>
gameCreateModalOpen.value ||
userEditModalOpen.value ||
userPasswordModalOpen.value ||
userDeleteModalOpen.value ||
userRoleModalOpen.value ||
importModalOpen.value ||
customItemModalOpen.value ||
customItemDeleteModalOpen.value ||
imageResetModalOpen.value ||
previewModalOpen.value
)
function handleAdminPopState() { function handleAdminPopState() {
if (customItemDeleteModalOpen.value) { if (customItemDeleteModalOpen.value) {
@@ -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 () => { onMounted(async () => {
if (typeof window !== 'undefined') window.addEventListener('popstate', handleAdminPopState) if (typeof window !== 'undefined') {
window.addEventListener('popstate', handleAdminPopState)
window.addEventListener('keydown', handleAdminKeydown)
}
await auth.refresh() await auth.refresh()
await Promise.all([refreshGames(), refreshCustomItems(), refreshAdminTierLists(), refreshUsers(), refreshTemplateRequests(), refreshImageDiagnostics()]) await Promise.all([refreshGames(), refreshCustomItems(), refreshAdminTierLists(), refreshUsers(), refreshTemplateRequests(), refreshImageDiagnostics()])
await syncFeaturedSortable() await syncFeaturedSortable()
}) })
onUnmounted(() => { onUnmounted(() => {
if (typeof window !== 'undefined') window.removeEventListener('popstate', handleAdminPopState) if (typeof window !== 'undefined') {
window.removeEventListener('popstate', handleAdminPopState)
window.removeEventListener('keydown', handleAdminKeydown)
}
if (typeof document !== 'undefined') document.body.style.overflow = previousBodyOverflow.value || ''
clearPreviewUrl('item') clearPreviewUrl('item')
clearPreviewUrl('thumb') clearPreviewUrl('thumb')
destroyFeaturedSortable() destroyFeaturedSortable()
@@ -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() { function resetMessages() {
error.value = '' error.value = ''
success.value = '' success.value = ''
@@ -678,6 +735,7 @@ async function createGame() {
const data = await res.json() const data = await res.json()
await refreshGames() await refreshGames()
selectedGameId.value = data.game.id selectedGameId.value = data.game.id
if (customItemModalOpen.value) customItemModalTargetGameId.value = data.game.id
closeGameCreateModal() closeGameCreateModal()
await loadGame() await loadGame()
success.value = '게임이 생성됐어요. 이어서 썸네일과 기본 아이템을 관리할 수 있어요.' success.value = '게임이 생성됐어요. 이어서 썸네일과 기본 아이템을 관리할 수 있어요.'
@@ -1076,6 +1134,7 @@ function pushCustomItemModalHistoryState() {
function openCustomItemModal(item) { function openCustomItemModal(item) {
modalTargetCustomItem.value = item || null modalTargetCustomItem.value = item || null
customItemModalDraftLabel.value = item?.label || ''
customItemModalTargetGameId.value = '' customItemModalTargetGameId.value = ''
customItemModalGameQuery.value = '' customItemModalGameQuery.value = ''
customItemModalGameSort.value = 'recent' customItemModalGameSort.value = 'recent'
@@ -1087,6 +1146,8 @@ function closeCustomItemModal({ fromPopState = false } = {}) {
customItemModalOpen.value = false customItemModalOpen.value = false
customItemDeleteModalOpen.value = false customItemDeleteModalOpen.value = false
modalTargetCustomItem.value = null modalTargetCustomItem.value = null
customItemModalDraftLabel.value = ''
customItemModalLabelSaving.value = false
customItemModalTargetGameId.value = '' customItemModalTargetGameId.value = ''
customItemModalGameQuery.value = '' customItemModalGameQuery.value = ''
customItemModalGameSort.value = 'recent' 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) { async function promoteCustomItem(item) {
resetMessages() resetMessages()
if (!customItemModalTargetGameId.value) { 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) { function tierListThumbUrl(tierList) {
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : '' return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
} }
@@ -1209,12 +1315,45 @@ function previewRequestGroupItems(preview, group) {
return (group?.itemIds || []).map((itemId) => itemsById[itemId]).filter(Boolean) return (group?.itemIds || []).map((itemId) => itemsById[itemId]).filter(Boolean)
} }
function previewRequestColumns(preview) {
const groups = Array.isArray(preview?.snapshotGroups) ? preview.snapshotGroups : []
const columnSource = groups.find((group) => Array.isArray(group?.columnNames) && group.columnNames.length) || null
const namedColumns = Array.isArray(columnSource?.columnNames) ? columnSource.columnNames : []
const cellCount = Math.max(1, namedColumns.length, ...groups.map((group) => (Array.isArray(group?.cells) ? group.cells.length : 0)))
return Array.from({ length: cellCount }, (_, index) => ({
id: namedColumns[index]?.id || ('column-' + index),
name: namedColumns[index]?.name || '',
}))
}
function previewRequestHasColumns(preview) {
const columns = previewRequestColumns(preview)
return columns.length > 1 || columns.some((column) => column.name)
}
function previewRequestGridStyle(preview) {
const count = previewRequestColumns(preview).length
return { gridTemplateColumns: 'repeat(' + count + ', minmax(0, 1fr))' }
}
function previewRequestGroupCellItems(preview, group, columnIndex) {
const itemsById = previewRequestItemsById(preview)
if (Array.isArray(group?.cells?.[columnIndex])) {
return group.cells[columnIndex].map((itemId) => itemsById[itemId]).filter(Boolean)
}
if (columnIndex === 0) return previewRequestGroupItems(preview, group)
return []
}
function previewRequestPoolItems(preview) { function previewRequestPoolItems(preview) {
const groupedIds = new Set((preview?.snapshotGroups || []).flatMap((group) => group.itemIds || [])) const groupedIds = new Set((preview?.snapshotGroups || []).flatMap((group) => group.itemIds || []))
return (preview?.snapshotItems || []).filter((item) => !groupedIds.has(item.id)) return (preview?.snapshotItems || []).filter((item) => !groupedIds.has(item.id))
} }
function openTemplateRequestPreview(request) { function openTemplateRequestPreview(request) {
const snapshotItems = Array.isArray(request.snapshotItems) && request.snapshotItems.length ? request.snapshotItems : Array.isArray(request.items) ? request.items : []
previewTierList.value = { previewTierList.value = {
id: request.id, id: request.id,
title: request.sourceTierListTitle || '템플릿 요청 미리보기', title: request.sourceTierListTitle || '템플릿 요청 미리보기',
@@ -1222,7 +1361,7 @@ function openTemplateRequestPreview(request) {
thumbnailSrc: request.thumbnailSrc || '', thumbnailSrc: request.thumbnailSrc || '',
requestPreview: true, requestPreview: true,
snapshotGroups: request.snapshotGroups || [], snapshotGroups: request.snapshotGroups || [],
snapshotItems: request.snapshotItems || [], snapshotItems,
snapshotShowCharacterNames: !!request.snapshotShowCharacterNames, snapshotShowCharacterNames: !!request.snapshotShowCharacterNames,
} }
previewModalOpen.value = true previewModalOpen.value = true
@@ -1684,10 +1823,10 @@ async function saveFeaturedOrder() {
<div v-if="!adminTierLists.length" class="hint">조건에 맞는 티어표가 없어요.</div> <div v-if="!adminTierLists.length" class="hint">조건에 맞는 티어표가 없어요.</div>
<div v-else class="tierAdminList"> <div v-else class="tierAdminList">
<article v-for="tierList in adminTierLists" :key="tierList.id" class="tierAdminCard"> <article v-for="tierList in adminTierLists" :key="tierList.id" class="tierAdminCard">
<div class="tierAdminCard__preview" @click="openAdminTierList(tierList)"> <button class="tierAdminCard__preview" type="button" @click="openAdminTierList(tierList)">
<img v-if="tierListThumbUrl(tierList)" class="tierAdminCard__thumb" :src="tierListThumbUrl(tierList)" :alt="tierList.title" /> <img v-if="tierListThumbUrl(tierList)" class="tierAdminCard__thumb" :src="tierListThumbUrl(tierList)" :alt="tierList.title" />
<div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div> <div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
</div> </button>
<div class="tierAdminCard__body"> <div class="tierAdminCard__body">
<div class="tierAdminCard__head"> <div class="tierAdminCard__head">
@@ -1699,7 +1838,6 @@ async function saveFeaturedOrder() {
</div> </div>
<div class="tierAdminCard__meta">{{ fmt(tierList.updatedAt) }}</div> <div class="tierAdminCard__meta">{{ fmt(tierList.updatedAt) }}</div>
</div> </div>
<button class="btn btn--ghost btn--small" @click="openAdminTierList(tierList)">완성본 보기</button>
</div> </div>
<div class="tierAdminCard__stats"> <div class="tierAdminCard__stats">
@@ -1710,7 +1848,7 @@ async function saveFeaturedOrder() {
<div v-if="tierList.extraItems?.length" class="tierAdminSection"> <div v-if="tierList.extraItems?.length" class="tierAdminSection">
<div class="tierAdminSection__title">추가로 넣은 아이템</div> <div class="tierAdminSection__title">추가로 넣은 아이템</div>
<div class="tierAdminItemList"> <div class="tierAdminItemList">
<button v-for="item in tierList.extraItems" :key="item.id" class="tierAdminItem" @click="openTierListImportModal(tierList, [item])"> <button v-for="item in tierList.extraItems" :key="item.id" class="tierAdminItem" @click="openTierListExtraItemModal(item, tierList)">
<img class="tierAdminItem__thumb" :src="toApiUrl(item.src)" :alt="item.label" /> <img class="tierAdminItem__thumb" :src="toApiUrl(item.src)" :alt="item.label" />
<div class="tierAdminItem__title">{{ item.label }}</div> <div class="tierAdminItem__title">{{ item.label }}</div>
</button> </button>
@@ -1979,15 +2117,12 @@ async function saveFeaturedOrder() {
<div v-if="customItemModalOpen" class="modalOverlay" @click.self="closeCustomItemModal"> <div v-if="customItemModalOpen" class="modalOverlay" @click.self="closeCustomItemModal">
<div class="modalCard modalCard--customItem" role="dialog" aria-modal="true"> <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"> <div v-if="modalTargetCustomItem" class="customItemModal">
<aside class="customItemModal__pickerPanel"> <aside class="customItemModal__pickerPanel">
<div class="customItemModal__pickerHead"> <div class="customItemModal__pickerHead">
<div class="customItemModal__pickerEyebrow">GAME PICKER</div> <div class="customItemModal__pickerEyebrow">GAME PICKER</div>
<div class="customItemModal__pickerTitle">템플릿으로 추가할 게임</div> <div class="customItemModal__pickerTitle">템플릿으로 추가할 게임</div>
<button class="btn btn--ghost btn--small customItemModal__createGameButton" type="button" @click="openGameCreateModal"> 템플릿 만들기</button>
</div> </div>
<div class="customItemModal__pickerControls"> <div class="customItemModal__pickerControls">
<input v-model="customItemModalGameQuery" class="input input--dense" placeholder="게임 이름, ID 검색" /> <input v-model="customItemModalGameQuery" class="input input--dense" placeholder="게임 이름, ID 검색" />
@@ -2015,13 +2150,24 @@ async function saveFeaturedOrder() {
</div> </div>
</aside> </aside>
<div class="customItemModal__body"> <div class="customItemModal__body">
<div class="customItemModal__titleRow"> <button class="customItemModal__close" type="button" @click="closeCustomItemModal">닫기</button>
<div> <div class="customItemModal__content">
<div class="customItemModal__title">{{ modalTargetCustomItem.label }}</div> <div class="customItemModal__titleRow">
<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>
</div>
<img class="customItemModal__image" :src="toApiUrl(modalTargetCustomItem.src)" :alt="modalTargetCustomItem.label" />
<div class="customItemModal__metaList"> <div class="customItemModal__metaList">
<div class="customItemModal__metaRow"><span>파일</span><strong :title="modalTargetCustomItem.src.split('/').pop()">{{ modalTargetCustomItem.src.split('/').pop() }}</strong></div> <div class="customItemModal__metaRow"><span>파일</span><strong :title="modalTargetCustomItem.src.split('/').pop()">{{ modalTargetCustomItem.src.split('/').pop() }}</strong></div>
<div class="customItemModal__metaRow"><span>업로더/출처</span><strong :title="modalTargetCustomItem.ownerName">{{ modalTargetCustomItem.ownerName }}</strong></div> <div class="customItemModal__metaRow"><span>업로더/출처</span><strong :title="modalTargetCustomItem.ownerName">{{ modalTargetCustomItem.ownerName }}</strong></div>
@@ -2035,12 +2181,13 @@ async function saveFeaturedOrder() {
</div> </div>
<div v-else class="hint hint--tight">아직 템플릿에 연결된 게임이 없어요.</div> <div v-else class="hint hint--tight">아직 템플릿에 연결된 게임이 없어요.</div>
</div> </div>
<div class="customItemModal__actions"> <div class="customItemModal__actions">
<a class="btn btn--ghost customItemModal__action" :href="toApiUrl(modalTargetCustomItem.src)" :download="modalTargetCustomItem.label">이미지 다운로드</a> <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)"> <button class="btn btn--ghost customItemModal__action" :disabled="!customItemModalTargetGameId || modalTargetCustomItem.isPromoting" @click="promoteCustomItem(modalTargetCustomItem)">
{{ modalTargetCustomItem.isPromoting ? '추가중...' : '기본 템플릿에 추가' }} {{ modalTargetCustomItem.isPromoting ? '추가중...' : '기본 템플릿에 추가' }}
</button> </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> <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> </div>
</div> </div>
@@ -2077,32 +2224,58 @@ async function saveFeaturedOrder() {
<div class="modalCard__title">{{ previewTierList?.title || '티어표 미리보기' }}</div> <div class="modalCard__title">{{ previewTierList?.title || '티어표 미리보기' }}</div>
<button class="btn btn--ghost btn--small" @click="closePreviewModal">닫기</button> <button class="btn btn--ghost btn--small" @click="closePreviewModal">닫기</button>
</div> </div>
<div v-if="previewTierList?.requestPreview" class="requestPreview"> <div v-if="previewTierList?.requestPreview" class="requestPreview">
<img <div class="requestPreview__hero">
v-if="previewTierList.thumbnailSrc" <div class="requestPreview__heroBody">
class="requestPreview__thumb" <div v-if="previewTierList.description" class="requestPreview__desc">{{ previewTierList.description }}</div>
:src="toApiUrl(previewTierList.thumbnailSrc)" <div class="requestPreview__meta">
:alt="previewTierList.title" {{ previewRequestHasColumns(previewTierList) ? (previewRequestColumns(previewTierList).length + '열 구성') : '단일 열 구성' }} ·
/> {{ previewTierList.snapshotGroups?.length || 0 }} ·
<div v-if="previewTierList.description" class="requestPreview__desc">{{ previewTierList.description }}</div> {{ previewTierList.snapshotItems?.length || 0 }} 아이템
<div class="requestPreview__rows"> </div>
<div v-for="group in previewTierList.snapshotGroups" :key="group.id" class="requestPreview__row"> </div>
<div class="requestPreview__rowLabel">{{ group.name }}</div> </div>
<div class="requestPreview__rowItems"> <div class="requestPreview__board requestPreview__board--full">
<div v-if="previewRequestHasColumns(previewTierList)" class="requestPreview__boardHead">
<div class="requestPreview__rowLabel requestPreview__rowLabel--head"></div>
<div class="requestPreview__columnLabels" :style="previewRequestGridStyle(previewTierList)">
<div <div
v-for="item in previewRequestGroupItems(previewTierList, group)" v-for="(column, columnIndex) in previewRequestColumns(previewTierList)"
:key="item.id" :key="column.id"
class="requestPreview__item" class="requestPreview__columnLabel"
> >
<img class="requestPreview__itemThumb" :src="toApiUrl(item.src)" :alt="item.label" /> {{ column.name || ('열 ' + (columnIndex + 1)) }}
<div v-if="previewTierList.snapshotShowCharacterNames" class="requestPreview__itemLabel">{{ item.label }}</div> </div>
</div>
</div>
<div class="requestPreview__rows">
<div v-for="group in previewTierList.snapshotGroups" :key="group.id" class="requestPreview__row">
<div class="requestPreview__rowLabel">{{ group.name }}</div>
<div class="requestPreview__cells" :style="previewRequestGridStyle(previewTierList)">
<div
v-for="(column, columnIndex) in previewRequestColumns(previewTierList)"
:key="group.id + '-' + column.id"
class="requestPreview__cell"
>
<div class="requestPreview__rowItems">
<div
v-for="item in previewRequestGroupCellItems(previewTierList, group, columnIndex)"
:key="item.id"
class="requestPreview__item"
>
<img class="requestPreview__itemThumb" :src="toApiUrl(item.src)" :alt="item.label" />
<div v-if="previewTierList.snapshotShowCharacterNames" class="requestPreview__itemLabel">{{ item.label }}</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div v-if="previewRequestPoolItems(previewTierList).length" class="requestPreview__pool"> <div v-if="previewRequestPoolItems(previewTierList).length" class="requestPreview__pool">
<div class="requestPreview__poolLabel">남은 아이템</div> <div class="requestPreview__poolLabel">남은 아이템</div>
<div class="requestPreview__rowItems"> <div class="requestPreview__rowItems requestPreview__rowItems--pool">
<div <div
v-for="item in previewRequestPoolItems(previewTierList)" v-for="item in previewRequestPoolItems(previewTierList)"
:key="item.id" :key="item.id"
@@ -3186,22 +3359,22 @@ async function saveFeaturedOrder() {
} }
.customItemModal { .customItemModal {
display: grid; display: grid;
grid-template-columns: minmax(320px, 360px) minmax(0, 1fr); grid-template-columns: 340px minmax(0, 1fr);
gap: 28px; min-height: min(860px, calc(100dvh - 40px));
align-items: start; align-items: stretch;
} }
.customItemModal__pickerPanel { .customItemModal__pickerPanel {
display: grid; display: grid;
gap: 12px; align-content: start;
gap: 18px;
min-width: 0; min-width: 0;
padding: 16px; padding: 28px 22px;
border-radius: 20px; border-right: 1px solid var(--theme-border);
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg); background: var(--theme-pill-bg);
} }
.customItemModal__pickerHead { .customItemModal__pickerHead {
display: grid; display: grid;
gap: 4px; gap: 10px;
} }
.customItemModal__pickerEyebrow { .customItemModal__pickerEyebrow {
font-size: 11px; font-size: 11px;
@@ -3223,6 +3396,9 @@ async function saveFeaturedOrder() {
max-height: 440px; max-height: 440px;
overflow: auto; overflow: auto;
} }
.customItemModal__createGameButton {
justify-self: start;
}
.customItemModal__gameItem { .customItemModal__gameItem {
display: grid; display: grid;
gap: 4px; gap: 4px;
@@ -3251,22 +3427,63 @@ async function saveFeaturedOrder() {
color: var(--theme-text-soft); color: var(--theme-text-soft);
} }
.customItemModal__body { .customItemModal__body {
display: grid;
gap: 14px;
min-width: 0; 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__titleRow,
.customItemModal__linked { .customItemModal__linked {
display: grid; display: grid;
gap: 8px; 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 { .customItemModal__image {
width: 100%; width: 100%;
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
max-height: min(360px, 34dvh);
object-fit: cover; object-fit: cover;
border-radius: 20px; border-radius: 24px;
background: var(--theme-surface-soft); 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 rgba(255, 255, 255, 0.1); border: 1px solid var(--theme-border);
} }
.customItemModal__label { .customItemModal__label {
font-size: 11px; font-size: 11px;
@@ -3291,6 +3508,10 @@ async function saveFeaturedOrder() {
.customItemModal__metaList { .customItemModal__metaList {
display: grid; display: grid;
gap: 10px; gap: 10px;
padding: 14px 16px;
border-radius: 18px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
} }
.customItemModal__metaRow { .customItemModal__metaRow {
display: grid; display: grid;
@@ -3312,7 +3533,8 @@ async function saveFeaturedOrder() {
.customItemModal__actions { .customItemModal__actions {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px; gap: 12px;
align-self: end;
} }
.customItemModal__action { .customItemModal__action {
width: 100%; width: 100%;
@@ -3323,7 +3545,16 @@ async function saveFeaturedOrder() {
text-align: center; text-align: center;
} }
.modalCard--customItem { .modalCard--customItem {
width: min(1280px, calc(100vw - 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;
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 { .pager {
margin-top: 16px; margin-top: 16px;
@@ -3670,41 +3901,81 @@ async function saveFeaturedOrder() {
display: grid; display: grid;
gap: 18px; gap: 18px;
} }
.requestPreview__thumb { .requestPreview__summary {
display: grid;
grid-template-columns: minmax(0, 1fr) 220px;
gap: 16px;
align-items: start;
}
.requestPreview__summaryBody {
display: grid;
gap: 8px;
}
.requestPreview__summaryThumb {
width: 100%; width: 100%;
max-height: 240px; aspect-ratio: 16 / 9;
object-fit: cover; object-fit: cover;
border-radius: 18px; border-radius: 18px;
border: 1px solid var(--theme-border); border: 1px solid var(--theme-border);
background: var(--theme-surface-soft); background: var(--theme-surface-soft);
} }
.requestPreview__meta {
color: var(--theme-text-soft);
font-size: 13px;
}
.requestPreview__desc { .requestPreview__desc {
color: var(--theme-text-muted); color: var(--theme-text-muted);
line-height: 1.6; line-height: 1.6;
white-space: pre-line; white-space: pre-line;
} }
.requestPreview__rows, .requestPreview__board,
.requestPreview__pool { .requestPreview__pool {
display: grid; display: grid;
gap: 12px; gap: 12px;
} }
.requestPreview__boardHead,
.requestPreview__row { .requestPreview__row {
display: grid; display: grid;
grid-template-columns: 88px minmax(0, 1fr); grid-template-columns: 92px minmax(0, 1fr);
gap: 12px; gap: 12px;
align-items: start; align-items: start;
} }
.requestPreview__rowLabel, .requestPreview__rowLabel,
.requestPreview__poolLabel { .requestPreview__poolLabel,
.requestPreview__columnLabel {
font-size: 13px; font-size: 13px;
font-weight: 800; font-weight: 800;
color: rgba(255, 255, 255, 0.86); color: rgba(255, 255, 255, 0.86);
} }
.requestPreview__rowLabel--head {
color: var(--theme-text-faint);
}
.requestPreview__columnLabels,
.requestPreview__cells {
display: grid;
gap: 12px;
}
.requestPreview__columnLabel,
.requestPreview__cell {
min-width: 0;
}
.requestPreview__cell {
padding: 10px;
border-radius: 16px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
}
.requestPreview__rowItems { .requestPreview__rowItems {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
gap: 10px; gap: 10px;
} }
.requestPreview__rowItems--pool {
grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
}
.requestPreview__rowItems--pool {
grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
}
.requestPreview__item { .requestPreview__item {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@@ -3752,6 +4023,11 @@ async function saveFeaturedOrder() {
} }
.tierAdminCard__preview { .tierAdminCard__preview {
cursor: pointer; cursor: pointer;
appearance: none;
border: 0;
padding: 0;
background: transparent;
text-align: left;
} }
.tierAdminCard__thumb { .tierAdminCard__thumb {
width: 100%; width: 100%;
@@ -3806,6 +4082,7 @@ async function saveFeaturedOrder() {
border-radius: 999px; border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.12);
background: var(--theme-surface-soft); background: var(--theme-surface-soft);
color: var(--theme-text);
font-size: 12px; font-size: 12px;
font-weight: 800; font-weight: 800;
} }
@@ -3814,6 +4091,22 @@ async function saveFeaturedOrder() {
background: rgba(251, 191, 36, 0.12); background: rgba(251, 191, 36, 0.12);
color: rgba(253, 230, 138, 0.96); 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 { .tierAdminSection {
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -3941,6 +4234,32 @@ async function saveFeaturedOrder() {
} }
.customItemModal { .customItemModal {
grid-template-columns: 1fr; 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 { .customItemModal__actions {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

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

View File

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