Compare commits

..

3 Commits

Author SHA1 Message Date
6f8e623b56 드래그 초기화와 문구 정리 2026-04-07 16:54:14 +09:00
5dbc83c79e 에디터 헤더 규칙 정리 2026-04-07 16:46:28 +09:00
110242f8e9 로딩 문구와 제목 보정 2026-04-07 16:36:44 +09:00
8 changed files with 159 additions and 49 deletions

View File

@@ -1,5 +1,20 @@
# 의사결정 이력
## 2026-04-07 v1.1.29
- 프리뷰 여부는 페이지 맥락과 우측 뷰어 카드만으로 충분히 전달되므로, 헤더 아이브로우에 `Preview`를 한 줄 더 두는 것은 중복 정보라고 정리했다.
- `커스텀 티어표 만들기`는 버튼 문구로 다소 길고 반복될수록 무거워 보여, 주요 CTA 에서는 `커스텀 티어표`로 짧게 통일하는 편이 더 낫다고 판단했다.
- 스켈레톤과 드래그 라이브러리를 함께 쓸 때는 “실제 드래그 대상 DOM이 렌더링된 뒤에만 초기화”가 핵심이라고 다시 확인했다. 로딩 placeholder 상태에서 초기화한 `Sortable` 인스턴스는 비어 있는 타깃을 잡아 드래그 회귀를 만들 수 있다.
## 2026-04-07 v1.1.27
- 티어표 화면 헤더도 다른 주요 페이지와 같은 `eyebrow / title / desc` 문법을 따라야 전체 앱 톤이 정리된다고 판단했다.
- 편집 가능한 화면과 보기 전용 화면은 헤더에서 전달해야 할 정보가 다르므로, 같은 문법은 유지하되 내용 규칙은 분리하는 편이 맞다고 정리했다. 편집 화면은 “무엇을 만들고 있는가”, 보기 화면은 “무엇을 보고 있는가”가 더 중요하다.
- 템플릿 이동 액션은 제목보다 아이브로우가 더 자연스럽다. 제목은 티어표 자체 이름으로 남겨두고, 상위 맥락인 템플릿 이름만 링크 역할을 맡기는 쪽이 이해하기 쉽다고 정리했다.
## 2026-04-07 v1.1.26
- `주제 불러오는 중...` 같은 임시 문구는 스켈레톤보다 더 거슬리게 보일 수 있으므로, 짧은 로딩 구간에서는 텍스트 fallback 대신 빈 자리 + 스켈레톤으로 처리하는 편이 낫다고 정리했다.
- 저장용 랜덤 제목이나 내부 `tierListId`는 시스템 식별자 역할일 뿐 화면 표시용 카피가 아니므로, 사용자가 보는 제목 fallback 으로 직접 노출하지 않는 쪽이 맞다고 정리했다.
- 같은 컴포넌트 안에서 라우트 파라미터만 바뀌는 화면은 일반 watch 보다 더 이른 시점에 로딩 상태를 세우는 편이 깜빡임 완화에 유리하다고 판단했다.
## 2026-04-07 v1.1.25
- 이번 로딩 문제는 “매우 짧은 대기시간 + 미완성 실제 화면 노출”이 핵심이므로, 풀스크린 로딩 화면을 계속 번쩍 띄우는 것보다 `초기 인증 게이트 + 페이지 스켈레톤` 조합이 더 맞다고 정리했다.
- 로그인 상태가 아직 hydrate 되지 않았을 때는 비로그인 UI를 잠깐 먼저 보여주지 않고, 최소 시간 있는 부트 게이트로 가려 두는 편이 완성도가 높다고 판단했다.

View File

@@ -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 기반 에디터 진입, 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 아이템 클릭 선택 후 셀/풀 재배치, 아이템 우클릭 메뉴 기반 복제본 생성, 헤더는 공통 `pageHead` 문법을 따르며 편집 가능 상태에서는 `NEW/EDIT + 템플릿 이름 + 작업 가이드`, 보기 전용 상태에서는 `템플릿 이름 + 티어표 제목 + 설명` 구조를 사용하고 아이브로우 클릭 시 해당 주제 허브로 이동하며 별도 `Preview` 아이브로우는 두지 않음, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 읽기 전용 상태의 즐겨찾기 단독 CTA, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, 댓글 카드 표시, 데이터가 준비되기 전에는 편집 모드/프리뷰 모드 모두 전용 스켈레톤 레이아웃을 먼저 보여주고, 저장용 랜덤 제목이나 내부 ID는 화면 표시용 제목으로 직접 노출하지 않으며, 스켈레톤이 내려간 뒤 실제 보드/풀 DOM에서만 드래그 초기화를 실행하고, `?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`

