Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 110242f8e9 |
@@ -1,5 +1,10 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-04-07 v1.1.26
|
||||
- `주제 불러오는 중...` 같은 임시 문구는 스켈레톤보다 더 거슬리게 보일 수 있으므로, 짧은 로딩 구간에서는 텍스트 fallback 대신 빈 자리 + 스켈레톤으로 처리하는 편이 낫다고 정리했다.
|
||||
- 저장용 랜덤 제목이나 내부 `tierListId`는 시스템 식별자 역할일 뿐 화면 표시용 카피가 아니므로, 사용자가 보는 제목 fallback 으로 직접 노출하지 않는 쪽이 맞다고 정리했다.
|
||||
- 같은 컴포넌트 안에서 라우트 파라미터만 바뀌는 화면은 일반 watch 보다 더 이른 시점에 로딩 상태를 세우는 편이 깜빡임 완화에 유리하다고 판단했다.
|
||||
|
||||
## 2026-04-07 v1.1.25
|
||||
- 이번 로딩 문제는 “매우 짧은 대기시간 + 미완성 실제 화면 노출”이 핵심이므로, 풀스크린 로딩 화면을 계속 번쩍 띄우는 것보다 `초기 인증 게이트 + 페이지 스켈레톤` 조합이 더 맞다고 정리했다.
|
||||
- 로그인 상태가 아직 hydrate 되지 않았을 때는 비로그인 UI를 잠깐 먼저 보여주지 않고, 최소 시간 있는 부트 게이트로 가려 두는 편이 완성도가 높다고 판단했다.
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
|
||||
## `/topics/:topicId`
|
||||
- 화면 파일: `frontend/src/views/TopicHubView.vue`
|
||||
- 역할: 선택한 주제 slug 기준 정보 표시, 관리자 추천 티어표 상단 강조 섹션과 일반 공개 티어표 목록 분리 표시, 왼쪽 공통 검색창으로 해당 주제의 공개 티어표만 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
|
||||
- 역할: 선택한 주제 slug 기준 정보 표시, 관리자 추천 티어표 상단 강조 섹션과 일반 공개 티어표 목록 분리 표시, 왼쪽 공통 검색창으로 해당 주제의 공개 티어표만 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입하며, 주제명은 데이터 준비 전 `주제 불러오는 중...` 같은 텍스트 대신 제목 영역 스켈레톤으로 대기
|
||||
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`
|
||||
|
||||
## `/editor/:topicId/new`, `/editor/:topicId/:tierListId`
|
||||
- 화면 파일: `frontend/src/views/TierEditorView.vue`
|
||||
- 역할: 주제 slug 기반 에디터 진입, 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 아이템 클릭 선택 후 셀/풀 재배치, 아이템 우클릭 메뉴 기반 복제본 생성, 상단 템플릿 제목 클릭 시 해당 주제 허브로 이동, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 읽기 전용 상태의 즐겨찾기 단독 CTA, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, 댓글 카드 표시, 데이터가 준비되기 전에는 편집 모드/프리뷰 모드 모두 전용 스켈레톤 레이아웃을 먼저 보여주고, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰와 하단 댓글 카드를 렌더링하며, 우측 뷰어 카드(`공유 티어표 보기`)는 스폰서 카드 바로 아래에서 유지하고 즐겨찾기 CTA도 함께 노출
|
||||
- 역할: 주제 slug 기반 에디터 진입, 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 아이템 클릭 선택 후 셀/풀 재배치, 아이템 우클릭 메뉴 기반 복제본 생성, 상단 템플릿 제목 클릭 시 해당 주제 허브로 이동, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 읽기 전용 상태의 즐겨찾기 단독 CTA, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, 댓글 카드 표시, 데이터가 준비되기 전에는 편집 모드/프리뷰 모드 모두 전용 스켈레톤 레이아웃을 먼저 보여주고, 저장용 랜덤 제목이나 내부 ID는 화면 표시용 제목으로 직접 노출하지 않으며, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰와 하단 댓글 카드를 렌더링하며, 우측 뷰어 카드(`공유 티어표 보기`)는 스폰서 카드 바로 아래에서 유지하고 즐겨찾기 CTA도 함께 노출
|
||||
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/:id`, `GET /api/tierlists/:id/comments`, `POST /api/tierlists/:id/comments`, `DELETE /api/tierlists/:id/comments/:commentId`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`, `POST /api/tierlists/template-request`
|
||||
|
||||
## `/comments`
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
- 팔로우 피드: 팔로우한 작성자의 공개 티어표
|
||||
- 즐겨찾기: 내가 즐겨찾기한 티어표
|
||||
- 위 네 화면의 목록 데이터는 현재 페이지네이션이나 무한 스크롤 없이 조회 결과 전체를 한 번에 렌더링한다.
|
||||
- 특정 주제 화면(`TopicHubView`)의 헤더는 데이터 준비 전 문자열 fallback 대신 제목/설명 스켈레톤만 보여준다.
|
||||
- 저장된 티어표에는 댓글 스레드가 붙을 수 있다. 작성자 본인 편집 화면에서는 `작업 팁` 아래, 작성자가 아닌 사용자의 보기 전용 화면에서는 `preview` 보드 아래에서 같은 댓글 카드를 사용한다.
|
||||
- 댓글 알림 메뉴는 좌측 사이드 `댓글 관리`로 노출하며, 읽지 않은 댓글이 하나라도 있으면 빨간 dot을 표시한다.
|
||||
- 댓글 관리(`/comments`)는 기본적으로 `안 읽은 댓글만 보기`를 켠 상태로 시작한다.
|
||||
@@ -95,6 +96,7 @@
|
||||
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 보드 영역은 메인 컬럼에, 우측 `320px` 편집 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
|
||||
- 공통 상단 토글 버튼은 Teleport로 이동한 로컬 편집 패널의 접힘/펼침 상태와도 연결되어, 우측 패널을 숨기면 중앙 보드 영역이 확장된다.
|
||||
- 편집 모드와 `preview=1` 뷰어 모드 모두 제목/보드/우측 패널 데이터가 준비되기 전까지는 실제 화면 대신 전용 스켈레톤 레이아웃을 먼저 보여준다.
|
||||
- 저장용 랜덤 제목은 내부 저장/도배 방지용으로만 쓰고, 화면 표시용 제목 fallback 에서는 내부 `tierListId`나 랜덤 문자열을 직접 노출하지 않는다.
|
||||
- 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다. 템플릿 등록/업데이트 요청 버튼은 저장된 티어표가 있을 때만 노출하며, 제목이 비어 있는 상태에서 저장하면 랜덤 고유 제목을 먼저 부여해 저장본을 만든다.
|
||||
- 상단 템플릿 제목은 해당 주제 허브로 이동하는 액션으로 사용하며, 미저장 변경이 있으면 이동 전에 확인 모달을 띄운다.
|
||||
- 보드 바로 옆에는 드래그용 아이템 풀을 별도 패널로 두고, 커스텀 아이템 이름 정리 목록은 우측 편집 패널 내부에서 관리한다.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 단기 확인
|
||||
- `v1.1.26` 이후 템플릿 카드 클릭 시 `주제 불러오는 중...` 문구가 더 이상 보이지 않고, 주제명 헤더 스켈레톤 뒤에 실제 이름이 자연스럽게 붙는지 확인한다.
|
||||
- `v1.1.26` 이후 템플릿 A에서 템플릿 B로 빠르게 연속 이동해도 이전 주제의 공개 티어표 카드가 잠깐 남지 않는지 확인한다.
|
||||
- `v1.1.26` 이후 저장 제목이 없는 티어표를 열 때 내부 ID나 자동 생성 문자열 대신 주제명 기반 표시 제목만 보이는지 확인한다.
|
||||
- `v1.1.25` 이후 첫 진입 시 로그인 상태가 비로그인처럼 잠깐 보였다가 바뀌는 깜빡임이 실제로 줄었는지, 느린 네트워크/빠른 네트워크 양쪽에서 확인한다.
|
||||
- `v1.1.25` 이후 티어표 화면 진입 시 제목 자리에 내부 ID가 먼저 노출되지 않고, 편집 모드/프리뷰 모드 둘 다 스켈레톤 뒤에 실제 데이터가 자연스럽게 붙는지 확인한다.
|
||||
- 초기 인증 게이트는 최소 140ms를 기다리므로, 초고속 응답에서도 오히려 부트 스켈레톤이 과하게 눈에 띄지 않는지 확인한다.
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-07 v1.1.26
|
||||
- 템플릿 주제 화면(`TopicHubView`)의 제목 fallback 문자열 `주제 불러오는 중...`을 제거했다. 이제 주제 데이터가 준비되기 전에는 실제 문구 대신 제목/설명 영역 스켈레톤을 보여준다.
|
||||
- 주제 전환 시 이전 템플릿의 공개 티어표 목록이 잠깐 남아 보이지 않도록, `topicName`, `featuredTierLists`, `tierLists`, `brokenThumbnailIds`를 먼저 비우고 다시 불러오게 정리했다.
|
||||
- 티어표 편집 화면은 저장용 랜덤 제목과 화면 표시용 제목을 분리했다. 이제 로딩 전후에 내부 `tierListId`나 자동 생성 문자열이 사용자 제목처럼 먼저 드러나지 않고, 표시용 제목은 `주제명 티어표` 또는 `제목 없는 티어표`로만 보여준다.
|
||||
- 편집 화면 라우트 감시 watch 는 `flush: 'sync'`로 조정해, 같은 컴포넌트 안에서 파라미터가 바뀔 때도 로딩 상태가 더 이른 시점에 반영되도록 보강했다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 2026-04-07 v1.1.25
|
||||
- 앱 최초 진입 시 로그인 상태가 잠깐 비로그인처럼 보였다가 다시 로그인 상태로 바뀌는 깜빡임을 줄이기 위해, 공통 앱 셸에 `bootGate` 초기 게이트를 추가했다. `App.vue`는 `auth.refresh()`와 최소 140ms 대기 시간을 함께 기다린 뒤 셸을 렌더링해, 아주 짧은 인증 복원에서도 화면이 번쩍 바뀌지 않게 한다.
|
||||
- `TierEditorView`는 제목/보드/우측 아이템 풀이 준비되기 전까지 실제 편집 화면 대신 같은 서비스 톤의 스켈레톤 레이아웃을 먼저 보여주도록 바꿨다. 이로써 티어표 진입 직후 내부 ID나 빈 보드가 먼저 드러나는 현상을 줄였다.
|
||||
|
||||
@@ -125,13 +125,19 @@ const effectiveAuthorName = computed(() => {
|
||||
return (authorAccountName.value || '').trim() || 'unknown'
|
||||
})
|
||||
const autoGeneratedTitle = ref(createAutoTierListTitle())
|
||||
const effectiveTitle = computed(() => {
|
||||
const saveTitle = computed(() => {
|
||||
const customTitle = (title.value || '').trim()
|
||||
if (customTitle) return customTitle
|
||||
if (persistedTierListId.value) return persistedTierListId.value
|
||||
if (tierListId.value && tierListId.value !== 'new') return tierListId.value
|
||||
return autoGeneratedTitle.value
|
||||
})
|
||||
const displayTitle = computed(() => {
|
||||
const customTitle = (title.value || '').trim()
|
||||
if (customTitle) return customTitle
|
||||
const topicName = (templateName.value || '').trim()
|
||||
if (topicName) return `${topicName} 티어표`
|
||||
if (isEditorLoading.value) return ''
|
||||
return '제목 없는 티어표'
|
||||
})
|
||||
const displayThumbnailUrl = computed(() => thumbnailPreviewUrl.value || (thumbnailSrc.value ? resolveItemSrc({ src: thumbnailSrc.value }) : ''))
|
||||
const untitledWarning = computed(
|
||||
() =>
|
||||
@@ -916,7 +922,7 @@ async function downloadImage() {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${effectiveTitle.value.trim()}.png`
|
||||
a.download = `${(displayTitle.value || 'tier-maker').trim()}.png`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
@@ -980,7 +986,7 @@ async function uploadPendingThumbnail() {
|
||||
}
|
||||
|
||||
function buildPayload(existingId) {
|
||||
const finalTitle = effectiveTitle.value
|
||||
const finalTitle = saveTitle.value
|
||||
return {
|
||||
id: existingId || undefined,
|
||||
topicId: templateId.value,
|
||||
@@ -1267,6 +1273,7 @@ function resetEditorStateForRoute() {
|
||||
groups.value = normalizeLoadedGroups([], columns.value)
|
||||
pool.value = []
|
||||
itemsById.value = {}
|
||||
templateName.value = ''
|
||||
title.value = ''
|
||||
persistedTierListId.value = ''
|
||||
thumbnailSrc.value = ''
|
||||
@@ -1416,7 +1423,7 @@ watch(
|
||||
() => {
|
||||
loadEditorState()
|
||||
},
|
||||
{ immediate: true }
|
||||
{ immediate: true, flush: 'sync' }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -20,7 +20,7 @@ const error = ref('')
|
||||
const brokenThumbnailIds = ref({})
|
||||
const isTopicLoading = ref(false)
|
||||
const isListView = computed(() => route.query.view === 'list')
|
||||
const topicTitle = computed(() => topicName.value || (isTopicLoading.value ? '주제 불러오는 중...' : ''))
|
||||
const topicTitle = computed(() => topicName.value || '')
|
||||
const publicTierLists = computed(() => tierLists.value.filter((tierList) => !tierList.isFeatured))
|
||||
|
||||
function fmt(ts) {
|
||||
@@ -88,6 +88,9 @@ watch(
|
||||
[topicId, query],
|
||||
() => {
|
||||
topicName.value = ''
|
||||
featuredTierLists.value = []
|
||||
tierLists.value = []
|
||||
brokenThumbnailIds.value = {}
|
||||
error.value = ''
|
||||
loadTierLists()
|
||||
},
|
||||
@@ -100,8 +103,16 @@ watch(
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Collection</div>
|
||||
<h2 class="pageHead__title">{{ topicTitle }}</h2>
|
||||
<div class="pageHead__desc">이 주제의 공개 티어표를 같은 카드 레이아웃으로 살펴보고 이어서 새 티어표를 만들 수 있어요.</div>
|
||||
<template v-if="isTopicLoading && !topicTitle">
|
||||
<div class="topicHeadSkeleton" aria-hidden="true">
|
||||
<div class="topicHeadSkeleton__line topicHeadSkeleton__line--title"></div>
|
||||
<div class="topicHeadSkeleton__line topicHeadSkeleton__line--desc"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h2 class="pageHead__title">{{ topicTitle }}</h2>
|
||||
<div class="pageHead__desc">이 주제의 공개 티어표를 같은 카드 레이아웃으로 살펴보고 이어서 새 티어표를 만들 수 있어요.</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -194,6 +205,39 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.topicHeadSkeleton {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.topicHeadSkeleton__line {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--theme-surface-soft-2) 88%, transparent);
|
||||
}
|
||||
.topicHeadSkeleton__line::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.08) 48%, transparent 100%);
|
||||
transform: translateX(-100%);
|
||||
animation: topicHeadSkeletonShimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
.topicHeadSkeleton__line--title {
|
||||
width: min(280px, 72%);
|
||||
height: 34px;
|
||||
}
|
||||
.topicHeadSkeleton__line--desc {
|
||||
width: min(560px, 94%);
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
@keyframes topicHeadSkeletonShimmer {
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
|
||||
Reference in New Issue
Block a user