From 7fe4eff7b7c96faed750837f950a4c84ad9fe79d Mon Sep 17 00:00:00 2001 From: zenn Date: Wed, 1 Apr 2026 11:50:54 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=8A=A4:=20v1.3.13=20?= =?UTF-8?q?=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=9A=94=EC=B2=AD=20=EC=8A=A4?= =?UTF-8?q?=EB=83=85=EC=83=B7=EA=B3=BC=20=EC=A0=80=EC=9E=A5=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/db.js | 69 +++++++++--- backend/src/routes/tierlists.js | 97 ++++++++++++---- docs/todo.md | 5 +- docs/update.md | 5 + frontend/src/lib/api.js | 2 +- frontend/src/views/AdminView.vue | 152 +++++++++++++++++++++++++- frontend/src/views/TierEditorView.vue | 126 +++++++++++++-------- 7 files changed, 366 insertions(+), 90 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index f84abba..a2343a3 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -154,7 +154,7 @@ function mapTemplateRequestRow(row) { requesterName: getUserDisplayName(row), requesterAccountName: getUserAccountName(row), requesterAvatarSrc: row.requester_avatar_src || '', - sourceTierListId: row.source_tierlist_id, + sourceTierListId: row.source_tierlist_id || '', sourceGameId: row.source_game_id, sourceGameName: row.source_game_name || '', sourceTierListTitle: row.title_snapshot || '', @@ -164,6 +164,9 @@ function mapTemplateRequestRow(row) { targetGameName: row.target_game_name || '', status: row.status, items: parseJson(row.items_json, []), + snapshotGroups: parseJson(row.groups_json, []), + snapshotItems: parseJson(row.board_items_json, []), + snapshotShowCharacterNames: !!row.show_character_names_snapshot, createdAt: Number(row.created_at), updatedAt: Number(row.updated_at), } @@ -389,6 +392,23 @@ async function ensureSchema() { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) + const templateRequestSourceTierListColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'source_tierlist_id'") + if (templateRequestSourceTierListColumns[0]?.Null !== 'YES') { + await query('ALTER TABLE template_requests MODIFY source_tierlist_id VARCHAR(64) NULL') + } + const templateRequestGroupsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'groups_json'") + if (!templateRequestGroupsColumns.length) { + await query("ALTER TABLE template_requests ADD COLUMN groups_json LONGTEXT NOT NULL AFTER items_json") + } + const templateRequestBoardItemsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'board_items_json'") + if (!templateRequestBoardItemsColumns.length) { + await query("ALTER TABLE template_requests ADD COLUMN board_items_json LONGTEXT NOT NULL AFTER groups_json") + } + const templateRequestShowNamesColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'show_character_names_snapshot'") + if (!templateRequestShowNamesColumns.length) { + await query("ALTER TABLE template_requests ADD COLUMN show_character_names_snapshot TINYINT(1) NOT NULL DEFAULT 0 AFTER board_items_json") + } + const tierListThumbnailColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'thumbnail_src'") if (!tierListThumbnailColumns.length) { await query("ALTER TABLE tierlists ADD COLUMN thumbnail_src VARCHAR(255) NOT NULL DEFAULT '' AFTER title") @@ -754,7 +774,7 @@ async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) { query("SELECT src FROM game_items WHERE src <> ''"), query("SELECT src FROM custom_items WHERE src <> ''"), query("SELECT thumbnail_src, pool_json FROM tierlists"), - query("SELECT thumbnail_src_snapshot, items_json FROM template_requests"), + query("SELECT thumbnail_src_snapshot, items_json, board_items_json FROM template_requests"), ]) for (const row of userRows) if (row.avatar_src) referencedSrcs.add(row.avatar_src) @@ -770,6 +790,7 @@ async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) { for (const row of templateRequestRows) { if (row.thumbnail_src_snapshot) referencedSrcs.add(row.thumbnail_src_snapshot) collectUploadSrcsFromItems(parseJson(row.items_json, []), referencedSrcs) + collectUploadSrcsFromItems(parseJson(row.board_items_json, []), referencedSrcs) } return assets.filter((asset) => !referencedSrcs.has(asset.src)) @@ -806,7 +827,7 @@ async function listReferencedUploadUsage() { query("SELECT src FROM game_items WHERE src <> ''"), query("SELECT src FROM custom_items WHERE src <> ''"), query("SELECT id, thumbnail_src, pool_json FROM tierlists"), - query("SELECT id, thumbnail_src_snapshot, items_json FROM template_requests"), + query("SELECT id, thumbnail_src_snapshot, items_json, board_items_json FROM template_requests"), ]) for (const row of userRows) addUsage(row.avatar_src, 'avatar') @@ -822,6 +843,7 @@ async function listReferencedUploadUsage() { for (const row of templateRequestRows) { addUsage(row.thumbnail_src_snapshot, 'template-thumbnail') for (const item of parseJson(row.items_json, [])) addUsage(item?.src, 'template-item') + for (const item of parseJson(row.board_items_json, [])) addUsage(item?.src, 'template-board-item') } return Array.from(usageMap.entries()) @@ -874,7 +896,7 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc }) { } } - const requestRows = await query('SELECT id, thumbnail_src_snapshot, items_json FROM template_requests') + const requestRows = await query('SELECT id, thumbnail_src_snapshot, items_json, board_items_json FROM template_requests') for (const row of requestRows) { let nextThumbnail = row.thumbnail_src_snapshot let changed = false @@ -884,12 +906,14 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc }) { } const replacedItems = replaceItemSrc(parseJson(row.items_json, []), fromSrc, toSrc) - if (replacedItems.changed) changed = true + const replacedBoardItems = replaceItemSrc(parseJson(row.board_items_json, []), fromSrc, toSrc) + if (replacedItems.changed || replacedBoardItems.changed) changed = true if (changed) { - await query('UPDATE template_requests SET thumbnail_src_snapshot = ?, items_json = ?, updated_at = ? WHERE id = ?', [ + await query('UPDATE template_requests SET thumbnail_src_snapshot = ?, items_json = ?, board_items_json = ?, updated_at = ? WHERE id = ?', [ nextThumbnail || '', serializeJson(replacedItems.items), + serializeJson(replacedBoardItems.items), now(), row.id, ]) @@ -1636,19 +1660,24 @@ async function createTemplateRequest({ id, type, requesterId, - sourceTierListId, + sourceTierListId = '', sourceGameId, targetGameId = '', title, description = '', thumbnailSrc = '', items = [], + groups = [], + boardItems = [], + showCharacterNames = false, }) { - const existing = await findPendingTemplateRequestByTierList({ sourceTierListId, type }) - if (existing) { - const err = new Error('template_request_exists') - err.code = 'TEMPLATE_REQUEST_EXISTS' - throw err + if (sourceTierListId) { + const existing = await findPendingTemplateRequestByTierList({ sourceTierListId, type }) + if (existing) { + const err = new Error('template_request_exists') + err.code = 'TEMPLATE_REQUEST_EXISTS' + throw err + } } const createdAt = now() @@ -1666,22 +1695,28 @@ async function createTemplateRequest({ description_snapshot, thumbnail_src_snapshot, items_json, + groups_json, + board_items_json, + show_character_names_snapshot, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ id, type, requesterId, - sourceTierListId, + sourceTierListId || null, sourceGameId, targetGameId, title, description, thumbnailSrc, serializeJson(items), + serializeJson(groups), + serializeJson(boardItems), + showCharacterNames ? 1 : 0, createdAt, createdAt, ] @@ -1704,6 +1739,9 @@ async function findTemplateRequestById(id) { tr.description_snapshot, tr.thumbnail_src_snapshot, tr.items_json, + tr.groups_json, + tr.board_items_json, + tr.show_character_names_snapshot, tr.created_at, tr.updated_at, u.nickname, @@ -1739,6 +1777,9 @@ async function listAdminTemplateRequests({ status = 'pending' } = {}) { tr.description_snapshot, tr.thumbnail_src_snapshot, tr.items_json, + tr.groups_json, + tr.board_items_json, + tr.show_character_names_snapshot, tr.created_at, tr.updated_at, u.nickname, diff --git a/backend/src/routes/tierlists.js b/backend/src/routes/tierlists.js index 8e84cee..5d8ad76 100644 --- a/backend/src/routes/tierlists.js +++ b/backend/src/routes/tierlists.js @@ -58,6 +58,33 @@ function getCustomTemplateItems(tierList) { const upload = createMemoryUpload(multer, { fileSize: 6 * 1024 * 1024 }) const thumbnailUpload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024 }) +const templateRequestSchema = z.object({ + type: z.enum(['create', 'update']), + sourceTierListId: z.string().max(64).optional().default(''), + gameId: z.string().min(1).max(120), + requestTitle: z.string().trim().min(1).max(120), + requestDescription: z.string().trim().min(1).max(1000), + thumbnailSrc: z.string().max(255).optional().default(''), + isPublic: z.boolean().optional().default(false), + showCharacterNames: z.boolean().optional().default(false), + saveToMyTierList: z.boolean().optional().default(true), + groups: z.array( + z.object({ + id: z.string().min(1), + name: z.string().min(1).max(16), + itemIds: z.array(z.string()), + }) + ), + boardItems: z.array( + z.object({ + id: z.string().min(1), + src: z.string().min(1), + label: z.string().min(1).max(60), + origin: z.enum(['game', 'custom']).default('game'), + }) + ), +}) + const tierListUpsertSchema = z.object({ id: z.string().optional(), gameId: z.string().min(1), @@ -194,42 +221,64 @@ router.post('/thumbnail', requireAuth, thumbnailUpload.single('thumbnail'), asyn res.json({ thumbnailSrc: optimized.src }) }) -router.post('/:id/template-request', requireAuth, async (req, res) => { - const schema = z.object({ - type: z.enum(['create', 'update']), - requestTitle: z.string().trim().min(1).max(80), - requestDescription: z.string().trim().min(1).max(240), - }) - const parsed = schema.safeParse(req.body) +router.post('/template-request', requireAuth, async (req, res) => { + const parsed = templateRequestSchema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) - const tierList = await findTierListById(req.params.id, req.session.userId) - if (!tierList) return res.status(404).json({ error: 'not_found' }) - if (tierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' }) - - const customItems = getCustomTemplateItems(tierList) + const payload = parsed.data + const normalizedBoardItems = payload.boardItems.map(normalizePoolItem) + const customItems = normalizedBoardItems.filter((item) => item?.origin === 'custom') if (!customItems.length) return res.status(400).json({ error: 'custom_items_required' }) - if (parsed.data.type === 'create') { - if (tierList.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' }) - } else { - if (tierList.gameId === FREEFORM_GAME_ID) return res.status(400).json({ error: 'game_template_required' }) + if (payload.type === 'create') { + if (payload.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' }) + } else if (payload.gameId === FREEFORM_GAME_ID) { + return res.status(400).json({ error: 'game_template_required' }) + } + + let sourceTierList = null + if (payload.sourceTierListId) { + sourceTierList = await findTierListById(payload.sourceTierListId, req.session.userId) + if (!sourceTierList) return res.status(404).json({ error: 'not_found' }) + if (sourceTierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' }) + } + + let savedTierList = null + if (payload.saveToMyTierList) { + savedTierList = await saveTierList({ + id: sourceTierList?.id || undefined, + authorId: req.session.userId, + gameId: payload.gameId, + title: payload.requestTitle, + thumbnailSrc: payload.thumbnailSrc || '', + description: payload.requestDescription || '', + isPublic: !!payload.isPublic, + showCharacterNames: !!payload.showCharacterNames, + sourceTierListId: sourceTierList?.sourceTierListId || '', + sourceSnapshotTitle: sourceTierList?.sourceSnapshotTitle || '', + sourceSnapshotAuthor: sourceTierList?.sourceSnapshotAuthor || '', + groups: payload.groups, + pool: normalizedBoardItems, + }) } try { const request = await createTemplateRequest({ id: nanoid(), - type: parsed.data.type, + type: payload.type, requesterId: req.session.userId, - sourceTierListId: tierList.id, - sourceGameId: tierList.gameId, - targetGameId: parsed.data.type === 'update' ? tierList.gameId : '', - title: parsed.data.requestTitle, - description: parsed.data.requestDescription, - thumbnailSrc: tierList.thumbnailSrc || '', + sourceTierListId: savedTierList?.id || sourceTierList?.id || '', + sourceGameId: payload.gameId, + targetGameId: payload.type === 'update' ? payload.gameId : '', + title: payload.requestTitle, + description: payload.requestDescription, + thumbnailSrc: payload.thumbnailSrc || '', items: customItems, + groups: payload.groups, + boardItems: normalizedBoardItems, + showCharacterNames: !!payload.showCharacterNames, }) - return res.json({ request }) + return res.json({ request, savedTierList: savedTierList ? normalizeTierList(savedTierList) : null }) } catch (e) { if (e?.code === 'TEMPLATE_REQUEST_EXISTS') { return res.status(409).json({ error: 'template_request_exists' }) diff --git a/docs/todo.md b/docs/todo.md index bbc5752..7da3d12 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,7 +1,10 @@ # 할 일 및 이슈 ## 즉시 확인 필요 -- 티어표 형식 추가 필요. 최근 게임들은 S, A, B,C 같은 랭크 뿐만 아니라 가로 열도 나누어진형태의 티어표를 원함 (공격, 방어, 지원 등 각 파트별 랭크를 보고싶어함) +- 최근 게임들은 S, A, B,C 같은 랭크 뿐만 아니라 가로 열도 나누어진형태의 티어표를 원함 (공격, 방어, 지원 등 각 파트별 랭크를 보고싶어함) +- 티어표에서 티어 추가(세로라인)를 행 추가 등의 단어로 변경. 열 추가 버튼도 표시. 열을 추가할경우 열에도 이름을 입력 가능. (1열만 있을 경우에는 삭제 불가, 열 제목 사용 안함) +- 드래그 아이콘의 위치를 이동시키거나, 제외하는 등(기능은 가능해야함) 적절한 판단을 통해 행 라벨의 너비가 큰편인데 조금 줄일 필요 있음. (실제로는 5~6자 이상 쓰는경우가 거의 없기 때문) +- 이미지 저장시 라벨 텍스트가 중앙 정렬이 아닌 살짝 상단으로 치우쳐진 상태라 이쁘지 않은데... 확인 필요함. - 레거시 파일 정리 스크립트는 준비됐으므로, 운영 단계에서는 cron 등으로 주기 실행할지와 삭제 전 보관 기간을 함께 정한다. - 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다. - 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다. diff --git a/docs/update.md b/docs/update.md index 986f99a..e4eb9b7 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-04-01 v1.3.13 +- 템플릿 등록/업데이트 요청 모달은 이제 현재 티어표 제목·설명을 기본값으로 가져오고, 비어 있더라도 모달 안에서 바로 작성해 요청할 수 있도록 흐름을 단순화함. +- 템플릿 요청 시 `내 티어 리스트에도 저장` 토글을 추가해, 요청 스냅샷만 관리자에게 전달할지 아니면 현재 양식도 내 티어표로 함께 저장할지 분리함. +- 관리자 템플릿 요청 관리는 더 이상 원본 티어표 링크에 의존하지 않고, 요청 시점의 그룹/아이템/이름표시 상태를 그대로 담은 스냅샷 미리보기를 직접 열어 확인할 수 있게 확장함. + ## 2026-04-01 v1.3.12 - 관리자 회원 관리 상단에 정렬 방향 선택을 추가해, 최근 활동순·가입순·작성 티어표순을 각각 오름차순/내림차순으로 다시 볼 수 있게 확장함. - 회원 정보 수정, 새 게임 생성, 비밀번호 초기화 모달은 Settings 톤 입력 스타일을 유지하면서 각 입력칸에 글자 수 피드백을 함께 보여주도록 정리함. diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index e65be02..1d7f677 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -101,7 +101,7 @@ export const api = { unfavoriteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/favorite`, { method: 'DELETE' }), deleteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`, { method: 'DELETE' }), duplicateTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/duplicate`, { method: 'POST' }), - requestTierListTemplate: (id, payload) => request(`/api/tierlists/${encodeURIComponent(id)}/template-request`, { method: 'POST', body: payload }), + requestTierListTemplate: (payload) => request('/api/tierlists/template-request', { method: 'POST', body: payload }), saveTierList: (payload) => request('/api/tierlists', { method: 'POST', body: payload }), uploadTierListThumbnail: async (file) => { const fd = new FormData() diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index cbae610..1915835 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -1065,6 +1065,38 @@ function openAdminTierList(tierList) { previewModalOpen.value = true } +function previewRequestItemsById(preview) { + const items = Array.isArray(preview?.snapshotItems) ? preview.snapshotItems : [] + return items.reduce((acc, item) => { + if (item?.id) acc[item.id] = item + return acc + }, {}) +} + +function previewRequestGroupItems(preview, group) { + const itemsById = previewRequestItemsById(preview) + return (group?.itemIds || []).map((itemId) => itemsById[itemId]).filter(Boolean) +} + +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) { + previewTierList.value = { + id: request.id, + title: request.sourceTierListTitle || '템플릿 요청 미리보기', + description: request.sourceDescription || '', + thumbnailSrc: request.thumbnailSrc || '', + requestPreview: true, + snapshotGroups: request.snapshotGroups || [], + snapshotItems: request.snapshotItems || [], + snapshotShowCharacterNames: !!request.snapshotShowCharacterNames, + } + previewModalOpen.value = true +} + function closePreviewModal() { previewModalOpen.value = false previewTierList.value = null @@ -1470,13 +1502,14 @@ async function saveFeaturedOrder() {
{{ request.sourceTierListTitle }}
+
{{ request.sourceDescription }}
{{ templateRequestTypeLabel(request) }} · {{ request.requesterName }} · {{ fmt(request.createdAt) }}
{{ templateRequestTargetLabel(request) }}
-
@@ -1886,8 +1919,45 @@ async function saveFeaturedOrder() {
{{ previewTierList?.title || '티어표 미리보기' }}
+
+ +
{{ previewTierList.description }}
+
+
+
{{ group.name }}
+
+
+ +
{{ item.label }}
+
+
+
+
+
+
남은 아이템
+
+
+ +
{{ item.label }}
+
+
+
+