View File

@@ -71,6 +71,8 @@
- 팔로우 피드: 팔로우한 작성자의 공개 티어표
- 즐겨찾기: 내가 즐겨찾기한 티어표
- 위 네 화면의 목록 데이터는 현재 페이지네이션이나 무한 스크롤 없이 조회 결과 전체를 한 번에 렌더링한다.
- 특정 주제 화면(`TopicHubView`)의 헤더는 데이터 준비 전 문자열 fallback 대신 제목/설명 스켈레톤만 보여준다.
- `freeform` 시작 액션과 주요 CTA 문구는 `커스텀 티어표`로 통일한다.
- 저장된 티어표에는 댓글 스레드가 붙을 수 있다. 작성자 본인 편집 화면에서는 `작업 팁` 아래, 작성자가 아닌 사용자의 보기 전용 화면에서는 `preview` 보드 아래에서 같은 댓글 카드를 사용한다.
- 댓글 알림 메뉴는 좌측 사이드 `댓글 관리`로 노출하며, 읽지 않은 댓글이 하나라도 있으면 빨간 dot을 표시한다.
- 댓글 관리(`/comments`)는 기본적으로 `안 읽은 댓글만 보기`를 켠 상태로 시작한다.
@@ -95,8 +97,12 @@
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 보드 영역은 메인 컬럼에, 우측 `320px` 편집 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
- 공통 상단 토글 버튼은 Teleport로 이동한 로컬 편집 패널의 접힘/펼침 상태와도 연결되어, 우측 패널을 숨기면 중앙 보드 영역이 확장된다.
- 편집 모드와 `preview=1` 뷰어 모드 모두 제목/보드/우측 패널 데이터가 준비되기 전까지는 실제 화면 대신 전용 스켈레톤 레이아웃을 먼저 보여준다.
- 저장용 랜덤 제목은 내부 저장/도배 방지용으로만 쓰고, 화면 표시용 제목 fallback 에서는 내부 `tierListId`나 랜덤 문자열을 직접 노출하지 않는다.
- 헤더 문법은 공통 `pageHead` 흐름을 따르되, 편집 가능 상태에서는 `NEW/EDIT + 현재 템플릿 이름 + 작업 가이드`, 보기 전용 상태에서는 `템플릿 이름 + 티어표 제목 + 티어표 설명` 규칙을 사용한다.
- 보기 전용 헤더에는 별도 `Preview` 아이브로우를 두지 않고, 템플릿 이름 아이브로우만 남긴다.
- 드래그 초기화는 스켈레톤이 내려간 뒤 실제 보드/풀 DOM이 렌더링된 다음 `Sortable`을 붙이는 순서를 따른다.
- 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다. 템플릿 등록/업데이트 요청 버튼은 저장된 티어표가 있을 때만 노출하며, 제목이 비어 있는 상태에서 저장하면 랜덤 고유 제목을 먼저 부여해 저장본을 만든다.
- 상단 템플릿 제목은 해당 주제 허브로 이동하는 액션으로 사용하며, 미저장 변경이 있으면 이동 전에 확인 모달을 띄운다.
- 보기 전용 상태에서 상단 아이브로우(템플릿 이름)는 해당 주제 허브로 이동하는 액션으로 사용하며, 편집 중 미저장 변경이 있으면 이동 전에 확인 모달을 띄운다.
- 보드 바로 옆에는 드래그용 아이템 풀을 별도 패널로 두고, 커스텀 아이템 이름 정리 목록은 우측 편집 패널 내부에서 관리한다.
- 관리자 화면
- 공통 우측 패널 대신 전용 로컬 운영 패널을 사용한다.

View File

