Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2559605318 | |||
| 6f8e623b56 | |||
| 5dbc83c79e |
@@ -14,6 +14,8 @@
|
|||||||
- 정적 파일 URL 조합은 `toApiUrl()`로 처리한다.
|
- 정적 파일 URL 조합은 `toApiUrl()`로 처리한다.
|
||||||
- 화면 상태는 `ref`, `computed`, `onMounted` 중심의 단순한 Composition API 패턴을 유지한다.
|
- 화면 상태는 `ref`, `computed`, `onMounted` 중심의 단순한 Composition API 패턴을 유지한다.
|
||||||
- 설정/계정처럼 자주 바꾸지 않는 정보는 상시 입력 폼보다 `현재 상태 요약 + 필요 시 모달 편집` 흐름을 우선한다.
|
- 설정/계정처럼 자주 바꾸지 않는 정보는 상시 입력 폼보다 `현재 상태 요약 + 필요 시 모달 편집` 흐름을 우선한다.
|
||||||
|
- 모달이 배경 화면 위에 떠 있고 모달 내부 선택이 주 작업인 경우, 열림 상태 동안 `body` 스크롤 잠금을 함께 적용해 뒤쪽 화면 위치가 바뀌지 않게 한다.
|
||||||
|
- 정렬 가능한 카드 목록을 버튼으로도 재정렬할 때는 즉시 재렌더링으로 깜빡이게 두지 않고, `TransitionGroup` move 전환 등으로 위치 이동을 부드럽게 표현한다.
|
||||||
|
|
||||||
## 백엔드
|
## 백엔드
|
||||||
- 라우트 검증은 `zod`로 처리한다.
|
- 라우트 검증은 `zod`로 처리한다.
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-04-08 v1.1.30
|
||||||
|
- 관리자 목록관리의 상단 고정 템플릿 후보가 많아지면 모든 후보를 화면에 상시 렌더링하는 방식은 탐색성이 떨어지고 관리 영역을 불필요하게 길게 만든다고 판단했다. 따라서 고정 목록은 현재 선택된 항목만 보여주고, 추가는 검색 모달로 분리하는 쪽으로 정리했다.
|
||||||
|
- 모달이 열린 상태에서 배경 스크롤이 움직이면 선택 작업 중 문맥 위치가 바뀌어 관리자가 다시 위치를 찾아야 하므로, 이 유형의 모달은 배경 스크롤을 잠그는 것이 맞다고 정리했다.
|
||||||
|
- 드래그 정렬과 버튼 정렬은 같은 “순서 변경” 동작이므로, 버튼 이동도 즉시 깜빡이는 교체보다 카드가 부드럽게 이동하는 전환을 적용해 같은 감각으로 맞추기로 했다.
|
||||||
|
|
||||||
|
## 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
|
## 2026-04-07 v1.1.26
|
||||||
- `주제 불러오는 중...` 같은 임시 문구는 스켈레톤보다 더 거슬리게 보일 수 있으므로, 짧은 로딩 구간에서는 텍스트 fallback 대신 빈 자리 + 스켈레톤으로 처리하는 편이 낫다고 정리했다.
|
- `주제 불러오는 중...` 같은 임시 문구는 스켈레톤보다 더 거슬리게 보일 수 있으므로, 짧은 로딩 구간에서는 텍스트 fallback 대신 빈 자리 + 스켈레톤으로 처리하는 편이 낫다고 정리했다.
|
||||||
- 저장용 랜덤 제목이나 내부 `tierListId`는 시스템 식별자 역할일 뿐 화면 표시용 카피가 아니므로, 사용자가 보는 제목 fallback 으로 직접 노출하지 않는 쪽이 맞다고 정리했다.
|
- 저장용 랜덤 제목이나 내부 `tierListId`는 시스템 식별자 역할일 뿐 화면 표시용 카피가 아니므로, 사용자가 보는 제목 fallback 으로 직접 노출하지 않는 쪽이 맞다고 정리했다.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
## `/editor/:topicId/new`, `/editor/:topicId/:tierListId`
|
## `/editor/:topicId/new`, `/editor/:topicId/:tierListId`
|
||||||
- 화면 파일: `frontend/src/views/TierEditorView.vue`
|
- 화면 파일: `frontend/src/views/TierEditorView.vue`
|
||||||
- 역할: 주제 slug 기반 에디터 진입, 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 아이템 클릭 선택 후 셀/풀 재배치, 아이템 우클릭 메뉴 기반 복제본 생성, 상단 템플릿 제목 클릭 시 해당 주제 허브로 이동, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 읽기 전용 상태의 즐겨찾기 단독 CTA, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, 댓글 카드 표시, 데이터가 준비되기 전에는 편집 모드/프리뷰 모드 모두 전용 스켈레톤 레이아웃을 먼저 보여주고, 저장용 랜덤 제목이나 내부 ID는 화면 표시용 제목으로 직접 노출하지 않으며, `?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`
|
- 연동 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`
|
## `/comments`
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
|
|
||||||
## `/admin`
|
## `/admin`
|
||||||
- 화면 파일: `frontend/src/views/AdminView.vue`
|
- 화면 파일: `frontend/src/views/AdminView.vue`
|
||||||
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `템플릿 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 템플릿 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 템플릿 이름/slug 수정, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/받은 즐겨찾기 수 표시/인기순 정렬/최소 즐겨찾기 필터/추천 지정 토글/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원의 작성 티어표·팔로워·받은 즐겨찾기·최근 콘텐츠 활동·마지막 접속일 확인과 회원 정보·권한 수정 및 공개 프로필 보기, 파일 입력 초기화, 아이템 삭제, 템플릿 삭제
|
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `목록 관리 / 템플릿 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 홈 상단 고정 템플릿 순서, 선택된 템플릿 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 목록 관리에서는 고정 템플릿을 드래그 또는 위/아래 버튼 애니메이션으로 정렬하고 `템플릿 추가` 검색 모달에서 후보를 선택, 템플릿 이름/slug 수정, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/받은 즐겨찾기 수 표시/인기순 정렬/최소 즐겨찾기 필터/추천 지정 토글/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원의 작성 티어표·팔로워·받은 즐겨찾기·최근 콘텐츠 활동·마지막 접속일 확인과 회원 정보·권한 수정 및 공개 프로필 보기, 파일 입력 초기화, 아이템 삭제, 템플릿 삭제
|
||||||
- 연동 API: `GET /api/admin/templates`, `POST /api/admin/templates`, `POST /api/admin/templates/:templateId/thumbnail`, `POST /api/admin/templates/:templateId/images`, `PATCH /api/admin/templates/:templateId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `GET /api/admin/tierlists/stats`, `PATCH /api/admin/tierlists/:tierListId/featured`, `GET /api/admin/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/template-requests/:requestId/link-template`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/templates/:templateId/items/:itemId`, `DELETE /api/admin/templates/:templateId`
|
- 연동 API: `GET /api/admin/templates`, `POST /api/admin/templates`, `POST /api/admin/templates/:templateId/thumbnail`, `POST /api/admin/templates/:templateId/images`, `PATCH /api/admin/templates/:templateId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `GET /api/admin/tierlists/stats`, `PATCH /api/admin/tierlists/:tierListId/featured`, `GET /api/admin/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/template-requests/:requestId/link-template`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/templates/:templateId/items/:itemId`, `DELETE /api/admin/templates/:templateId`
|
||||||
|
|
||||||
## `/profile`
|
## `/profile`
|
||||||
|
|||||||
10
docs/spec.md
10
docs/spec.md
@@ -72,6 +72,7 @@
|
|||||||
- 즐겨찾기: 내가 즐겨찾기한 티어표
|
- 즐겨찾기: 내가 즐겨찾기한 티어표
|
||||||
- 위 네 화면의 목록 데이터는 현재 페이지네이션이나 무한 스크롤 없이 조회 결과 전체를 한 번에 렌더링한다.
|
- 위 네 화면의 목록 데이터는 현재 페이지네이션이나 무한 스크롤 없이 조회 결과 전체를 한 번에 렌더링한다.
|
||||||
- 특정 주제 화면(`TopicHubView`)의 헤더는 데이터 준비 전 문자열 fallback 대신 제목/설명 스켈레톤만 보여준다.
|
- 특정 주제 화면(`TopicHubView`)의 헤더는 데이터 준비 전 문자열 fallback 대신 제목/설명 스켈레톤만 보여준다.
|
||||||
|
- `freeform` 시작 액션과 주요 CTA 문구는 `커스텀 티어표`로 통일한다.
|
||||||
- 저장된 티어표에는 댓글 스레드가 붙을 수 있다. 작성자 본인 편집 화면에서는 `작업 팁` 아래, 작성자가 아닌 사용자의 보기 전용 화면에서는 `preview` 보드 아래에서 같은 댓글 카드를 사용한다.
|
- 저장된 티어표에는 댓글 스레드가 붙을 수 있다. 작성자 본인 편집 화면에서는 `작업 팁` 아래, 작성자가 아닌 사용자의 보기 전용 화면에서는 `preview` 보드 아래에서 같은 댓글 카드를 사용한다.
|
||||||
- 댓글 알림 메뉴는 좌측 사이드 `댓글 관리`로 노출하며, 읽지 않은 댓글이 하나라도 있으면 빨간 dot을 표시한다.
|
- 댓글 알림 메뉴는 좌측 사이드 `댓글 관리`로 노출하며, 읽지 않은 댓글이 하나라도 있으면 빨간 dot을 표시한다.
|
||||||
- 댓글 관리(`/comments`)는 기본적으로 `안 읽은 댓글만 보기`를 켠 상태로 시작한다.
|
- 댓글 관리(`/comments`)는 기본적으로 `안 읽은 댓글만 보기`를 켠 상태로 시작한다.
|
||||||
@@ -97,8 +98,11 @@
|
|||||||
- 공통 상단 토글 버튼은 Teleport로 이동한 로컬 편집 패널의 접힘/펼침 상태와도 연결되어, 우측 패널을 숨기면 중앙 보드 영역이 확장된다.
|
- 공통 상단 토글 버튼은 Teleport로 이동한 로컬 편집 패널의 접힘/펼침 상태와도 연결되어, 우측 패널을 숨기면 중앙 보드 영역이 확장된다.
|
||||||
- 편집 모드와 `preview=1` 뷰어 모드 모두 제목/보드/우측 패널 데이터가 준비되기 전까지는 실제 화면 대신 전용 스켈레톤 레이아웃을 먼저 보여준다.
|
- 편집 모드와 `preview=1` 뷰어 모드 모두 제목/보드/우측 패널 데이터가 준비되기 전까지는 실제 화면 대신 전용 스켈레톤 레이아웃을 먼저 보여준다.
|
||||||
- 저장용 랜덤 제목은 내부 저장/도배 방지용으로만 쓰고, 화면 표시용 제목 fallback 에서는 내부 `tierListId`나 랜덤 문자열을 직접 노출하지 않는다.
|
- 저장용 랜덤 제목은 내부 저장/도배 방지용으로만 쓰고, 화면 표시용 제목 fallback 에서는 내부 `tierListId`나 랜덤 문자열을 직접 노출하지 않는다.
|
||||||
|
- 헤더 문법은 공통 `pageHead` 흐름을 따르되, 편집 가능 상태에서는 `NEW/EDIT + 현재 템플릿 이름 + 작업 가이드`, 보기 전용 상태에서는 `템플릿 이름 + 티어표 제목 + 티어표 설명` 규칙을 사용한다.
|
||||||
|
- 보기 전용 헤더에는 별도 `Preview` 아이브로우를 두지 않고, 템플릿 이름 아이브로우만 남긴다.
|
||||||
|
- 드래그 초기화는 스켈레톤이 내려간 뒤 실제 보드/풀 DOM이 렌더링된 다음 `Sortable`을 붙이는 순서를 따른다.
|
||||||
- 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다. 템플릿 등록/업데이트 요청 버튼은 저장된 티어표가 있을 때만 노출하며, 제목이 비어 있는 상태에서 저장하면 랜덤 고유 제목을 먼저 부여해 저장본을 만든다.
|
- 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다. 템플릿 등록/업데이트 요청 버튼은 저장된 티어표가 있을 때만 노출하며, 제목이 비어 있는 상태에서 저장하면 랜덤 고유 제목을 먼저 부여해 저장본을 만든다.
|
||||||
- 상단 템플릿 제목은 해당 주제 허브로 이동하는 액션으로 사용하며, 미저장 변경이 있으면 이동 전에 확인 모달을 띄운다.
|
- 보기 전용 상태에서 상단 아이브로우(템플릿 이름)는 해당 주제 허브로 이동하는 액션으로 사용하며, 편집 중 미저장 변경이 있으면 이동 전에 확인 모달을 띄운다.
|
||||||
- 보드 바로 옆에는 드래그용 아이템 풀을 별도 패널로 두고, 커스텀 아이템 이름 정리 목록은 우측 편집 패널 내부에서 관리한다.
|
- 보드 바로 옆에는 드래그용 아이템 풀을 별도 패널로 두고, 커스텀 아이템 이름 정리 목록은 우측 편집 패널 내부에서 관리한다.
|
||||||
- 관리자 화면
|
- 관리자 화면
|
||||||
- 공통 우측 패널 대신 전용 로컬 운영 패널을 사용한다.
|
- 공통 우측 패널 대신 전용 로컬 운영 패널을 사용한다.
|
||||||
@@ -327,7 +331,9 @@
|
|||||||
- 기본 아이템 이름 저장 버튼은 값이 실제로 바뀐 경우에만 활성화된다.
|
- 기본 아이템 이름 저장 버튼은 값이 실제로 바뀐 경우에만 활성화된다.
|
||||||
- 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다.
|
- 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다.
|
||||||
- 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다.
|
- 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다.
|
||||||
- 게임 관리 탭에서는 홈 화면 상단에 먼저 노출할 게임을 최대 50개까지 지정하고, 드래그 또는 위/아래 버튼으로 순서를 저장할 수 있다.
|
- 목록 관리 탭에서는 홈 화면 상단에 먼저 노출할 템플릿을 최대 50개까지 지정한다. 새 고정 템플릿은 `템플릿 추가` 모달에서 이름/slug/ID로 검색해 선택하며, 이미 고정된 템플릿은 후보에서 제외한다.
|
||||||
|
- 상단 고정 템플릿 순서는 드래그 또는 위/아래 버튼으로 바꿀 수 있고, 버튼 이동도 카드 위치가 부드럽게 전환되는 애니메이션을 사용한다.
|
||||||
|
- 상단 고정 템플릿 추가 모달이 열려 있는 동안에는 배경 관리자 화면 스크롤을 잠가 모달 선택 흐름이 뒤쪽 화면 위치를 바꾸지 않도록 한다.
|
||||||
- 사용자 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
|
- 사용자 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
|
||||||
- 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다.
|
- 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다.
|
||||||
- 사용자 아이템은 사용 횟수(`usageCount`)를 표시하며, `미사용 아이템` 필터에서 저장 티어표/템플릿 참조가 더 이상 없는 항목만 개별/일괄 삭제할 수 있다. 사용자가 계정 탈퇴 등으로 삭제된 경우는 `custom_items.owner_id` 외래키로 레코드가 같이 사라지므로 보통 `미사용 아이템`으로 남지 않는다.
|
- 사용자 아이템은 사용 횟수(`usageCount`)를 표시하며, `미사용 아이템` 필터에서 저장 티어표/템플릿 참조가 더 이상 없는 항목만 개별/일괄 삭제할 수 있다. 사용자가 계정 탈퇴 등으로 삭제된 경우는 `custom_items.owner_id` 외래키로 레코드가 같이 사라지므로 보통 `미사용 아이템`으로 남지 않는다.
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
# 할 일 및 이슈
|
# 할 일 및 이슈
|
||||||
|
|
||||||
## 단기 확인
|
## 단기 확인
|
||||||
|
- `v1.1.30` 이후 관리자 목록관리에서 `템플릿 추가` 모달을 열고 검색·선택·닫기 흐름이 자연스러운지, 이미 고정된 템플릿이 추가 후보에서 제외되는지 확인한다.
|
||||||
|
- `v1.1.30` 이후 상단 고정 템플릿 추가 모달이 열린 동안 마우스 휠/트랙패드/모바일 터치로 뒤쪽 관리자 화면이 스크롤되지 않는지 확인한다.
|
||||||
|
- `v1.1.30` 이후 상단 고정 목록의 `위로 / 아래로` 버튼을 눌렀을 때 카드가 깜빡이지 않고 부드럽게 이동하며, 드래그 정렬과 저장 순서가 계속 일치하는지 확인한다.
|
||||||
|
- `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` 이후 템플릿 카드 클릭 시 `주제 불러오는 중...` 문구가 더 이상 보이지 않고, 주제명 헤더 스켈레톤 뒤에 실제 이름이 자연스럽게 붙는지 확인한다.
|
||||||
- `v1.1.26` 이후 템플릿 A에서 템플릿 B로 빠르게 연속 이동해도 이전 주제의 공개 티어표 카드가 잠깐 남지 않는지 확인한다.
|
- `v1.1.26` 이후 템플릿 A에서 템플릿 B로 빠르게 연속 이동해도 이전 주제의 공개 티어표 카드가 잠깐 남지 않는지 확인한다.
|
||||||
- `v1.1.26` 이후 저장 제목이 없는 티어표를 열 때 내부 ID나 자동 생성 문자열 대신 주제명 기반 표시 제목만 보이는지 확인한다.
|
- `v1.1.26` 이후 저장 제목이 없는 티어표를 열 때 내부 ID나 자동 생성 문자열 대신 주제명 기반 표시 제목만 보이는지 확인한다.
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-04-08 v1.1.30
|
||||||
|
- 관리자 목록관리의 홈 화면 상단 고정 템플릿 추가 방식을 전체 템플릿 목록 상시 노출에서 `템플릿 추가` 버튼 기반 검색 모달로 변경했다. 템플릿 이름, slug, ID로 검색한 뒤 선택한 항목만 고정 목록에 추가한다.
|
||||||
|
- 상단 고정 템플릿 추가 모달이 열려 있는 동안 배경 페이지가 휠/터치 스크롤로 움직이지 않도록 `body.modal-scroll-lock` 잠금 처리를 추가했다.
|
||||||
|
- 상단 고정 목록의 `위로 / 아래로` 버튼 순서 변경에 `TransitionGroup` move 애니메이션을 적용했다. 버튼으로 이동해도 카드가 즉시 깜빡이며 바뀌지 않고 드래그 정렬과 비슷하게 부드럽게 자리 이동한다.
|
||||||
|
- 확인: `npm run build`
|
||||||
|
|
||||||
|
## 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
|
## 2026-04-07 v1.1.26
|
||||||
- 템플릿 주제 화면(`TopicHubView`)의 제목 fallback 문자열 `주제 불러오는 중...`을 제거했다. 이제 주제 데이터가 준비되기 전에는 실제 문구 대신 제목/설명 영역 스켈레톤을 보여준다.
|
- 템플릿 주제 화면(`TopicHubView`)의 제목 fallback 문자열 `주제 불러오는 중...`을 제거했다. 이제 주제 데이터가 준비되기 전에는 실제 문구 대신 제목/설명 영역 스켈레톤을 보여준다.
|
||||||
- 주제 전환 시 이전 템플릿의 공개 티어표 목록이 잠깐 남아 보이지 않도록, `topicName`, `featuredTierLists`, `tierLists`, `brokenThumbnailIds`를 먼저 비우고 다시 불러오게 정리했다.
|
- 주제 전환 시 이전 템플릿의 공개 티어표 목록이 잠깐 남아 보이지 않도록, `topicName`, `featuredTierLists`, `tierLists`, `brokenThumbnailIds`를 먼저 비우고 다시 불러오게 정리했다.
|
||||||
|
|||||||
@@ -101,8 +101,9 @@ const guideSteps = [
|
|||||||
id: 'select-topic',
|
id: 'select-topic',
|
||||||
title: '주제 또는 양식 선택',
|
title: '주제 또는 양식 선택',
|
||||||
summary: '주제 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작합니다.',
|
summary: '주제 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작합니다.',
|
||||||
|
summary: '주제 템플릿을 고르거나 커스텀 티어표로 바로 시작합니다.',
|
||||||
description:
|
description:
|
||||||
'홈 화면에서는 주제 템플릿을 선택하거나 커스텀 티어표 만들기로 바로 새 보드를 열 수 있어요. 원하는 주제를 먼저 고르면 해당 주제의 공개 티어표도 같이 살펴볼 수 있어서, 완전히 처음 만드는지 기존 흐름을 참고할지 결정하기 쉽습니다.',
|
'홈 화면에서는 주제 템플릿을 선택하거나 커스텀 티어표로 바로 새 보드를 열 수 있어요. 원하는 주제를 먼저 고르면 해당 주제의 공개 티어표도 같이 살펴볼 수 있어서, 완전히 처음 만드는지 기존 흐름을 참고할지 결정하기 쉽습니다.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'arrange-board',
|
id: 'arrange-board',
|
||||||
@@ -174,10 +175,10 @@ const shouldLockRightRailBodyScroll = computed(() => isRightRailOverlay.value &&
|
|||||||
const leftBottomPrimaryAction = computed(() => {
|
const leftBottomPrimaryAction = computed(() => {
|
||||||
if (!authReady.value) return null
|
if (!authReady.value) return null
|
||||||
if (route.name === 'home' && auth.user) {
|
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) {
|
if (route.name === 'templates' && auth.user) {
|
||||||
return { label: '커스텀 티어표 만들기', to: editorNewPath('freeform'), iconSrc: iconDashboardCustomize }
|
return { label: '커스텀 티어표', to: editorNewPath('freeform'), iconSrc: iconDashboardCustomize }
|
||||||
}
|
}
|
||||||
if (route.name === 'topicHub') {
|
if (route.name === 'topicHub') {
|
||||||
const target = editorNewPath(currentTopicId.value)
|
const target = editorNewPath(currentTopicId.value)
|
||||||
@@ -192,7 +193,7 @@ const routeMeta = computed(() => {
|
|||||||
title: '홈',
|
title: '홈',
|
||||||
subtitle: '공개 티어표 피드',
|
subtitle: '공개 티어표 피드',
|
||||||
contextTitle: '빠른 시작',
|
contextTitle: '빠른 시작',
|
||||||
contextText: auth.user ? '추천 티어표와 최신 공개 티어표를 둘러보고 바로 새 작업을 시작할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
|
contextText: auth.user ? '추천 티어표와 최신 공개 티어표를 둘러보고 바로 새 작업을 시작할 수 있어요.' : '로그인하면 커스텀 티어표와 개인 목록 관리가 열립니다.',
|
||||||
actionLabel: '템플릿 보기',
|
actionLabel: '템플릿 보기',
|
||||||
action: () => {
|
action: () => {
|
||||||
router.push(templatesPath())
|
router.push(templatesPath())
|
||||||
@@ -204,8 +205,8 @@ const routeMeta = computed(() => {
|
|||||||
title: '템플릿',
|
title: '템플릿',
|
||||||
subtitle: '주제 템플릿 선택',
|
subtitle: '주제 템플릿 선택',
|
||||||
contextTitle: '빠른 시작',
|
contextTitle: '빠른 시작',
|
||||||
contextText: auth.user ? '주제 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
|
contextText: auth.user ? '주제 템플릿을 고르거나 커스텀 티어표로 바로 시작할 수 있어요.' : '로그인하면 커스텀 티어표와 개인 목록 관리가 열립니다.',
|
||||||
actionLabel: auth.user ? '커스텀 티어표 만들기' : '로그인하러 가기',
|
actionLabel: auth.user ? '커스텀 티어표' : '로그인하러 가기',
|
||||||
action: () => {
|
action: () => {
|
||||||
router.push(auth.user ? editorNewPath('freeform') : loginPath())
|
router.push(auth.user ? editorNewPath('freeform') : loginPath())
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
featuredTemplates: { type: Array, required: true },
|
featuredTemplates: { type: Array, required: true },
|
||||||
availableTemplatesForFeatured: { type: Array, required: true },
|
availableTemplatesForFeatured: { type: Array, required: true },
|
||||||
@@ -9,6 +11,44 @@ const props = defineProps({
|
|||||||
removeFeaturedTemplate: { type: Function, required: true },
|
removeFeaturedTemplate: { type: Function, required: true },
|
||||||
addFeaturedTemplate: { type: Function, required: true },
|
addFeaturedTemplate: { type: Function, required: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const featuredTemplateSearchQuery = ref('')
|
||||||
|
const featuredTemplatePickerOpen = ref(false)
|
||||||
|
|
||||||
|
const filteredAvailableTemplatesForFeatured = computed(() => {
|
||||||
|
const query = featuredTemplateSearchQuery.value.trim().toLowerCase()
|
||||||
|
if (!query) return props.availableTemplatesForFeatured
|
||||||
|
return props.availableTemplatesForFeatured.filter((template) => {
|
||||||
|
const haystack = `${template.name || ''} ${template.slug || ''} ${template.id || ''}`.toLowerCase()
|
||||||
|
return haystack.includes(query)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function openFeaturedTemplatePicker() {
|
||||||
|
featuredTemplateSearchQuery.value = ''
|
||||||
|
featuredTemplatePickerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFeaturedTemplatePicker() {
|
||||||
|
featuredTemplatePickerOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseFeaturedTemplate(templateId) {
|
||||||
|
props.addFeaturedTemplate(templateId)
|
||||||
|
closeFeaturedTemplatePicker()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFeaturedTransitionListRef(el) {
|
||||||
|
props.featuredListRef(el?.$el || el)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(featuredTemplatePickerOpen, (isOpen) => {
|
||||||
|
document.body.classList.toggle('modal-scroll-lock', isOpen)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.body.classList.remove('modal-scroll-lock')
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -18,14 +58,23 @@ const props = defineProps({
|
|||||||
<div class="panel__title">홈 화면 상단 고정 순서</div>
|
<div class="panel__title">홈 화면 상단 고정 순서</div>
|
||||||
<div class="hint hint--tight">여기에 넣은 템플릿은 지정한 순서대로 먼저 노출되고, 나머지 템플릿은 최근 생성순으로 뒤에 이어집니다. 최대 50개까지 설정할 수 있어요.</div>
|
<div class="hint hint--tight">여기에 넣은 템플릿은 지정한 순서대로 먼저 노출되고, 나머지 템플릿은 최근 생성순으로 뒤에 이어집니다. 최대 50개까지 설정할 수 있어요.</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn--primary" @click="props.saveFeaturedOrder">순서 저장</button>
|
<div class="featuredOrderPanel__headActions">
|
||||||
|
<button
|
||||||
|
class="btn btn--ghost"
|
||||||
|
:disabled="props.featuredTemplateIds.length >= 50 || !props.availableTemplatesForFeatured.length"
|
||||||
|
@click="openFeaturedTemplatePicker"
|
||||||
|
>
|
||||||
|
템플릿 추가
|
||||||
|
</button>
|
||||||
|
<button class="btn btn--primary" @click="props.saveFeaturedOrder">순서 저장</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="featuredOrderPanel">
|
<div class="featuredOrderPanel">
|
||||||
<div class="featuredOrderPanel__list">
|
<div class="featuredOrderPanel__list">
|
||||||
<div class="section__title">상단 고정 목록</div>
|
<div class="section__title">상단 고정 목록</div>
|
||||||
<div v-if="!props.featuredTemplates.length" class="hint">아직 상단 고정 템플릿이 없어요.</div>
|
<div v-if="!props.featuredTemplates.length" class="hint">아직 상단 고정 템플릿이 없어요.</div>
|
||||||
<div v-else :ref="props.featuredListRef" class="featuredList">
|
<TransitionGroup v-else :ref="setFeaturedTransitionListRef" tag="div" name="featured-list" class="featuredList">
|
||||||
<article v-for="(template, index) in props.featuredTemplates" :key="template.id" class="featuredCard" :data-featured-id="template.id">
|
<article v-for="(template, index) in props.featuredTemplates" :key="template.id" class="featuredCard" :data-featured-id="template.id">
|
||||||
<div class="featuredCard__meta">
|
<div class="featuredCard__meta">
|
||||||
<span class="featuredCard__rank">{{ index + 1 }}</span>
|
<span class="featuredCard__rank">{{ index + 1 }}</span>
|
||||||
@@ -41,22 +90,35 @@ const props = defineProps({
|
|||||||
<button class="btn btn--danger btn--small" @click="props.removeFeaturedTemplate(template.id)">제외</button>
|
<button class="btn btn--danger btn--small" @click="props.removeFeaturedTemplate(template.id)">제외</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</TransitionGroup>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="featuredOrderPanel__picker">
|
<div v-if="featuredTemplatePickerOpen" class="modalOverlay" @click.self="closeFeaturedTemplatePicker">
|
||||||
<div class="section__title">템플릿 추가</div>
|
<div class="modalCard" role="dialog" aria-modal="true">
|
||||||
<div class="featuredPickerList">
|
<div class="modalCard__titleRow">
|
||||||
|
<div>
|
||||||
|
<div class="modalCard__title">상단 고정 템플릿 추가</div>
|
||||||
|
<div class="modalCard__desc">템플릿 이름, slug, ID로 검색한 뒤 고정 목록에 추가할 항목을 선택하세요.</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--ghost btn--small" @click="closeFeaturedTemplatePicker">닫기</button>
|
||||||
|
</div>
|
||||||
|
<div class="modalCard__form modalCard__form--search">
|
||||||
|
<input v-model="featuredTemplateSearchQuery" class="input" placeholder="템플릿 이름 또는 slug 검색" autofocus />
|
||||||
|
<span class="hint hint--tight">{{ filteredAvailableTemplatesForFeatured.length }}개 결과</span>
|
||||||
|
</div>
|
||||||
|
<div class="templatePickerModalList">
|
||||||
<button
|
<button
|
||||||
v-for="template in props.availableTemplatesForFeatured"
|
v-for="template in filteredAvailableTemplatesForFeatured"
|
||||||
:key="template.id"
|
:key="template.id"
|
||||||
class="featuredPickerItem"
|
class="adminTemplatePicker__item"
|
||||||
:disabled="props.featuredTemplateIds.length >= 50"
|
type="button"
|
||||||
@click="props.addFeaturedTemplate(template.id)"
|
@click="chooseFeaturedTemplate(template.id)"
|
||||||
>
|
>
|
||||||
<span>{{ template.name }}</span>
|
<span class="adminTemplatePicker__name">{{ template.name }}</span>
|
||||||
<span class="featuredPickerItem__id">{{ template.slug || template.id }}</span>
|
<span class="adminTemplatePicker__meta">{{ template.slug || template.id }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<div v-if="!filteredAvailableTemplatesForFeatured.length" class="hint hint--tight">추가할 수 있는 템플릿이 없어요.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ export function useAdminFeaturedTemplates({
|
|||||||
const [moved] = nextIds.splice(currentIndex, 1)
|
const [moved] = nextIds.splice(currentIndex, 1)
|
||||||
nextIds.splice(nextIndex, 0, moved)
|
nextIds.splice(nextIndex, 0, moved)
|
||||||
featuredTemplateIds.value = nextIds
|
featuredTemplateIds.value = nextIds
|
||||||
syncFeaturedSortable()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveFeaturedOrder() {
|
async function saveFeaturedOrder() {
|
||||||
|
|||||||
@@ -3470,12 +3470,22 @@ function openUserProfile(user) {
|
|||||||
color: var(--theme-text-muted);
|
color: var(--theme-text-muted);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
body.modal-scroll-lock {
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
.adminUiScope .featuredOrderPanel {
|
.adminUiScope .featuredOrderPanel {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.35fr) minmax(260px, 0.95fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
.adminUiScope .featuredOrderPanel__headActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
.adminUiScope .featuredOrderPanel__list,
|
.adminUiScope .featuredOrderPanel__list,
|
||||||
.adminUiScope .featuredOrderPanel__picker {
|
.adminUiScope .featuredOrderPanel__picker {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
@@ -3485,6 +3495,7 @@ function openUserProfile(user) {
|
|||||||
}
|
}
|
||||||
.adminUiScope .featuredList,
|
.adminUiScope .featuredList,
|
||||||
.adminUiScope .featuredPickerList {
|
.adminUiScope .featuredPickerList {
|
||||||
|
position: relative;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -3501,6 +3512,20 @@ function openUserProfile(user) {
|
|||||||
border: 1px solid var(--theme-border);
|
border: 1px solid var(--theme-border);
|
||||||
background: var(--theme-pill-bg);
|
background: var(--theme-pill-bg);
|
||||||
}
|
}
|
||||||
|
.adminUiScope .featured-list-move,
|
||||||
|
.adminUiScope .featured-list-enter-active,
|
||||||
|
.adminUiScope .featured-list-leave-active {
|
||||||
|
transition: transform 180ms ease, opacity 180ms ease;
|
||||||
|
}
|
||||||
|
.adminUiScope .featured-list-enter-from,
|
||||||
|
.adminUiScope .featured-list-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
.adminUiScope .featured-list-leave-active {
|
||||||
|
position: absolute;
|
||||||
|
width: calc(100% - 32px);
|
||||||
|
}
|
||||||
.adminUiScope .featuredCard__meta {
|
.adminUiScope .featuredCard__meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|||||||
@@ -173,6 +173,17 @@ const currentUserId = computed(() => auth.user?.id || '')
|
|||||||
const canSubmitTemplateCreateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
|
const canSubmitTemplateCreateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
|
||||||
const canSubmitTemplateUpdateRequest = 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 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 shareTierListUrl = computed(() => {
|
||||||
const savedTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
|
const savedTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
|
||||||
if (!savedTierListId) return ''
|
if (!savedTierListId) return ''
|
||||||
@@ -1406,6 +1417,7 @@ async function loadEditorState() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isEditorLoading.value = false
|
||||||
await nextTick()
|
await nextTick()
|
||||||
if (loadToken !== editorLoadToken) return
|
if (loadToken !== editorLoadToken) return
|
||||||
|
|
||||||
@@ -1414,8 +1426,6 @@ async function loadEditorState() {
|
|||||||
if (canEdit.value) {
|
if (canEdit.value) {
|
||||||
await initSortables()
|
await initSortables()
|
||||||
}
|
}
|
||||||
if (loadToken !== editorLoadToken) return
|
|
||||||
isEditorLoading.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -1481,8 +1491,16 @@ onUnmounted(() => {
|
|||||||
<section v-else-if="previewMode" class="previewOnly" :style="{ '--thumb-size': `${iconSize}px` }">
|
<section v-else-if="previewMode" class="previewOnly" :style="{ '--thumb-size': `${iconSize}px` }">
|
||||||
<header class="pageHead">
|
<header class="pageHead">
|
||||||
<div class="pageHead__main">
|
<div class="pageHead__main">
|
||||||
<div class="pageHead__eyebrow">Preview</div>
|
<button
|
||||||
<h1 class="pageHead__title">{{ effectiveTitle }}</h1>
|
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>
|
<p v-if="description" class="pageHead__desc">{{ description }}</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -1709,24 +1727,10 @@ onUnmounted(() => {
|
|||||||
<section class="layout" :style="{ '--thumb-size': `${iconSize}px` }">
|
<section class="layout" :style="{ '--thumb-size': `${iconSize}px` }">
|
||||||
<div class="editorMain">
|
<div class="editorMain">
|
||||||
<section class="head">
|
<section class="head">
|
||||||
<div class="editorMain__headCopy">
|
<div class="pageHead__main">
|
||||||
<button
|
<div class="pageHead__eyebrow">{{ editorEyebrowLabel }}</div>
|
||||||
class="editorMain__title editorMain__titleButton"
|
<div class="pageHead__title">{{ editorHeaderTitle }}</div>
|
||||||
type="button"
|
<div class="pageHead__desc">{{ editorHeaderDescription }}</div>
|
||||||
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 v-if="sourceTierListId" class="editorMain__sourceNote">
|
<div v-if="sourceTierListId" class="editorMain__sourceNote">
|
||||||
<span>원본</span>
|
<span>원본</span>
|
||||||
<button class="editorMain__sourceLink" type="button" @click="openSourceTierList">{{ copiedFromLabel }}</button>
|
<button class="editorMain__sourceLink" type="button" @click="openSourceTierList">{{ copiedFromLabel }}</button>
|
||||||
@@ -1761,7 +1765,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div ref="exportBoardEl" class="exportBoard" :class="{ 'exportBoard--active': isExporting }">
|
<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="isExporting && description" class="exportBoard__description">{{ description }}</div>
|
||||||
<div v-if="columns.length > 1" class="boardColumnsHeader" :class="{ 'boardColumnsHeader--export': isExporting }">
|
<div v-if="columns.length > 1" class="boardColumnsHeader" :class="{ 'boardColumnsHeader--export': isExporting }">
|
||||||
<div class="boardColumnsHeader__spacer" aria-hidden="true"></div>
|
<div class="boardColumnsHeader__spacer" aria-hidden="true"></div>
|
||||||
@@ -2228,7 +2232,12 @@ onUnmounted(() => {
|
|||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
letter-spacing: -0.04em;
|
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;
|
width: fit-content;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -2237,20 +2246,17 @@ onUnmounted(() => {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: color 160ms ease, opacity 160ms ease;
|
||||||
}
|
}
|
||||||
.editorMain__titleButton:hover {
|
.pageHead__eyebrowButton:hover {
|
||||||
color: var(--theme-text-strong);
|
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: 2px solid color-mix(in srgb, var(--theme-accent) 70%, white);
|
||||||
outline-offset: 4px;
|
outline-offset: 4px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
.editorMain__subtitle {
|
|
||||||
color: var(--theme-text-soft);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.editorMain__sourceNote {
|
.editorMain__sourceNote {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user