diff --git a/backend/src/db.js b/backend/src/db.js index c91cc30..55281c2 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -66,6 +66,9 @@ function mapTierListRow(row) { return { id: row.id, authorId: row.author_id, + authorName: getUserDisplayName(row), + authorAccountName: getUserAccountName(row), + authorAvatarSrc: row.avatar_src || '', gameId: row.game_id, title: row.title, description: row.description || '', @@ -614,9 +617,23 @@ async function listUserTierLists(userId) { async function findTierListById(id) { const rows = await query( ` - SELECT id, author_id, game_id, title, description, is_public, groups_json, pool_json, created_at, updated_at - FROM tierlists - WHERE id = ? + SELECT + t.id, + t.author_id, + t.game_id, + t.title, + t.description, + t.is_public, + t.groups_json, + t.pool_json, + t.created_at, + t.updated_at, + u.nickname, + u.email, + u.avatar_src + FROM tierlists t + INNER JOIN users u ON u.id = t.author_id + WHERE t.id = ? LIMIT 1 `, [id] diff --git a/docs/history.md b/docs/history.md index b98357e..0a897ef 100644 --- a/docs/history.md +++ b/docs/history.md @@ -77,3 +77,8 @@ - 목록 썸네일 fallback 문자는 닉네임보다 계정 기준이 더 일관되므로, 아바타 이미지가 없을 때는 계정명 첫 글자를 사용하기로 결정했다. - 저장 성공은 화면 이동보다 현 위치 유지가 더 중요하므로, 편집을 계속할 수 있는 확인형 모달로 피드백을 제공하기로 결정했다. - PNG export는 가장자리 여백이 없는 결과보다 중앙 정렬된 카드형 결과물이 더 완성도 있게 보이므로, export 전용 보드에 충분한 바깥 패딩을 포함하기로 했다. + +## 2026-03-26 v0.1.22 +- 무제목 저장은 게임 이름 기본값보다 `이름 없음 + 날짜`가 더 명확하다고 판단해 자동 제목 규칙을 변경했다. +- 제목이 비어 있는 티어표는 품질 관리 대상이 될 수 있으므로, 작성 중 단계에서 관리자 임의 삭제 가능성을 미리 안내하기로 결정했다. +- 다운로드 이미지에는 편집용 빈 칸 안내 문구를 제외하고, 더 넓은 보드 폭과 하단 작성자/날짜 메타를 포함해 공유용 완성도를 높이기로 결정했다. diff --git a/docs/spec.md b/docs/spec.md index 994732e..1edd38c 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -106,10 +106,12 @@ - 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다. - 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다. - 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다. +- 제목이 비어 있는 상태로 저장하면 내부 제목은 `이름 없음 + 날짜` 형식으로 자동 생성한다. +- 제목 입력이 비어 있는 동안에는 무분별한 도배 방지를 위한 관리자 임의 삭제 가능성 안내 문구를 표시한다. - 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다. - 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다. - 티어표 목록 메타 정보는 최종 업데이트 시각만 간략하게 표시한다. -- 저장 성공 시에는 에디터 안에서 반투명 오버레이 기반 확인 모달을 띄우고, PNG export 이미지는 외곽 여백을 포함해 생성한다. +- 저장 성공 시에는 에디터 안에서 반투명 오버레이 기반 확인 모달을 띄우고, PNG export 이미지는 약 `1600px` 폭과 외곽 여백, 작성자/날짜 하단 메타를 포함해 생성한다. ## 운영 환경 변수 - 프런트엔드 diff --git a/docs/update.md b/docs/update.md index 9b7a2ec..b58972b 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-03-26 v0.1.22 +- **무제목 저장 규칙 변경**: 제목을 비워두고 저장하면 내부 저장 제목을 `이름 없음 + 날짜` 형식으로 생성하도록 변경 +- **무제목 안내 문구 추가**: 제목 입력이 비어 있는 동안 관리자 임의 삭제 가능성을 알리는 경고 문구를 제목 입력 아래에 표시 +- **export 보드 확장**: 다운로드용 티어표 이미지는 빈 칸 안내 문구를 숨기고, 약 `1600px` 폭과 더 넉넉한 여백, 하단 작성자/날짜 메타 정보를 포함하도록 조정 + ## 2026-03-26 v0.1.21 - **아바타 fallback 기준 통일**: 티어표 목록에서 작성자 아바타 이미지가 없을 때 닉네임이 아니라 계정명 기준 첫 글자를 표시하도록 정리 - **저장 완료 모달 추가**: 에디터에서 저장 성공 시 반투명 오버레이와 확인 버튼이 있는 피드백 모달을 표시하도록 추가 diff --git a/frontend/src/views/TierEditorView.vue b/frontend/src/views/TierEditorView.vue index a34331a..521c138 100644 --- a/frontend/src/views/TierEditorView.vue +++ b/frontend/src/views/TierEditorView.vue @@ -33,6 +33,9 @@ const isSaving = ref(false) const isExporting = ref(false) const isSaveModalOpen = ref(false) const ownerId = ref('') +const authorName = ref('') +const authorAccountName = ref('') +const updatedAt = ref(0) const isDragActive = ref(false) const boardEl = ref(null) @@ -47,6 +50,47 @@ const dropSortables = ref([]) const isNewTierList = computed(() => tierListId.value === 'new') const canEdit = computed(() => !!auth.user && (!ownerId.value || ownerId.value === auth.user.id)) +const hasCustomTitle = computed(() => !!(title.value || '').trim()) +const fallbackTimestamp = computed(() => (updatedAt.value ? updatedAt.value : Date.now())) +const effectiveAuthorName = computed(() => { + const currentNickname = (auth.user?.nickname || '').trim() + if (currentNickname) return currentNickname + if ((authorName.value || '').trim()) return authorName.value.trim() + const currentEmail = (auth.user?.email || '').trim() + if (currentEmail) return currentEmail.split('@')[0] || currentEmail + return (authorAccountName.value || '').trim() || 'unknown' +}) +const effectiveTitle = computed(() => { + const customTitle = (title.value || '').trim() + if (customTitle) return customTitle + return `이름 없음 ${formatTitleDate(fallbackTimestamp.value)}` +}) +const untitledWarning = computed( + () => + canEdit.value && + !hasCustomTitle.value && + '제목 없이 저장된 티어표는 무분별한 도배 방지를 위해 관리자에 의해 임의 삭제될 수 있어요.' +) + +function formatTitleDate(ts) { + const date = new Date(ts) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + return `${year}-${month}-${day} ${hours}:${minutes}` +} + +function formatExportDate(ts) { + return new Date(ts).toLocaleString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} function setGroupDropEl(groupId, el) { if (!el) { @@ -225,7 +269,7 @@ async function downloadImage() { const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url - a.download = `${(title.value || gameName.value || 'tierlist').trim()}.png` + a.download = `${effectiveTitle.value.trim()}.png` document.body.appendChild(a) a.click() a.remove() @@ -273,7 +317,7 @@ async function uploadPendingCustomItems() { } function buildPayload(existingId) { - const finalTitle = (title.value || '').trim() || `${(gameName.value || gameId.value).trim()} 티어표` + const finalTitle = effectiveTitle.value return { id: existingId || undefined, gameId: gameId.value, @@ -293,6 +337,9 @@ async function save() { const payload = buildPayload(tierListId.value && tierListId.value !== 'new' ? tierListId.value : null) const res = await api.saveTierList(payload) if (tierListId.value === 'new') history.replaceState({}, '', `/editor/${gameId.value}/${res.tierList.id}`) + updatedAt.value = Number(res.tierList?.updatedAt || Date.now()) + authorName.value = res.tierList?.authorName || effectiveAuthorName.value + authorAccountName.value = res.tierList?.authorAccountName || authorAccountName.value isSaveModalOpen.value = true } catch (e) { error.value = '저장 실패: 로그인 상태인지 확인해주세요.' @@ -321,6 +368,8 @@ async function removeTierList() { onMounted(() => { ;(async () => { await auth.refresh() + authorName.value = (auth.user?.nickname || '').trim() + authorAccountName.value = ((auth.user?.email || '').trim().split('@')[0] || '').trim() if (isNewTierList.value && !auth.user) { router.replace(`/login?redirect=/editor/${gameId.value}/new`) @@ -352,6 +401,9 @@ onMounted(() => { title.value = t.title description.value = t.description || '' isPublic.value = !!t.isPublic + authorName.value = t.authorName || '' + authorAccountName.value = t.authorAccountName || '' + updatedAt.value = Number(t.updatedAt || 0) groups.value = t.groups const map = {} ;(t.pool || []).forEach((it) => (map[it.id] = it)) @@ -381,6 +433,7 @@ onUnmounted(() => {