@@ -1,6 +1,15 @@
# 할 일 및 이슈
## 단기 확인
- `v1.1.29` 이후 보기 전용 티어표 헤더에서 아이브로우가 템플릿 이름 한 줄만 남고, 더 이상 `Preview`와 중복되지 않는지 확인한다.
- `v1.1.29` 이후 좌측 빠른 시작 버튼과 템플릿 화면 액션의 `커스텀 티어표` 문구가 전체 화면에서 같은 톤으로 보이는지 확인한다.
- `v1.1.29` 이후 편집 화면에서 `풀 → 보드`, `보드 → 다른 칸`, `보드 → 풀` 드래그가 모두 다시 동작하는지 최우선으로 확인한다.
- `v1.1.27` 이후 편집 가능한 티어표 화면에서 헤더가 `NEW/EDIT → 템플릿 이름 → 작업 가이드` 순서로 자연스럽게 보이는지 확인한다.
- `v1.1.27` 이후 보기 전용 티어표 화면에서는 아이브로우만 템플릿 이동 링크로 동작하고, 제목은 더 이상 클릭 가능한 요소처럼 보이지 않는지 확인한다.
- `v1.1.27` 이후 freeform 티어표도 헤더 제목이 비지 않고 `커스텀 티어표`로 안정적으로 보이는지 확인한다.
- `v1.1.26` 이후 템플릿 카드 클릭 시 `주제 불러오는 중...` 문구가 더 이상 보이지 않고, 주제명 헤더 스켈레톤 뒤에 실제 이름이 자연스럽게 붙는지 확인한다.
- `v1.1.26` 이후 템플릿 A에서 템플릿 B로 빠르게 연속 이동해도 이전 주제의 공개 티어표 카드가 잠깐 남지 않는지 확인한다.
- `v1.1.26` 이후 저장 제목이 없는 티어표를 열 때 내부 ID나 자동 생성 문자열 대신 주제명 기반 표시 제목만 보이는지 확인한다.
- `v1.1.25` 이후 첫 진입 시 로그인 상태가 비로그인처럼 잠깐 보였다가 바뀌는 깜빡임이 실제로 줄었는지, 느린 네트워크/빠른 네트워크 양쪽에서 확인한다.
- `v1.1.25` 이후 티어표 화면 진입 시 제목 자리에 내부 ID가 먼저 노출되지 않고, 편집 모드/프리뷰 모드 둘 다 스켈레톤 뒤에 실제 데이터가 자연스럽게 붙는지 확인한다.
- 초기 인증 게이트는 최소 140ms를 기다리므로, 초고속 응답에서도 오히려 부트 스켈레톤이 과하게 눈에 띄지 않는지 확인한다.

View File

@@ -1,5 +1,27 @@
# 업데이트 로그
## 2026-04-07 v1.1.29
- 보기 전용 티어표 헤더에서 중복이던 `Preview` 아이브로우를 제거했다. 이제 프리뷰 화면 헤더는 템플릿 이름 아이브로우 한 줄만 남고, 그 아래 티어표 제목과 설명이 이어진다.
- 앱 전반의 `커스텀 티어표 만들기` 문구를 `커스텀 티어표`로 짧게 통일했다. 좌측 빠른 시작 버튼, 템플릿 화면 우측 액션, 가이드 설명 문구까지 같은 표현을 사용한다.
- 스켈레톤 로딩 도입 후 드래그가 동작하지 않던 회귀를 수정했다. 원인은 `isEditorLoading`이 아직 true인 상태에서 `Sortable`을 먼저 초기화해 실제 드래그 대상 DOM이 없던 것이었고, 이제는 스켈레톤을 먼저 내린 뒤 `nextTick()` 후 실제 에디터 DOM에서 `initSortables()`를 실행한다.
- 이 수정으로 클릭 이동은 유지하면서도 `풀 → 보드`, `보드 → 보드`, `보드 → 풀` 드래그가 다시 정상 동작한다.
- 확인: `npm run build`
## 2026-04-07 v1.1.27
- 티어표 편집 화면 헤더를 다른 주요 화면과 같은 `pageHead__main` 3단 문법으로 재구성했다. 이제 편집 가능한 화면도 `eyebrow / title / desc` 흐름을 그대로 따른다.
- 보기 전용 티어표 화면은 `템플릿 이름 / 티어표 제목 / 티어표 설명` 규칙으로 정리했다. 기존처럼 제목 클릭으로 템플릿 화면으로 이동하지 않고, 아이브로우(템플릿 이름)만 클릭 이동 대상으로 바꾸고 hover/focus 스타일도 함께 넣었다.
- 편집 가능한 티어표 화면은 제목/설명이 우측 패널에 따로 있는 점을 반영해 `NEW 또는 EDIT / 현재 템플릿 이름 / 작업 가이드 문구` 구조로 정리했다.
- 커스텀 티어표(freeform)는 템플릿 이름이 비어도 `커스텀 티어표`라는 편집 헤더 제목이 보이도록 보정했다.
- 내보내기용 제목도 사용자 표시용 제목 기준으로 맞춰, 제목이 비어 있는 티어표에서 내부 식별자나 랜덤 문자열이 직접 노출되지 않게 정리했다.
- 확인: `npm run build`
## 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나 빈 보드가 먼저 드러나는 현상을 줄였다.

View File

