diff --git a/backend/src/db.js b/backend/src/db.js index b209e9f..3054eea 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -79,6 +79,9 @@ function mapTierListRow(row) { description: row.description || '', isPublic: !!row.is_public, showCharacterNames: !!row.show_character_names, + sourceTierListId: row.source_tierlist_id || '', + sourceSnapshotTitle: row.source_snapshot_title || '', + sourceSnapshotAuthor: row.source_snapshot_author || '', groups: parseJson(row.groups_json, []), pool: parseJson(row.pool_json, []), createdAt: Number(row.created_at), @@ -228,6 +231,9 @@ async function ensureSchema() { description TEXT NOT NULL, is_public TINYINT(1) NOT NULL DEFAULT 0, show_character_names TINYINT(1) NOT NULL DEFAULT 0, + source_tierlist_id VARCHAR(64) NULL DEFAULT NULL, + source_snapshot_title VARCHAR(120) NOT NULL DEFAULT '', + source_snapshot_author VARCHAR(120) NOT NULL DEFAULT '', groups_json LONGTEXT NOT NULL, pool_json LONGTEXT NOT NULL, created_at BIGINT NOT NULL, @@ -295,6 +301,18 @@ async function ensureSchema() { if (!tierListShowNamesColumns.length) { await query("ALTER TABLE tierlists ADD COLUMN show_character_names TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public") } + const tierListSourceIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_tierlist_id'") + if (!tierListSourceIdColumns.length) { + await query("ALTER TABLE tierlists ADD COLUMN source_tierlist_id VARCHAR(64) NULL DEFAULT NULL AFTER show_character_names") + } + const tierListSourceTitleColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_snapshot_title'") + if (!tierListSourceTitleColumns.length) { + await query("ALTER TABLE tierlists ADD COLUMN source_snapshot_title VARCHAR(120) NOT NULL DEFAULT '' AFTER source_tierlist_id") + } + const tierListSourceAuthorColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_snapshot_author'") + if (!tierListSourceAuthorColumns.length) { + await query("ALTER TABLE tierlists ADD COLUMN source_snapshot_author VARCHAR(120) NOT NULL DEFAULT '' AFTER source_snapshot_title") + } await query( ` @@ -894,6 +912,9 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited t.description, t.is_public, t.show_character_names, + t.source_tierlist_id, + t.source_snapshot_title, + t.source_snapshot_author, t.groups_json, t.pool_json, t.created_at, @@ -1024,6 +1045,9 @@ async function listAdminTierLists({ queryText = '', page = 1, limit = 50, curren t.description, t.is_public, t.show_character_names, + t.source_tierlist_id, + t.source_snapshot_title, + t.source_snapshot_author, t.groups_json, t.pool_json, t.created_at, @@ -1080,6 +1104,9 @@ async function findTierListById(id, currentUserId = '') { t.description, t.is_public, t.show_character_names, + t.source_tierlist_id, + t.source_snapshot_title, + t.source_snapshot_author, t.groups_json, t.pool_json, t.created_at, @@ -1277,7 +1304,21 @@ async function deleteCustomItems(ids) { await query(`DELETE FROM custom_items WHERE id IN (${placeholders})`, ids) } -async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', description, isPublic, showCharacterNames = false, groups, pool }) { +async function saveTierList({ + id, + authorId, + gameId, + title, + thumbnailSrc = '', + description, + isPublic, + showCharacterNames = false, + sourceTierListId = '', + sourceSnapshotTitle = '', + sourceSnapshotAuthor = '', + groups, + pool, +}) { const existing = id ? await findTierListById(id, authorId) : null await syncOwnedCustomItemLabels({ ownerId: authorId, items: pool }) const nextThumbnailSrc = (thumbnailSrc || '').trim() || getAutoThumbnailSrc(groups, pool) @@ -1286,10 +1327,10 @@ async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', de await query( ` UPDATE tierlists - SET title = ?, thumbnail_src = ?, description = ?, is_public = ?, show_character_names = ?, groups_json = ?, pool_json = ?, updated_at = ? + SET title = ?, thumbnail_src = ?, description = ?, is_public = ?, show_character_names = ?, source_tierlist_id = ?, source_snapshot_title = ?, source_snapshot_author = ?, groups_json = ?, pool_json = ?, updated_at = ? WHERE id = ? `, - [title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, serializeJson(groups), serializeJson(pool), now(), existing.id] + [title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), now(), existing.id] ) return findTierListById(existing.id, authorId) } @@ -1298,15 +1339,37 @@ async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', de await query( ` INSERT INTO tierlists ( - id, author_id, game_id, title, thumbnail_src, description, is_public, show_character_names, groups_json, pool_json, created_at, updated_at + id, author_id, game_id, title, thumbnail_src, description, is_public, show_character_names, source_tierlist_id, source_snapshot_title, source_snapshot_author, groups_json, pool_json, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, - [id, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt] + [id, 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) } +async function duplicateTierListForUser({ tierList, targetUserId }) { + const { nanoid } = require('nanoid') + const duplicateId = nanoid() + const baseTitle = (tierList.title || '티어표').trim() || '티어표' + const copyTitle = baseTitle.endsWith(' 복사본') ? baseTitle : `${baseTitle} 복사본` + return saveTierList({ + id: duplicateId, + authorId: targetUserId, + gameId: tierList.gameId, + title: copyTitle, + thumbnailSrc: tierList.thumbnailSrc || '', + description: tierList.description || '', + isPublic: false, + showCharacterNames: !!tierList.showCharacterNames, + sourceTierListId: tierList.id, + sourceSnapshotTitle: tierList.title || '', + sourceSnapshotAuthor: tierList.authorName || tierList.authorAccountName || '', + groups: JSON.parse(JSON.stringify(tierList.groups || [])), + pool: JSON.parse(JSON.stringify(tierList.pool || [])), + }) +} + async function favoriteTierList({ userId, tierListId }) { await query('INSERT IGNORE INTO favorite_tierlists (user_id, tierlist_id, created_at) VALUES (?, ?, ?)', [userId, tierListId, now()]) } @@ -1363,6 +1426,7 @@ module.exports = { findCustomItemsByIds, deleteCustomItems, saveTierList, + duplicateTierListForUser, createTemplateRequest, findTemplateRequestById, listAdminTemplateRequests, diff --git a/backend/src/routes/tierlists.js b/backend/src/routes/tierlists.js index 7c2ceff..475c93e 100644 --- a/backend/src/routes/tierlists.js +++ b/backend/src/routes/tierlists.js @@ -15,6 +15,7 @@ const { findUserById, favoriteTierList, unfavoriteTierList, + duplicateTierListForUser, } = require('../db') const { requireAuth } = require('../middleware/auth') @@ -84,6 +85,9 @@ const tierListUpsertSchema = z.object({ description: z.string().max(1000).optional().default(''), isPublic: z.boolean().default(false), showCharacterNames: z.boolean().optional().default(false), + sourceTierListId: z.string().max(64).optional().default(''), + sourceSnapshotTitle: z.string().max(120).optional().default(''), + sourceSnapshotAuthor: z.string().max(120).optional().default(''), groups: z.array( z.object({ id: z.string().min(1), @@ -131,6 +135,15 @@ router.get('/:id', async (req, res) => { res.json({ tierList: normalizeTierList(t) }) }) +router.post('/:id/duplicate', requireAuth, async (req, res) => { + const tierList = await findTierListById(req.params.id, req.session.userId) + if (!tierList) return res.status(404).json({ error: 'not_found' }) + if (!tierList.isPublic && tierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' }) + + const duplicated = await duplicateTierListForUser({ tierList, targetUserId: req.session.userId }) + res.json({ tierList: normalizeTierList(duplicated) }) +}) + router.delete('/:id', requireAuth, async (req, res) => { const tierList = await findTierListById(req.params.id, req.session.userId) if (!tierList) return res.status(404).json({ error: 'not_found' }) @@ -245,6 +258,9 @@ router.post('/', requireAuth, async (req, res) => { description: payload.description || '', isPublic: !!payload.isPublic, showCharacterNames: !!payload.showCharacterNames, + sourceTierListId: payload.sourceTierListId || existing.sourceTierListId || '', + sourceSnapshotTitle: payload.sourceSnapshotTitle || existing.sourceSnapshotTitle || '', + sourceSnapshotAuthor: payload.sourceSnapshotAuthor || existing.sourceSnapshotAuthor || '', groups: payload.groups, pool: normalizedPool, }) @@ -260,6 +276,9 @@ router.post('/', requireAuth, async (req, res) => { description: payload.description || '', isPublic: !!payload.isPublic, showCharacterNames: !!payload.showCharacterNames, + sourceTierListId: payload.sourceTierListId || '', + sourceSnapshotTitle: payload.sourceSnapshotTitle || '', + sourceSnapshotAuthor: payload.sourceSnapshotAuthor || '', groups: payload.groups, pool: normalizedPool, }) diff --git a/docs/update.md b/docs/update.md index 3e1517b..e5582fa 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-03-31 v1.2.71 +- 게임 허브 공개 티어표 카드는 자동 폭 그리드와 2줄 제목/유연한 메타 배치로 보정해, 브라우저 폭이 줄어들어도 썸네일과 텍스트가 카드 밖으로 넘치지 않도록 정리함. +- 공개 티어표 상세에서는 다른 사용자의 티어표를 복사해 내 작업본으로 가져오는 기능을 추가하고, 복사본에는 원본 제목/작성자 정보를 작은 출처 메모로 남기도록 확장함. +- 보기 전용 티어표의 미배치 아이템은 더 어둡고 흐리게 표시하고 `미배치` 상태를 붙여, 내 보드처럼 조작 가능한 인상을 줄이도록 보정함. + ## 2026-03-31 v1.2.70 - 관리자 게임 관리의 썸네일 드롭존을 카드 안 카드 구조 대신, 썸네일 전체 위에 하단 오버레이 문구를 얹는 단일 미디어 영역으로 정리함. - 게임 관리 본문 상단 안내 패널과 과한 설명 문구를 제거하고, 비선택 상태는 `게임을 선택해 주세요.` 한 줄 중심의 empty 상태로 단순화함. diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 9d82ef0..c1eee4f 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -90,6 +90,7 @@ export const api = { favoriteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/favorite`, { method: 'POST' }), 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 }), saveTierList: (payload) => request('/api/tierlists', { method: 'POST', body: payload }), uploadTierListThumbnail: async (file) => { diff --git a/frontend/src/views/GameHubView.vue b/frontend/src/views/GameHubView.vue index 95bce73..440f931 100644 --- a/frontend/src/views/GameHubView.vue +++ b/frontend/src/views/GameHubView.vue @@ -213,8 +213,9 @@ function submitSearch() { } .list { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(280px, 320px)); gap: 18px; + justify-content: start; } .boardCard { border-radius: 22px; @@ -271,25 +272,34 @@ function submitSearch() { min-width: 0; font-size: 18px; line-height: 1.35; - white-space: nowrap; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; } .boardCard__head { + min-width: 0; padding: 16px 18px 18px; display: grid; - gap: 6px; + gap: 8px; } .boardCard__titleRow, .boardCard__metaRow { + min-width: 0; display: flex; gap: 10px; align-items: center; justify-content: space-between; } +.boardCard__titleRow { + align-items: flex-start; +} + .boardCard__metaRow { align-items: flex-end; + flex-wrap: wrap; } .boardCard__author { min-width: 0; @@ -323,6 +333,7 @@ function submitSearch() { .boardCard__date, .favoriteStat { flex: 0 0 auto; + min-width: 0; font-size: 13px; color: rgba(255, 255, 255, 0.64); white-space: nowrap; diff --git a/frontend/src/views/TierEditorView.vue b/frontend/src/views/TierEditorView.vue index 496a3db..b3a54f8 100644 --- a/frontend/src/views/TierEditorView.vue +++ b/frontend/src/views/TierEditorView.vue @@ -49,6 +49,9 @@ const ownerId = ref('') const authorName = ref('') const authorAccountName = ref('') const updatedAt = ref(0) +const sourceTierListId = ref('') +const sourceSnapshotTitle = ref('') +const sourceSnapshotAuthor = ref('') const isDragActive = ref(false) const isThumbnailDragActive = ref(false) const iconSize = ref(80) @@ -95,6 +98,14 @@ const untitledWarning = computed( '제목 없이 저장된 티어표는 무분별한 도배 방지를 위해 관리자에 의해 임의 삭제될 수 있어요.' ) const canFavorite = computed(() => !!auth.user && !isNewTierList.value && !canEdit.value) +const canDuplicate = computed(() => !!auth.user && !isNewTierList.value && !canEdit.value) +const copiedFromLabel = computed(() => { + if (!sourceTierListId.value) return '' + const parts = [] + if (sourceSnapshotTitle.value) parts.push(`원본 ${sourceSnapshotTitle.value}`) + if (sourceSnapshotAuthor.value) parts.push(sourceSnapshotAuthor.value) + return parts.join(' · ') || '복사해 온 티어표' +}) const customItems = computed(() => Object.values(itemsById.value) .filter((item) => item?.origin === 'custom') @@ -473,6 +484,9 @@ function buildPayload(existingId) { description: (description.value || '').trim(), isPublic: !!isPublic.value, showCharacterNames: !!showCharacterNames.value, + sourceTierListId: sourceTierListId.value || '', + sourceSnapshotTitle: sourceSnapshotTitle.value || '', + sourceSnapshotAuthor: sourceSnapshotAuthor.value || '', groups: groups.value.map((g) => ({ id: g.id, name: g.name, itemIds: g.itemIds })), pool: Object.values(itemsById.value), } @@ -561,6 +575,19 @@ async function confirmDeleteTierList() { } } +async function duplicateCurrentTierList() { + if (!canDuplicate.value) return + try { + const data = await api.duplicateTierList(tierListId.value) + const duplicatedId = data.tierList?.id + if (!duplicatedId) throw new Error('duplicate_failed') + toast.success('티어표를 복사해 내 작업으로 가져왔어요.') + router.push(`/editor/${gameId.value}/${duplicatedId}`) + } catch (e) { + error.value = '티어표 복사에 실패했어요.' + } +} + async function toggleFavorite() { if (!canFavorite.value || isFavoriteBusy.value) return try { @@ -653,6 +680,9 @@ onMounted(() => { authorName.value = t.authorName || '' authorAccountName.value = t.authorAccountName || '' updatedAt.value = Number(t.updatedAt || 0) + sourceTierListId.value = t.sourceTierListId || '' + sourceSnapshotTitle.value = t.sourceSnapshotTitle || '' + sourceSnapshotAuthor.value = t.sourceSnapshotAuthor || '' favoriteCount.value = Number(t.favoriteCount || 0) isFavorited.value = !!t.isFavorited groups.value = t.groups @@ -699,7 +729,7 @@ onUnmounted(() => {