From d4ab4b2cd1ab3165029cc7dccfed3fc2e85ea58a Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 19 Mar 2026 18:17:53 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=8A=A4:=20v0.1.19=20?= =?UTF-8?q?=ED=8B=B0=EC=96=B4=ED=91=9C=20=EC=A0=80=EC=9E=A5=20UI=EC=99=80?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1=EC=9E=90=20=ED=91=9C=EC=8B=9C=20=EC=A0=95?= =?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 | 33 ++++++++++-- docs/history.md | 6 +++ docs/spec.md | 4 +- docs/update.md | 6 +++ frontend/src/views/GameHubView.vue | 56 +++++++++++++++++++-- frontend/src/views/MyTierListsView.vue | 54 ++++++++++++++++++-- frontend/src/views/TierEditorView.vue | 70 +++++++++++++++++++++++--- 7 files changed, 208 insertions(+), 21 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index 3ec2047..bc6d4e4 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -77,6 +77,15 @@ function mapTierListRow(row) { } } +function getUserDisplayName(row) { + if (!row) return '' + const nickname = (row.nickname || '').trim() + if (nickname) return nickname + const email = (row.email || '').trim() + if (!email) return '' + return email.split('@')[0] || email +} + async function createPool() { const rootConnection = await mysql.createConnection({ host: DB_HOST, @@ -537,7 +546,8 @@ async function listPublicTierLists(gameId) { t.updated_at, t.author_id, u.nickname, - u.email + u.email, + u.avatar_src FROM tierlists t INNER JOIN users u ON u.id = t.author_id ${whereClause} @@ -554,16 +564,27 @@ async function listPublicTierLists(gameId) { createdAt: Number(row.created_at), updatedAt: Number(row.updated_at), authorId: row.author_id, - authorName: row.nickname || row.email, + authorName: getUserDisplayName(row), + authorAvatarSrc: row.avatar_src || '', })) } async function listUserTierLists(userId) { const rows = await query( ` - SELECT id, game_id, title, created_at, updated_at, is_public - FROM tierlists - WHERE author_id = ? + SELECT + t.id, + t.game_id, + t.title, + t.created_at, + t.updated_at, + t.is_public, + u.nickname, + u.email, + u.avatar_src + FROM tierlists t + INNER JOIN users u ON u.id = t.author_id + WHERE t.author_id = ? ORDER BY updated_at DESC `, [userId] @@ -576,6 +597,8 @@ async function listUserTierLists(userId) { createdAt: Number(row.created_at), updatedAt: Number(row.updated_at), isPublic: !!row.is_public, + authorName: getUserDisplayName(row), + authorAvatarSrc: row.avatar_src || '', })) } diff --git a/docs/history.md b/docs/history.md index c6940e2..514c8c3 100644 --- a/docs/history.md +++ b/docs/history.md @@ -66,3 +66,9 @@ ## 2026-03-19 v0.1.17 - 작성자가 자기 티어표를 직접 삭제할 수 있어야 관리 흐름이 완결되므로, `내 티어표` 화면에 삭제 기능을 추가하기로 결정했다. - 사용자 커스텀 이미지는 서버 용량을 계속 차지하므로, 참조가 하나도 남지 않은 이미지(미사용 항목)를 식별하고 관리자 화면에서 개별/일괄 정리할 수 있도록 하기로 결정했다. + +## 2026-03-19 v0.1.19 +- 티어표 공개 여부는 운영 기준상 대부분 공개 공유가 목적이므로, 신규 작성 시 기본값을 `공개 ON`으로 두기로 결정했다. +- 에디터에서 저장 관련 행동은 좌우로 역할을 나눠 `다운로드/삭제`와 `공개/저장`으로 묶는 편이 더 빠르게 인지된다고 판단했다. +- 공개 목록과 내 목록에서 제목만으로는 식별이 어려우므로, 제목 옆에 작성자 아바타와 표시명을 함께 노출하기로 결정했다. +- 티어표 카드 메타 정보는 저장 시각과 업데이트 시각을 동시에 노출하는 대신, 최종 업데이트 시각만 남겨 단순화하기로 결정했다. diff --git a/docs/spec.md b/docs/spec.md index f95316a..839b6c0 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -105,7 +105,9 @@ - 비로그인 사용자는 공개된 티어표를 열람만 할 수 있고, 편집 UI와 저장 동작은 비활성화된다. - 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다. - 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다. -- 작성자는 `내 티어표` 목록에서 저장한 티어표를 직접 삭제할 수 있다. +- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다. +- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다. +- 티어표 목록 메타 정보는 최종 업데이트 시각만 간략하게 표시한다. ## 운영 환경 변수 - 프런트엔드 diff --git a/docs/update.md b/docs/update.md index 5820b8d..56398a2 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 로그 +## 2026-03-19 v0.1.19 +- **에디터 저장 영역 재정렬**: 공개 기본값을 `ON`으로 바꾸고, 액션 영역을 `이미지 다운로드 / 삭제 / 공개 ON·OFF / 저장` 흐름으로 재배치 +- **에디터 삭제 진입점 추가**: 기존 티어표는 편집 화면에서 바로 삭제할 수 있도록 버튼을 추가 +- **목록 작성자 표시 개선**: 공개 티어표와 내 티어표 목록의 제목 옆에 원형 아바타와 `by 닉네임(없으면 계정명)`을 표시 +- **목록 메타 단순화**: 티어표 카드 하단 정보는 게임 ID, 저장 시각, 라벨 문구를 제거하고 최종 업데이트 시각만 간략하게 노출 + ## 2026-03-19 v0.1.18 - **미사용 아이콘 필터 수정**: 관리자 아이템 관리의 `미사용 아이콘 보기` 체크 상태가 실제 API 요청의 `orphanOnly` 파라미터로 전달되도록 수정 - **삭제 활성화 흐름 정상화**: 미사용 아이콘만 조회했을 때 `usageCount = 0` 항목의 개별 삭제 버튼이 의도대로 활성화되도록 정리 diff --git a/frontend/src/views/GameHubView.vue b/frontend/src/views/GameHubView.vue index 9df5009..00696ef 100644 --- a/frontend/src/views/GameHubView.vue +++ b/frontend/src/views/GameHubView.vue @@ -2,6 +2,7 @@ import { computed, onMounted, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' import { api } from '../lib/api' +import { toApiUrl } from '../lib/runtime' import { useAuthStore } from '../stores/auth' const route = useRoute() @@ -21,10 +22,21 @@ function fmt(ts) { day: '2-digit', hour: '2-digit', minute: '2-digit', - second: '2-digit', }) } +function displayNameOf(tierList) { + return tierList.authorName || '알 수 없음' +} + +function avatarSrcOf(tierList) { + return tierList.authorAvatarSrc ? toApiUrl(tierList.authorAvatarSrc) : '' +} + +function avatarFallbackOf(tierList) { + return displayNameOf(tierList).trim().charAt(0).toUpperCase() || '?' +} + onMounted(async () => { try { const [gameRes, listRes] = await Promise.all([api.getGame(gameId.value), api.listPublicTierLists(gameId.value)]) @@ -66,10 +78,15 @@ function openTierList(id) {
아직 공개 티어표가 없어요.
@@ -141,12 +158,43 @@ function openTierList(id) { background: rgba(255, 255, 255, 0.03); color: rgba(255, 255, 255, 0.92); cursor: pointer; + width: 100%; } .row:hover { background: rgba(255, 255, 255, 0.05); } .row__title { font-weight: 800; + min-width: 0; +} +.row__head { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; +} +.row__author { + display: inline-flex; + gap: 8px; + align-items: center; + font-size: 13px; + opacity: 0.86; + flex: 0 0 auto; +} +.row__avatar { + width: 28px; + height: 28px; + border-radius: 999px; + object-fit: cover; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.08); +} +.row__avatar--fallback { + display: grid; + place-items: center; + font-size: 12px; + font-weight: 900; } .row__meta { opacity: 0.78; diff --git a/frontend/src/views/MyTierListsView.vue b/frontend/src/views/MyTierListsView.vue index 6a19f0d..82b95a2 100644 --- a/frontend/src/views/MyTierListsView.vue +++ b/frontend/src/views/MyTierListsView.vue @@ -2,6 +2,7 @@ import { onMounted, ref } from 'vue' import { useRouter } from 'vue-router' import { api } from '../lib/api' +import { toApiUrl } from '../lib/runtime' const router = useRouter() const myLists = ref([]) @@ -14,10 +15,21 @@ function fmt(ts) { day: '2-digit', hour: '2-digit', minute: '2-digit', - second: '2-digit', }) } +function displayNameOf(tierList) { + return tierList.authorName || '알 수 없음' +} + +function avatarSrcOf(tierList) { + return tierList.authorAvatarSrc ? toApiUrl(tierList.authorAvatarSrc) : '' +} + +function avatarFallbackOf(tierList) { + return displayNameOf(tierList).trim().charAt(0).toUpperCase() || '?' +} + onMounted(async () => { try { const data = await api.listMyTierLists() @@ -56,10 +68,15 @@ async function removeList(t) {
@@ -133,6 +150,35 @@ async function removeList(t) { } .row__title { font-weight: 900; + min-width: 0; +} +.row__head { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; +} +.row__author { + display: inline-flex; + gap: 8px; + align-items: center; + font-size: 13px; + opacity: 0.84; +} +.row__avatar { + width: 28px; + height: 28px; + border-radius: 999px; + object-fit: cover; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.08); +} +.row__avatar--fallback { + display: grid; + place-items: center; + font-size: 12px; + font-weight: 900; } .row__meta { margin-top: 6px; diff --git a/frontend/src/views/TierEditorView.vue b/frontend/src/views/TierEditorView.vue index 74ec53b..6bb170f 100644 --- a/frontend/src/views/TierEditorView.vue +++ b/frontend/src/views/TierEditorView.vue @@ -27,7 +27,7 @@ const itemsById = ref({}) const title = ref('') const description = ref('') -const isPublic = ref(false) +const isPublic = ref(true) const error = ref('') const isSaving = ref(false) const isExporting = ref(false) @@ -299,6 +299,19 @@ async function save() { } } +async function removeTierList() { + if (!canEdit.value || isNewTierList.value) return + error.value = '' + try { + const ok = window.confirm(`"${title.value || gameName.value || '이 티어표'}"를 삭제할까요?`) + if (!ok) return + await api.deleteTierList(tierListId.value) + router.push(gameId.value === 'freeform' ? '/me' : `/games/${gameId.value}`) + } catch (e) { + error.value = '티어표 삭제에 실패했어요.' + } +} + onMounted(() => { ;(async () => { await auth.refresh() @@ -378,12 +391,17 @@ onUnmounted(() => {
-
@@ -495,10 +513,17 @@ onUnmounted(() => { font-size: 13px; } .actions { + display: flex; + gap: 14px; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; +} +.actions__left, +.actions__right { display: flex; gap: 10px; align-items: center; - justify-content: flex-end; flex-wrap: wrap; } .toggle { @@ -539,6 +564,27 @@ onUnmounted(() => { .btn--primary:hover { background: rgba(110, 231, 183, 0.24); } +.btn--download { + justify-self: flex-start; +} +.btn--save { + min-width: 112px; + padding: 12px 18px; + font-size: 15px; + font-weight: 900; + background: rgba(96, 165, 250, 0.22); + border-color: rgba(96, 165, 250, 0.36); +} +.btn--save:hover { + background: rgba(96, 165, 250, 0.3); +} +.btn--danger { + background: rgba(239, 68, 68, 0.14); + border-color: rgba(239, 68, 68, 0.28); +} +.btn--danger:hover { + background: rgba(239, 68, 68, 0.22); +} .btn--ghost { width: 100%; margin-top: 10px; @@ -753,6 +799,16 @@ onUnmounted(() => { .layout { grid-template-columns: 1fr; } + .actions { + justify-content: stretch; + } + .actions__left, + .actions__right { + width: 100%; + } + .actions__right { + justify-content: flex-end; + } .row { grid-template-columns: 150px 1fr; }