@@ -101,8 +101,9 @@ const guideSteps = [
id: 'select-topic',
title: '주제 또는 양식 선택',
summary: '주제 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작합니다.',
summary: '주제 템플릿을 고르거나 커스텀 티어표로 바로 시작합니다.',
description:
'홈 화면에서는 주제 템플릿을 선택하거나 커스텀 티어표 만들기로 바로 새 보드를 열 수 있어요. 원하는 주제를 먼저 고르면 해당 주제의 공개 티어표도 같이 살펴볼 수 있어서, 완전히 처음 만드는지 기존 흐름을 참고할지 결정하기 쉽습니다.',
'홈 화면에서는 주제 템플릿을 선택하거나 커스텀 티어표로 바로 새 보드를 열 수 있어요. 원하는 주제를 먼저 고르면 해당 주제의 공개 티어표도 같이 살펴볼 수 있어서, 완전히 처음 만드는지 기존 흐름을 참고할지 결정하기 쉽습니다.',
},
{
id: 'arrange-board',
@@ -174,10 +175,10 @@ const shouldLockRightRailBodyScroll = computed(() => isRightRailOverlay.value &&
const leftBottomPrimaryAction = computed(() => {
if (!authReady.value) return null
if (route.name === 'home' && auth.user) {
return { label: '커스텀 티어표 만들기', to: editorNewPath('freeform'), iconSrc: iconDashboardCustomize }
return { label: '커스텀 티어표', to: editorNewPath('freeform'), iconSrc: iconDashboardCustomize }
}
if (route.name === 'templates' && auth.user) {
return { label: '커스텀 티어표 만들기', to: editorNewPath('freeform'), iconSrc: iconDashboardCustomize }
return { label: '커스텀 티어표', to: editorNewPath('freeform'), iconSrc: iconDashboardCustomize }
}
if (route.name === 'topicHub') {
const target = editorNewPath(currentTopicId.value)
@@ -192,7 +193,7 @@ const routeMeta = computed(() => {
title: '홈',
subtitle: '공개 티어표 피드',
contextTitle: '빠른 시작',
contextText: auth.user ? '추천 티어표와 최신 공개 티어표를 둘러보고 바로 새 작업을 시작할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
contextText: auth.user ? '추천 티어표와 최신 공개 티어표를 둘러보고 바로 새 작업을 시작할 수 있어요.' : '로그인하면 커스텀 티어표 개인 목록 관리가 열립니다.',
actionLabel: '템플릿 보기',
action: () => {
router.push(templatesPath())
@@ -204,8 +205,8 @@ const routeMeta = computed(() => {
title: '템플릿',
subtitle: '주제 템플릿 선택',
contextTitle: '빠른 시작',
contextText: auth.user ? '주제 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
actionLabel: auth.user ? '커스텀 티어표 만들기' : '로그인하러 가기',
contextText: auth.user ? '주제 템플릿을 고르거나 커스텀 티어표로 바로 시작할 수 있어요.' : '로그인하면 커스텀 티어표 개인 목록 관리가 열립니다.',
actionLabel: auth.user ? '커스텀 티어표' : '로그인하러 가기',
action: () => {
router.push(auth.user ? editorNewPath('freeform') : loginPath())
},

View File

@@ -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(
() =>
@@ -167,6 +173,17 @@ const currentUserId = computed(() => auth.user?.id || '')
const canSubmitTemplateCreateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
const canSubmitTemplateUpdateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
const templateRequestTargetLabel = computed(() => (templateId.value === 'freeform' ? '새로운 템플릿' : (templateName.value || templateId.value || '선택한 주제')))
const editorEyebrowLabel = computed(() => (isNewTierList.value ? 'NEW' : 'EDIT'))
const editorHeaderTitle = computed(() => {
const currentTemplateName = (templateName.value || '').trim()
if (currentTemplateName) return currentTemplateName
return templateId.value === 'freeform' ? '커스텀 티어표' : '티어표 만들기'
})
const editorHeaderDescription = computed(() =>
canEdit.value
? '행/열 이름과 순서를 바꾸고 아이템을 드래그해서 배치할 수 있어요.'
: '공개된 티어표를 보는 중입니다. 로그인한 작성자만 수정할 수 있어요.'
)
const shareTierListUrl = computed(() => {
const savedTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
if (!savedTierListId) return ''
@@ -916,7 +933,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 +997,7 @@ async function uploadPendingThumbnail() {
}
function buildPayload(existingId) {
const finalTitle = effectiveTitle.value
const finalTitle = saveTitle.value
return {
id: existingId || undefined,
topicId: templateId.value,
@@ -1267,6 +1284,7 @@ function resetEditorStateForRoute() {
groups.value = normalizeLoadedGroups([], columns.value)
pool.value = []
itemsById.value = {}
templateName.value = ''
title.value = ''
persistedTierListId.value = ''
thumbnailSrc.value = ''
@@ -1399,6 +1417,7 @@ async function loadEditorState() {
}
}
isEditorLoading.value = false
await nextTick()
if (loadToken !== editorLoadToken) return
@@ -1407,8 +1426,6 @@ async function loadEditorState() {
if (canEdit.value) {
await initSortables()
}
if (loadToken !== editorLoadToken) return
isEditorLoading.value = false
}
watch(
@@ -1416,7 +1433,7 @@ watch(
() => {
loadEditorState()
},
{ immediate: true }
{ immediate: true, flush: 'sync' }
)
onMounted(() => {
@@ -1474,8 +1491,16 @@ onUnmounted(() => {
<section v-else-if="previewMode" class="previewOnly" :style="{ '--thumb-size': `${iconSize}px` }">
<header class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Preview</div>
<h1 class="pageHead__title">{{ effectiveTitle }}</h1>
<button
class="pageHead__eyebrow pageHead__eyebrowButton"
type="button"
title="이 템플릿 화면으로 이동"
@click="openTemplateTopic"
@keydown.space.prevent="openTemplateTopic"
>
{{ templateName || templateId }}
</button>
<h1 class="pageHead__title">{{ displayTitle }}</h1>
<p v-if="description" class="pageHead__desc">{{ description }}</p>
</div>
</header>
@@ -1702,24 +1727,10 @@ onUnmounted(() => {
<section class="layout" :style="{ '--thumb-size': `${iconSize}px` }">
<div class="editorMain">
<section class="head">
<div class="editorMain__headCopy">
<button
class="editorMain__title editorMain__titleButton"
type="button"
title="이 템플릿 화면으로 이동"
@click="openTemplateTopic"
@keydown.space.prevent="openTemplateTopic"
>
{{ templateName || templateId }}
</button>
<div class="editorMain__subtitle">
<template v-if="canEdit">
/ 이름과 순서를 바꾸고 아이템을 드래그해서 배치할 있어요.
</template>
<template v-else>
공개된 티어표를 보는 중입니다. 로그인한 작성자만 수정할 있어요.
</template>
</div>
<div class="pageHead__main">
<div class="pageHead__eyebrow">{{ editorEyebrowLabel }}</div>
<div class="pageHead__title">{{ editorHeaderTitle }}</div>
<div class="pageHead__desc">{{ editorHeaderDescription }}</div>
<div v-if="sourceTierListId" class="editorMain__sourceNote">
<span>원본</span>
<button class="editorMain__sourceLink" type="button" @click="openSourceTierList">{{ copiedFromLabel }}</button>
@@ -1754,7 +1765,7 @@ onUnmounted(() => {
</div>
</div>
<div ref="exportBoardEl" class="exportBoard" :class="{ 'exportBoard--active': isExporting }">
<div v-if="isExporting" class="exportBoard__title">{{ effectiveTitle }}</div>
<div v-if="isExporting" class="exportBoard__title">{{ displayTitle }}</div>
<div v-if="isExporting && description" class="exportBoard__description">{{ description }}</div>
<div v-if="columns.length > 1" class="boardColumnsHeader" :class="{ 'boardColumnsHeader--export': isExporting }">
<div class="boardColumnsHeader__spacer" aria-hidden="true"></div>
@@ -2221,7 +2232,12 @@ onUnmounted(() => {
font-weight: 900;
letter-spacing: -0.04em;
}
.editorMain__titleButton {
.editorMain__subtitle {
color: var(--theme-text-soft);
font-size: 13px;
line-height: 1.5;
}
.pageHead__eyebrowButton {
width: fit-content;
max-width: 100%;
border: 0;
@@ -2230,20 +2246,17 @@ onUnmounted(() => {
color: inherit;
text-align: left;
cursor: pointer;
transition: color 160ms ease, opacity 160ms ease;
}
.editorMain__titleButton:hover {
.pageHead__eyebrowButton:hover {
color: var(--theme-text-strong);
opacity: 1;
}
.editorMain__titleButton:focus-visible {
.pageHead__eyebrowButton:focus-visible {
outline: 2px solid color-mix(in srgb, var(--theme-accent) 70%, white);
outline-offset: 4px;
border-radius: 8px;
}
.editorMain__subtitle {
color: var(--theme-text-soft);
font-size: 13px;
line-height: 1.5;
}
.editorMain__sourceNote {
margin-top: 4px;
display: inline-flex;

View File

@@ -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;