diff --git a/backend/src/routes/tierlists.js b/backend/src/routes/tierlists.js index 5d8ad76..75b1124 100644 --- a/backend/src/routes/tierlists.js +++ b/backend/src/routes/tierlists.js @@ -72,8 +72,8 @@ const templateRequestSchema = z.object({ z.object({ id: z.string().min(1), name: z.string().min(1).max(16), - itemIds: z.array(z.string()), - }) + itemIds: z.array(z.string()).optional().default([]), + }).passthrough() ), boardItems: z.array( z.object({ @@ -100,8 +100,8 @@ const tierListUpsertSchema = z.object({ z.object({ id: z.string().min(1), name: z.string().min(1).max(16), - itemIds: z.array(z.string()), - }) + itemIds: z.array(z.string()).optional().default([]), + }).passthrough() ), pool: z.array( z.object({ diff --git a/docs/todo.md b/docs/todo.md index 7da3d12..a410b93 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,10 +1,6 @@ # 할 일 및 이슈 ## 즉시 확인 필요 -- 최근 게임들은 S, A, B,C 같은 랭크 뿐만 아니라 가로 열도 나누어진형태의 티어표를 원함 (공격, 방어, 지원 등 각 파트별 랭크를 보고싶어함) -- 티어표에서 티어 추가(세로라인)를 행 추가 등의 단어로 변경. 열 추가 버튼도 표시. 열을 추가할경우 열에도 이름을 입력 가능. (1열만 있을 경우에는 삭제 불가, 열 제목 사용 안함) -- 드래그 아이콘의 위치를 이동시키거나, 제외하는 등(기능은 가능해야함) 적절한 판단을 통해 행 라벨의 너비가 큰편인데 조금 줄일 필요 있음. (실제로는 5~6자 이상 쓰는경우가 거의 없기 때문) -- 이미지 저장시 라벨 텍스트가 중앙 정렬이 아닌 살짝 상단으로 치우쳐진 상태라 이쁘지 않은데... 확인 필요함. - 레거시 파일 정리 스크립트는 준비됐으므로, 운영 단계에서는 cron 등으로 주기 실행할지와 삭제 전 보관 기간을 함께 정한다. - 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다. - 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다. diff --git a/docs/update.md b/docs/update.md index e4eb9b7..31d9110 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-04-01 v1.3.14 +- 티어 에디터를 단일 세로 랭크형에서 행/열 혼합 보드로 확장해, 공격·방어·지원 같은 가로 열을 추가하고 각 열 이름도 직접 입력할 수 있게 함. +- 에디터 액션 문구를 `행 추가 / 열 추가` 기준으로 정리하고, 행 라벨 폭과 드래그 아이콘 위치를 다듬어 실제 사용 빈도에 맞는 더 압축된 보드 레이아웃으로 보정함. +- 이름 오버레이 정렬과 저장용 미리보기 보드도 함께 손봐서, 이미지 다운로드 시 라벨 텍스트가 하단 중앙에 더 안정적으로 배치되도록 수정함. + ## 2026-04-01 v1.3.13 - 템플릿 등록/업데이트 요청 모달은 이제 현재 티어표 제목·설명을 기본값으로 가져오고, 비어 있더라도 모달 안에서 바로 작성해 요청할 수 있도록 흐름을 단순화함. - 템플릿 요청 시 `내 티어 리스트에도 저장` 토글을 추가해, 요청 스냅샷만 관리자에게 전달할지 아니면 현재 양식도 내 티어표로 함께 저장할지 분리함. diff --git a/frontend/src/views/TierEditorView.vue b/frontend/src/views/TierEditorView.vue index 90f288a..6c6e897 100644 --- a/frontend/src/views/TierEditorView.vue +++ b/frontend/src/views/TierEditorView.vue @@ -19,11 +19,12 @@ const tierListId = computed(() => route.params.tierListId) const previewMode = computed(() => route.query.preview === '1') const gameName = ref('') +const columns = ref([{ id: 'col-1', name: '' }]) const groups = ref([ - { id: 'gS', name: 'S', itemIds: [] }, - { id: 'gA', name: 'A', itemIds: [] }, - { id: 'gB', name: 'B', itemIds: [] }, - { id: 'gC', name: 'C', itemIds: [] }, + { id: 'gS', name: 'S', itemIds: [], cells: [[]] }, + { id: 'gA', name: 'A', itemIds: [], cells: [[]] }, + { id: 'gB', name: 'B', itemIds: [], cells: [[]] }, + { id: 'gC', name: 'C', itemIds: [], cells: [[]] }, ]) const pool = ref([]) @@ -154,38 +155,107 @@ function setIconSize(nextSize) { iconSize.value = nextSize } -function removeItemFromGroup(groupId, itemId) { - if (!canEdit.value || !groupId || !itemId) return +function getGroupCellIds(group, columnIndex) { + return Array.isArray(group?.cells?.[columnIndex]) ? group.cells[columnIndex] : [] +} + +function syncGroupItemIds(group) { + group.itemIds = (group.cells || []).flat() +} + +function normalizeLoadedColumns(rawGroups) { + const fromGroup = Array.isArray(rawGroups) ? rawGroups.find((group) => Array.isArray(group?.columnNames) && group.columnNames.length) : null + const rawColumns = Array.isArray(fromGroup?.columnNames) ? fromGroup.columnNames : [] + const cellCount = Math.max(1, ...(Array.isArray(rawGroups) ? rawGroups.map((group) => (Array.isArray(group?.cells) ? group.cells.length : 0)) : [0])) + const size = Math.max(rawColumns.length || 0, cellCount) + return Array.from({ length: size || 1 }, (_, index) => ({ + id: rawColumns[index]?.id || `col-${index + 1}` , + name: typeof rawColumns[index]?.name === 'string' ? rawColumns[index].name.slice(0, 16) : '', + })) +} + +function normalizeLoadedGroups(rawGroups, nextColumns = columns.value) { + if (!Array.isArray(rawGroups) || !rawGroups.length) { + return [ + { id: 'gS', name: 'S', itemIds: [], cells: nextColumns.map(() => []) }, + { id: 'gA', name: 'A', itemIds: [], cells: nextColumns.map(() => []) }, + { id: 'gB', name: 'B', itemIds: [], cells: nextColumns.map(() => []) }, + { id: 'gC', name: 'C', itemIds: [], cells: nextColumns.map(() => []) }, + ] + } + return rawGroups.map((group, index) => { + const cells = Array.from({ length: nextColumns.length }, (_, cellIndex) => { + if (Array.isArray(group?.cells?.[cellIndex])) return [...group.cells[cellIndex]] + if (cellIndex === 0 && Array.isArray(group?.itemIds)) return [...group.itemIds] + return [] + }) + return { + id: typeof group?.id === 'string' && group.id ? group.id : `g-${index + 1}` , + name: typeof group?.name === 'string' && group.name ? group.name.slice(0, 16) : 'Tier', + itemIds: cells.flat(), + cells, + } + }) +} + +function buildGroupPayload() { + return groups.value.map((group) => ({ + id: group.id, + name: group.name, + itemIds: (group.cells || []).flat(), + cells: (group.cells || []).map((cell) => [...cell]), + columnNames: columns.value.map((column) => ({ id: column.id, name: column.name || '' })), + })) +} + +function removeItemFromGroup(groupId, columnIndex, itemId) { + if (!canEdit.value || !groupId || columnIndex == null || !itemId) return const targetGroup = groups.value.find((group) => group.id === groupId) if (!targetGroup) return - if (!targetGroup.itemIds.includes(itemId)) return - targetGroup.itemIds = targetGroup.itemIds.filter((id) => id !== itemId) + const nextCells = [...targetGroup.cells] + nextCells[columnIndex] = getGroupCellIds(targetGroup, columnIndex).filter((id) => id !== itemId) + targetGroup.cells = nextCells + syncGroupItemIds(targetGroup) pool.value = [itemId, ...pool.value.filter((id) => id !== itemId)] } -function setGroupDropEl(groupId, el) { +function setGroupDropEl(groupId, columnIndex, el) { + const key = `${groupId}::${columnIndex}` if (!el) { - delete groupDropEls.value[groupId] + delete groupDropEls.value[key] return } - groupDropEls.value[groupId] = el + groupDropEls.value[key] = el } function getListByContainer(containerEl) { - if (!containerEl) return { type: null, groupId: null } + if (!containerEl) return { type: null, groupId: null, columnIndex: null } const t = containerEl.getAttribute('data-list-type') - if (t === 'pool') return { type: 'pool', groupId: null } - if (t === 'group') return { type: 'group', groupId: containerEl.getAttribute('data-group-id') } - return { type: null, groupId: null } + if (t === 'pool') return { type: 'pool', groupId: null, columnIndex: null } + if (t === 'group') { + return { + type: 'group', + groupId: containerEl.getAttribute('data-group-id'), + columnIndex: Number(containerEl.getAttribute('data-column-index')), + } + } + return { type: null, groupId: null, columnIndex: null } } function normalizeSort(containerEl) { const ids = Array.from(containerEl.querySelectorAll('[data-item-id]')).map((n) => n.getAttribute('data-item-id')) const meta = getListByContainer(containerEl) - if (meta.type === 'pool') pool.value = ids + if (meta.type === 'pool') { + pool.value = ids + return + } if (meta.type === 'group') { const g = groups.value.find((x) => x.id === meta.groupId) - if (g) g.itemIds = ids + if (!g || !Number.isInteger(meta.columnIndex)) return + const nextCells = [...g.cells] + nextCells[meta.columnIndex] = ids + g.cells = nextCells + syncGroupItemIds(g) } } @@ -224,7 +294,7 @@ async function initSortables() { onAdd: () => normalizeSort(poolEl.value), }) - dropSortables.value = Object.entries(groupDropEls.value).map(([gid, el]) => + dropSortables.value = Object.entries(groupDropEls.value).map(([, el]) => Sortable.create(el, { group: 'tier-items', animation: 160, @@ -257,32 +327,69 @@ async function syncSortables() { } } -function createGroupName() { +function createGroupName(index = groups.value.length) { const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' - const index = groups.value.length if (index < alphabet.length) return alphabet[index] return `Tier ${index + 1}` } +function createColumnName(index = columns.value.length) { + return `열 ${index + 1}` +} + async function addGroup() { groups.value = [ ...groups.value, { - id: `g-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, + id: `g-${Date.now()}-${Math.random().toString(16).slice(2, 8)}` , name: createGroupName(), itemIds: [], + cells: columns.value.map(() => []), }, ] await syncSortables() } +async function addColumn() { + columns.value = [ + ...columns.value, + { id: `col-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, name: createColumnName() }, + ] + groups.value = groups.value.map((group) => ({ + ...group, + cells: [...group.cells, []], + itemIds: [...group.itemIds], + })) + await syncSortables() +} + +async function removeColumn(columnIndex) { + if (!canEdit.value || columns.value.length <= 1) return + const nextColumns = columns.value.filter((_, index) => index !== columnIndex) + groups.value = groups.value.map((group) => { + const nextCells = group.cells.filter((_, index) => index !== columnIndex) + const removed = Array.isArray(group.cells[columnIndex]) ? group.cells[columnIndex] : [] + if (nextCells[0] && removed.length) nextCells[0] = [...removed, ...nextCells[0]] + const nextGroup = { ...group, cells: nextCells } + syncGroupItemIds(nextGroup) + return nextGroup + }) + Object.keys(groupDropEls.value).forEach((key) => { + if (key.endsWith(`::${columnIndex}`)) delete groupDropEls.value[key] + }) + columns.value = nextColumns + await syncSortables() +} + async function performRemoveGroup(groupId) { if (groups.value.length <= 1) return const target = groups.value.find((group) => group.id === groupId) if (!target) return pool.value = [...target.itemIds, ...pool.value] groups.value = groups.value.filter((group) => group.id !== groupId) - delete groupDropEls.value[groupId] + Object.keys(groupDropEls.value).forEach((key) => { + if (key.startsWith(`${groupId}::`)) delete groupDropEls.value[key] + }) await syncSortables() } @@ -469,10 +576,14 @@ async function uploadPendingCustomItems() { } itemsById.value = nextItemsById pool.value = pool.value.map((currentId) => (currentId === item.id ? uploaded.id : currentId)) - groups.value = groups.value.map((group) => ({ - ...group, - itemIds: group.itemIds.map((currentId) => (currentId === item.id ? uploaded.id : currentId)), - })) + groups.value = groups.value.map((group) => { + const nextGroup = { + ...group, + cells: group.cells.map((cell) => cell.map((currentId) => (currentId === item.id ? uploaded.id : currentId))), + } + syncGroupItemIds(nextGroup) + return nextGroup + }) } } @@ -501,7 +612,7 @@ function buildPayload(existingId) { sourceTierListId: sourceTierListId.value || '', sourceSnapshotTitle: sourceSnapshotTitle.value || '', sourceSnapshotAuthor: sourceSnapshotAuthor.value || '', - groups: groups.value.map((g) => ({ id: g.id, name: g.name, itemIds: g.itemIds })), + groups: buildGroupPayload(), pool: Object.values(itemsById.value), } } @@ -633,11 +744,7 @@ async function requestTemplate(type) { isPublic: !!isPublic.value, showCharacterNames: !!showCharacterNames.value, saveToMyTierList: !!templateRequestSaveToMyTierList.value, - groups: groups.value.map((group) => ({ - id: group.id, - name: group.name, - itemIds: [...group.itemIds], - })), + groups: buildGroupPayload(), boardItems: Object.values(itemsById.value), }) @@ -727,12 +834,13 @@ onMounted(() => { sourceSnapshotAuthor.value = t.sourceSnapshotAuthor || '' favoriteCount.value = Number(t.favoriteCount || 0) isFavorited.value = !!t.isFavorited - groups.value = t.groups + columns.value = normalizeLoadedColumns(t.groups) + groups.value = normalizeLoadedGroups(t.groups, columns.value) const map = {} ;(t.pool || []).forEach((it) => (map[it.id] = it)) itemsById.value = map const grouped = new Set() - groups.value.forEach((g) => g.itemIds.forEach((id) => grouped.add(id))) + groups.value.forEach((group) => group.itemIds.forEach((id) => grouped.add(id))) pool.value = Object.keys(itemsById.value).filter((id) => !grouped.has(id)) } catch (e) { error.value = '티어표를 불러오지 못했어요.' @@ -760,10 +868,15 @@ onUnmounted(() => {