Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fcf4228bff | |||
| 04e9a0420a | |||
| bf726b6161 | |||
| 73a269d61d | |||
| 713b07a1de |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,6 +8,7 @@ backend/.sessions/
|
||||
backend/uploads/avatars/
|
||||
backend/uploads/games/
|
||||
backend/uploads/custom/
|
||||
backend/uploads/assets/
|
||||
|
||||
.DS_Store
|
||||
.env.production
|
||||
|
||||
@@ -54,8 +54,6 @@ const {
|
||||
} = require('../db')
|
||||
const { requireAdmin } = require('../middleware/auth')
|
||||
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
|
||||
const { isReservedNickname } = require('../lib/user-validation')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
function getTemplateIdFromParams(req) {
|
||||
@@ -91,7 +89,7 @@ function buildItemLabelFromSrc(src) {
|
||||
return normalized || 'item'
|
||||
}
|
||||
|
||||
const upload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024, maxCount: 50 })
|
||||
const upload = createMemoryUpload(multer, { fileSize: 20 * 1024 * 1024, maxCount: 100 })
|
||||
const avatarUpload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 })
|
||||
|
||||
function decorateAdminUser(user, primaryAdmin) {
|
||||
@@ -201,7 +199,7 @@ router.post('/templates/:templateId/thumbnail', requireAdmin, upload.single('thu
|
||||
res.json({ template: updated })
|
||||
})
|
||||
|
||||
router.post('/templates/:templateId/images', requireAdmin, upload.array('images', 50), async (req, res) => {
|
||||
router.post('/templates/:templateId/images', requireAdmin, upload.array('images', 100), async (req, res) => {
|
||||
const files = Array.isArray(req.files) ? req.files : []
|
||||
if (!files.length) return res.status(400).json({ error: 'file_required' })
|
||||
const templateId = getTemplateIdFromParams(req)
|
||||
@@ -994,9 +992,6 @@ router.patch('/users/:userId', requireAdmin, async (req, res) => {
|
||||
return res.status(403).json({ error: 'primary_admin_only' })
|
||||
}
|
||||
|
||||
if (isReservedNickname(parsed.data.nickname)) {
|
||||
return res.status(400).json({ error: 'nickname_reserved' })
|
||||
}
|
||||
const duplicateEmail = await findUserByEmail(parsed.data.email)
|
||||
if (duplicateEmail && duplicateEmail.id !== targetUser.id) {
|
||||
return res.status(409).json({ error: 'email_taken' })
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-04-03 v1.4.43
|
||||
- Vue Router에서 같은 컴포넌트가 유지된 채 `params/query`만 바뀌는 에디터 이동은 `onMounted()`만으로는 새 데이터를 다시 불러오지 못할 수 있으므로, 에디터 로딩을 라우트 값 watch 기반으로 옮기는 편이 맞다고 판단했다.
|
||||
- 복사본에서 원본으로 이동하는 액션은 사용자가 편집 중이던 내용을 잃을 수 있으므로, 저장하지 않은 변경이 감지되는 경우에는 바로 이동하지 않고 확인 모달로 한 번 끊어주는 쪽이 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.42
|
||||
- 홈 템플릿 목록은 관리자가 아직 수동 순서를 건드리지 않은 신규 템플릿까지 이름순으로 섞이면 “새로 만든 항목이 앞에 보인다”는 운영 기대와 어긋나므로, 수동 순서가 없는 항목은 최신 생성순을 우선하는 정렬이 맞다고 판단했다.
|
||||
- 티어표 편집 조작은 드래그만으로도 충분하지만, 세밀한 이동이나 터치패드 환경에서는 클릭 선택 후 대상 셀 클릭 방식이 더 편할 수 있으므로 두 조작을 병행 지원하는 쪽으로 확장했다.
|
||||
- 다만 드래그 직후 click 이벤트가 이어서 들어오면 의도치 않은 재선택이 생길 수 있으므로, 드래그 시작 시 선택을 비우고 드래그 종료 직후 짧은 클릭 잠금을 두는 방식으로 충돌을 줄였다.
|
||||
|
||||
## 2026-04-03 v1.4.41
|
||||
- 관리자 기본 아이템 업로드는 운영자가 한 번에 많은 캐릭터 이미지를 정리하는 작업이 잦으므로, 서버 개별 파일 제한뿐 아니라 한 요청당 업로드 개수와 프록시 본문 크기 제한도 같이 넉넉하게 올려두는 편이 맞다고 판단했다.
|
||||
- 다중 업로드가 프런트에서 한 번의 `FormData` 요청으로 묶여 나가는 구조라면, 백엔드 `multer`만 올리고 Nginx `client_max_body_size`를 그대로 두면 병목이 남을 수 있으므로 프런트 프록시 제한도 함께 상향하는 쪽으로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.40
|
||||
- 공유 링크 진입 화면은 사용자가 조작 가능한 편집 화면보다 `뷰어 모드`로 명확히 분리하는 편이 안전하므로, 비로그인 사용자와 타인 티어표는 기본적으로 드래그 없는 완성본 열람 상태를 보여주기로 정리했다.
|
||||
- 공유 링크를 받은 비로그인 사용자도 다시 전달할 수 있어야 하므로 `공유하기`는 로그인 여부와 무관하게 뷰어 모드에서 열어두고, `내 티어표로 복사`는 로그인한 타인 열람자에게만 노출하는 쪽으로 권한을 나눴다.
|
||||
- 작성자 본인도 공유 화면이 어떻게 보이는지 확인할 필요가 있으므로, 본인 티어표는 `수정 모드`와 `뷰어 모드`를 양방향 전환할 수 있게 두는 편이 자연스럽다고 판단했다.
|
||||
- 뷰어 모드 우측 레일은 빈 공간을 크게 남기기보다 다른 화면처럼 광고를 상단에 두고, 실제 행동 버튼은 하단 카드로 모아 시각 균형과 전환 흐름을 함께 맞추는 방향으로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.39
|
||||
- 기존 저장 티어표를 다시 열 때마다 최신 템플릿 아이템으로 전체를 덮어쓰면 과거 결과물이 깨질 수 있으므로, 저장본의 기존 그룹/풀은 보존하고 새로 추가된 템플릿 아이템만 미사용 풀에 합류시키는 병합 방식이 더 안전하다고 판단했다.
|
||||
- 관리자 템플릿에서 삭제된 기본 아이템을 과거 저장본에서도 자동 삭제하면 사용자 결과물 보존성이 떨어지므로, 삭제는 이후 새 티어표 생성에만 반영하고 기존 저장본은 유지하는 정책을 계속 유지하기로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.38
|
||||
- 최고 관리자 보호는 서버에서만 막고 프런트 버튼은 열어두면 운영자가 계속 실패 액션을 누르게 되므로, 최고 관리자 대상 행은 일반 운영자 화면에서 버튼 자체를 비활성화하는 편이 맞다고 판단했다.
|
||||
- 예약어 닉네임은 일반 가입/프로필 수정에서는 계속 막아야 하지만, 운영자가 직접 회원 계정을 정리할 때는 `공식`, `운영자` 같은 표기를 의도적으로 부여해야 할 수 있으므로 관리자 수정 API만 예외를 두는 쪽으로 정리했다.
|
||||
- 공유 프리뷰는 별도 단독 페이지처럼 보이는 것보다 홈과 같은 좌측/우측 레일, 같은 중앙 헤더, 같은 메인 배경 위에 자연스럽게 얹히는 편이 서비스 유입과 탐색 전환에 더 유리하다고 판단해, 프리뷰 전용 셸을 공통 앱 셸로 흡수했다.
|
||||
|
||||
## 2026-04-02 v1.4.33
|
||||
- 서비스 공개 전 단계에서는 가입 자체를 열어두는 것보다, 이메일/닉네임 중복과 운영자 사칭성 닉네임을 먼저 막아두는 편이 훨씬 중요하다고 판단했다.
|
||||
- 닉네임 제한은 회원가입 한 곳에만 두면 이후 프로필 수정이나 관리자 수정으로 쉽게 우회되므로, auth/profile/admin 수정 흐름 전부가 같은 예약어 정책을 공유하도록 정리했다.
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
- 연동 API: `GET /api/topics`
|
||||
|
||||
## `/topics/:topicId`
|
||||
- 화면 파일: `frontend/src/views/GameHubView.vue`
|
||||
- 화면 파일: `frontend/src/views/TopicHubView.vue`
|
||||
- 역할: 선택한 주제 정보 표시, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 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`
|
||||
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청
|
||||
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰 렌더링
|
||||
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/:id`, `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`
|
||||
|
||||
## `/login`
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
## 공통 레이아웃
|
||||
- 앱 셸 파일: `frontend/src/App.vue`
|
||||
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
|
||||
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, `preview=1` 공유 프리뷰에서도 같은 좌우 레일과 중앙 헤더 유지, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
|
||||
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker`와 서비스 설명이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
|
||||
|
||||
## 백엔드 진입점
|
||||
|
||||
27
docs/spec.md
27
docs/spec.md
@@ -9,7 +9,7 @@
|
||||
- 운영 배포: `frontend(Nginx 정적 서빙 + /api,/uploads 프록시) + backend + mariadb` Docker Compose 구조
|
||||
- NAS HTTPS 리버스 프록시 운영 시 프런트 Nginx는 백엔드로 `X-Forwarded-Proto: https`를 전달하고, Express 세션은 프록시 환경에서 `secure` 쿠키를 허용하도록 설정한다.
|
||||
- 프런트 파비콘은 운영 정적 파일 차단 영향을 줄이기 위해 `index.html`의 인라인 데이터 URL로 제공한다.
|
||||
- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, preview 모드에서는 이 셸을 숨기고 콘텐츠만 렌더링한다.
|
||||
- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, `preview=1` 모드에서도 같은 셸을 유지한 채 중앙 본문만 완성본 프리뷰로 렌더링한다.
|
||||
- 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 우측 패널은 상단 토글 버튼으로 접고 펼칠 수 있다.
|
||||
- 좌측 패널은 필요 시 축소형 레일로 접을 수 있으며, 접힌 상태에서는 아이콘 중심 내비게이션과 축약된 바로가기만 유지한다.
|
||||
- 이 3단 셸 구조는 홈, 게임 허브, 에디터, 관리자 등 일반 페이지 전반의 공통 뼈대로 유지하고, 페이지별 차이는 중앙/우측에 어떤 콘텐츠를 넣는지만 달라지도록 관리한다.
|
||||
@@ -45,6 +45,7 @@
|
||||
- 좌우 레일의 주요 CTA는 스크롤되는 본문과 분리된 하단 `56px` 액션 영역에 배치한다.
|
||||
- 하단 액션은 화면 바닥에 바로 붙지 않도록 푸터 내부에 추가 하단 여백을 둔다.
|
||||
- 홈 화면 기준 우측 패널은 임시 정보 카드 여러 개보다 핵심 CTA 하나만 남겨, 시안처럼 단순한 보조 레일 역할을 우선 유지한다.
|
||||
- 광고 영역은 상단 헤더와 시각적으로 너무 붙지 않도록 `78px` 상단 여백을 두고, 하단 카피라이트는 중앙 정렬된 공통 footer로 표시한다.
|
||||
- 티어표 편집 화면
|
||||
- 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다.
|
||||
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 보드 영역은 메인 컬럼에, 우측 `320px` 편집 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
|
||||
@@ -54,7 +55,7 @@
|
||||
- 관리자 화면
|
||||
- 공통 우측 패널 대신 전용 로컬 운영 패널을 사용한다.
|
||||
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 관리 목록은 메인 컬럼에, 우측 운영 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
|
||||
- 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다. 템플릿 요청 카드는 전체 티어표 카드와 같은 썸네일 좌측/정보 우측 구조를 따르며, 요청 미리보기는 일반 티어표 완성본과 같은 행·열 보드 문법으로 검수한다.
|
||||
- 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다. 템플릿 요청 카드는 전체 티어표 카드와 같은 썸네일 좌측/정보 우측 구조를 따르며, 요청 미리보기와 `preview=1` 공유 프리뷰는 공통 앱 셸 안에서 일반 티어표 완성본과 같은 행·열 보드 문법으로 검수한다.
|
||||
- 상단 헤더에는 현재 탭 기준 요약 통계 카드를 배치해 운영 상태를 먼저 읽고, 각 관리 카드는 공통 대시보드 카드 문법(두꺼운 반경, 얕은 레이어 배경, 강조된 액션 버튼)을 공유한다.
|
||||
|
||||
## DB 스키마
|
||||
@@ -134,7 +135,7 @@
|
||||
- `POST /api/admin/templates`
|
||||
- `POST /api/admin/templates/:templateId/thumbnail`
|
||||
- `POST /api/admin/templates/:templateId/images`
|
||||
- 여러 이미지를 한 번에 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다.
|
||||
- 여러 이미지를 한 번에 최대 `100개`까지 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다.
|
||||
- `PATCH /api/admin/templates/:templateId/items/:itemId`
|
||||
- `GET /api/admin/tierlists`
|
||||
- `GET /api/admin/template-requests`
|
||||
@@ -172,11 +173,19 @@
|
||||
- 관리자 티어표 관리 상단에는 사용자가 보낸 템플릿 등록/업데이트 요청 목록이 별도로 표시되며, 여기서 승인/반려를 바로 처리할 수 있다.
|
||||
- 관리자 템플릿 요청 목록에서 `반려 후 숨김`을 누르면 해당 요청은 pending 목록에서 즉시 제외된다.
|
||||
- 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다.
|
||||
- 단, 일반 운영자는 최고 관리자 계정의 프로필 이미지/회원 정보/비밀번호/삭제 버튼을 사용할 수 없고, 최고 관리자만 다른 관리자 권한을 변경할 수 있다.
|
||||
- 관리자 회원 정보 수정은 운영상 필요한 경우 예약어 닉네임도 저장할 수 있지만, 일반 회원가입과 개인 프로필 수정에서는 운영자 사칭성 예약어 닉네임을 계속 차단한다.
|
||||
- 회원 관리 카드에는 아바타, 작성 티어표 수, 최근 활동 시각을 함께 표시한다.
|
||||
|
||||
## 티어표 접근 메모
|
||||
- `new` 작성 경로는 로그인한 사용자만 진입할 수 있다.
|
||||
- 비로그인 사용자는 공개된 티어표를 열람만 할 수 있고, 편집 UI와 저장 동작은 비활성화된다.
|
||||
- 공유 링크로 여는 `preview=1` 화면은 `뷰어 모드`로 취급하며, 드래그/행열 편집/저장 같은 편집 UI 없이 완성본만 렌더링한다.
|
||||
- 비로그인 사용자나 작성자 본인이 아닌 로그인 사용자는 저장된 티어표를 기본적으로 뷰어 모드로 열람하며, 일반 편집 URL로 직접 진입해도 소유자가 아니면 `preview=1` 주소로 자동 전환된다.
|
||||
- 비로그인 사용자도 뷰어 모드 우측 레일의 `공유하기` 버튼으로 현재 공유 링크를 복사할 수 있다.
|
||||
- 로그인한 타인 티어표 열람자는 뷰어 모드 우측 레일에서 `내 티어표로 복사`를 사용할 수 있고, 작성자 본인은 `수정 모드로 전환`을 사용할 수 있다.
|
||||
- 작성자 본인이 일반 편집 화면에서 저장된 본인 티어표를 보고 있을 때는 우측 패널의 `뷰어 모드로 보기`로 공유 화면 형태를 바로 확인할 수 있다.
|
||||
- 같은 `TierEditorView` 안에서 `topicId / tierListId / preview` 라우트 값만 바뀌어도 화면이 이전 티어표 데이터에 남지 않도록, 라우트 변경마다 에디터 상태를 다시 로드한다.
|
||||
- 복사한 티어표 상단의 `원본` 링크를 누르면 원본 티어표로 이동하며, 편집 모드에서 저장하지 않은 변경이 있으면 이동 전에 `저장 없이 이동` 확인 모달을 먼저 띄운다.
|
||||
- 비공개 티어표라도 관리자는 편집 화면에서 완성본을 열람할 수 있다.
|
||||
- 공개 티어표는 목록과 상세 화면에서 즐겨찾기 토글 및 개수를 함께 표시한다.
|
||||
- 카드형 목록에서는 즐겨찾기 수/상태만 표시하고, 실제 토글은 상세 화면에서 처리한다.
|
||||
@@ -186,6 +195,10 @@
|
||||
- 사용자가 직접 추가한 커스텀 아이템 이름은 편집 화면 우측 목록에서 정리할 수 있고, 저장 시 원본 커스텀 아이템 라벨과 함께 동기화된다.
|
||||
- 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다.
|
||||
- 티어 행에 넣은 아이템은 작은 제거 버튼으로 다시 우측 아이템 풀로 되돌릴 수 있다.
|
||||
- 편집 가능한 티어표에서는 아이템을 드래그로 옮길 수 있고, 아이템을 클릭해 선택한 뒤 원하는 셀이나 아이템 풀 빈 영역을 클릭하는 방식으로도 위치를 바꿀 수 있다.
|
||||
- 클릭 배치 모드에서 선택된 아이템은 파란 포커스 테두리로 표시하며, 드래그를 시작하면 선택 상태를 해제하고 드래그 직후 짧은 클릭 입력은 무시해 드래그/클릭 조작이 섞이지 않게 한다.
|
||||
- 기존 저장 티어표/복사본을 수정 가능한 상태로 다시 열 때는 저장본의 그룹/풀을 먼저 복원하고, 이후 현재 템플릿에 새로 추가된 기본 아이템만 미사용 풀 끝에 병합한다.
|
||||
- 관리자 템플릿에서 삭제된 기본 아이템이라도 이미 저장된 티어표의 그룹/풀에 포함되어 있던 항목은 자동 제거하지 않고 그대로 보존한다.
|
||||
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
|
||||
- 제목이 비어 있는 상태로 저장하면 내부 제목은 현재 게임명을 기본값으로 사용한다.
|
||||
- 제목 입력이 비어 있는 동안에는 무분별한 도배 방지를 위한 관리자 임의 삭제 가능성 안내 문구를 표시한다.
|
||||
@@ -197,6 +210,7 @@
|
||||
- 티어표에 썸네일을 직접 지정하지 않으면 저장 시 대표 아이템 이미지 하나를 기본 썸네일로 자동 선택한다.
|
||||
- 편집 화면 상단 헤더는 좌측 제목/설명, 우측 썸네일 카드 구조를 사용하며 모바일에서는 한 열로 접힌다.
|
||||
- 티어표 편집 화면의 우측 패널은 공통 `rightRail`의 `localRightRailRoot`에 직접 section들을 쌓는 구조이며, 별도 외곽 래퍼 카드 없이 공통 오른쪽 컬럼 문법을 그대로 따른다.
|
||||
- 뷰어 모드 우측 패널도 같은 `localRightRailRoot`를 사용하며, 상단에는 광고 블록을, 하단에는 공유/복사/수정 전환 액션 카드를 배치한다.
|
||||
- 공통 앱 셸은 좌측/중앙/우측 컬럼마다 높이 `56px`의 상단 헤더 블록을 유지하며, 중앙 헤더에는 사이트 타이틀 `Tier Maker`와 현재 서비스 설명을 표시한다.
|
||||
- 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다.
|
||||
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
|
||||
@@ -206,11 +220,14 @@
|
||||
- 저장/삭제/가져오기 같은 사용자 행동 피드백은 전역 우측 상단 토스트로 표시한다.
|
||||
- 전역 토스트는 동일 메시지/타입이 연속 발생하면 하나로 합쳐 카운트를 올리고, 종료 시 짧은 페이드아웃 애니메이션을 사용한다.
|
||||
- 홈 게임 목록은 관리자 상단 고정 순서가 있으면 그 순서를 먼저 적용하고, 그 외 게임은 최근 생성순으로 뒤에 이어진다.
|
||||
- 홈 주제 템플릿 목록의 실제 정렬 우선순위는 `즐겨찾기 여부 → 관리자 수동 순서(displayRank) → 최신 생성순(createdAt DESC) → 이름순`이다.
|
||||
- `커스텀 티어표 만들기`는 카드가 아니라 홈 우측 상단 버튼으로 진입한다.
|
||||
|
||||
## 업로드 제한 메모
|
||||
- 프로필 아바타 업로드는 파일당 최대 `3MB`다.
|
||||
- 관리자 게임 썸네일/기본 아이템 업로드와 사용자 커스텀 이미지 업로드는 파일당 최대 `6MB`다.
|
||||
- 관리자 템플릿 썸네일/기본 아이템 업로드는 파일당 최대 `20MB`다.
|
||||
- 사용자 커스텀 이미지 업로드는 파일당 최대 `6MB`다.
|
||||
- 운영 프런트 Nginx는 다중 이미지 업로드 한 번의 요청 본문을 최대 `1024MB`까지 허용한다.
|
||||
- 현재는 업로드 전에 이미지 리사이즈/압축을 하지 않고 원본 파일을 그대로 저장한다.
|
||||
|
||||
## 운영 환경 변수
|
||||
|
||||
17
docs/todo.md
17
docs/todo.md
@@ -1,6 +1,23 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 단기 확인
|
||||
- `v1.4.43`에서 같은 `TierEditorView` 라우트 안에서도 `topicId / tierListId / preview`가 바뀌면 상태를 다시 불러오게 했으므로, 타인 티어표 복사 직후 화면이 내 복사본으로 바뀌는지와 상단 원본 링크 클릭 시 실제 원본 티어표 내용으로 전환되는지 확인한다.
|
||||
- 편집 중 미저장 변경이 있는 상태에서 상단 원본 링크를 눌렀을 때는 경고 모달이 뜨고, `계속 편집`은 현재 화면 유지, `저장 없이 이동`은 원본으로 이동하면서 변경분을 버리는지 QA한다.
|
||||
- `v1.4.42`에서 홈 템플릿 정렬을 `즐겨찾기 → 수동 순서 → 최신 생성순 → 이름순`으로 바꿨으므로, 관리자에서 아무 수동 정렬을 하지 않은 신규 템플릿이 가장 앞쪽에 보이고, 즐겨찾기/수동 고정 항목은 기존 우선순위를 유지하는지 확인한다.
|
||||
- 티어표 편집기의 클릭 배치를 추가했으므로, 풀 아이템 클릭→빈 셀 클릭, 셀 아이템 클릭→다른 셀 클릭, 셀 아이템 클릭→풀 빈 영역 클릭, 같은 아이템 재클릭 선택 해제, 드래그 직후 의도치 않은 재선택 방지까지 한 번씩 QA한다.
|
||||
- 클릭 배치에서 이미 아이템이 들어 있는 셀 안의 빈 영역을 눌렀을 때는 해당 셀 끝에 추가되고, 같은 셀을 다시 누르면 선택만 해제되는지 확인한다.
|
||||
- `v1.4.41`에서 관리자 템플릿 기본 아이템 다중 업로드를 `100개/파일당 20MB`와 Nginx `client_max_body_size 1024m`으로 올렸으므로, 운영 NAS 앞단 리버스 프록시에도 별도 본문 크기 제한이 있으면 같은 수준으로 맞춰야 하는지 확인한다.
|
||||
- 실제 QA에서는 10개 이상, 50개 이상, 100개 근처의 이미지 묶음을 한 번에 올렸을 때 브라우저/프런트 Nginx/백엔드 중 어느 단계에서도 `413`이나 업로드 실패가 나지 않는지 확인한다.
|
||||
- `v1.4.40`에서 `preview=1` 공유 화면을 뷰어 모드로 정리했으므로, 비로그인/로그인한 타인/작성자 본인 세 경우에 드래그 편집이 막히고 오른쪽 레일 버튼이 각각 `공유하기`, `내 티어표로 복사`, `수정 모드로 전환` 조건대로 노출되는지 확인한다. 특히 비로그인/타인이 일반 편집 URL로 직접 들어왔을 때도 자동으로 `preview=1`로 바뀌는지 본다.
|
||||
- 작성자 본인 편집 화면에는 `뷰어 모드로 보기`가 추가됐으므로, 저장된 본인 티어표에서 뷰어 모드로 진입한 뒤 다시 `수정 모드로 전환`으로 돌아오는 왕복 라우팅이 자연스러운지 QA한다.
|
||||
- 뷰어 모드 오른쪽 레일이 공통 로컬 레일 마운트를 다시 사용하게 바뀌었으므로, 데스크톱/태블릿 폭에서 광고가 상단에 나오고 액션 카드가 하단으로 내려가며, 우측 레일 접기/펼치기 시 콘텐츠가 깨지지 않는지 확인한다.
|
||||
- `v1.4.39`에서 기존 저장 티어표/복사본을 다시 열 때 최신 템플릿 기본 아이템이 미사용 풀로 합류하도록 바꿨으므로, `12345`로 저장한 티어표를 만든 뒤 관리자 템플릿에 `6789`를 추가하고 다시 열거나 복사했을 때 `6789`만 미사용 상태로 나타나는지 확인한다.
|
||||
- 같은 정책에서 관리자 템플릿에서 삭제한 기존 아이템은 과거 저장 티어표의 그룹/풀 안에 남아 있으면 계속 보존되어야 하므로, `5`번을 삭제해도 이미 사용한 옛 티어표에서 사라지지 않는지 다시 확인한다.
|
||||
- `v1.4.39`에서 주제별 공개 티어표 화면을 `pageWrap`으로 감쌌으므로, 좁은 브라우저 폭에서 검색창이 아래 줄로 내려온 상태에서도 검색창과 카드 목록 사이 간격이 홈/즐겨찾기 화면과 비슷하게 유지되는지 확인한다.
|
||||
- `v1.4.38`에서 운영자 계정의 최고 관리자 관리 버튼을 프런트에서 비활성화했으므로, 최고 관리자/일반 운영자/일반 회원 세 계정 조합으로 회원 정보 수정, 썸네일 변경, 비밀번호 초기화, 삭제 버튼 활성 상태와 서버 차단 응답이 기대대로 맞는지 확인한다.
|
||||
- 관리자 회원 정보 수정에서는 예약어 닉네임을 허용하도록 예외를 열었으므로, 일반 회원가입/개인 프로필 수정은 여전히 예약어가 막히고 관리자 수정만 저장되는지 한 번 더 QA한다.
|
||||
- `preview=1` 화면이 공통 앱 셸을 그대로 쓰도록 바뀌었으므로, 좌측 레일 접기/펼치기, 우측 레일 닫기/열기, 중앙 헤더 타이틀, 메인 배경색, 오른쪽 광고와 카피라이트 정렬이 홈 화면과 같은 문법으로 보이는지 비교 QA한다.
|
||||
- 프리뷰 우측 광고에 `padding-top: 78px`를 넣었으므로, 데스크톱/태블릿 폭에서 광고 시작 위치가 너무 내려가거나 모바일 오버레이 레이아웃에서 어색해지지 않는지 확인한다.
|
||||
- `v1.4.33`에서 회원가입에 닉네임 입력과 중복/예약어 검사를 붙였으므로, 실제 QA에서는 이메일 중복, 닉네임 중복, 예약 닉네임, 프로필 닉네임 변경, 관리자 회원 수정 흐름이 같은 규칙으로 막히는지 확인한다.
|
||||
- 테마는 저장값이 없을 때 무조건 다크로 시작하게 바꿨고 설정 화면 토글도 다시 열었으므로, 첫 접속/새 브라우저/다른 운영체제에서 기본 다크 시작과 수동 토글 저장이 그대로 정상인지 확인한다.
|
||||
- 관리자 템플릿 썸네일 드롭존 빈 상태 아이콘 제거와 아이템 상세 모달 썸네일 프리뷰가 들어갔으므로, 관리자 화면에서 썸네일 교체와 아이템 선택 모달 가독성을 한 번 더 QA한다.
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-03 v1.4.43
|
||||
- 다른 사람 티어표를 복사한 직후 URL은 복사본 ID로 바뀌었는데 화면 데이터가 기존 원본에 남아 있을 수 있었던 문제를 고치기 위해, `TierEditorView`가 같은 컴포넌트 안에서 `topicId / tierListId / preview` 라우트 값이 바뀔 때마다 편집기 상태를 다시 로드하도록 바꿨다.
|
||||
- 복사한 티어표 상단의 원본 링크를 클릭했을 때도 주소만 바뀌고 화면이 그대로 남지 않도록, 원본 이동 버튼이 같은 재로딩 흐름을 타게 정리했다.
|
||||
- 작성자 본인 편집 모드에서 저장하지 않은 수정 내용이 있는 상태로 원본 링크를 누르면, 현재 변경 내용이 사라진다는 확인 모달을 먼저 띄우고 `저장 없이 이동`을 선택한 경우에만 원본 티어표로 이동하도록 보강했다.
|
||||
|
||||
## 2026-04-03 v1.4.42
|
||||
- 홈 주제 템플릿 목록 정렬에서 수동 고정 순서가 같은 항목끼리 이름순으로 다시 정렬되던 부분을 바꿔, 즐겨찾기 우선과 관리자 수동 순서를 유지하되 수동 순서가 없는 템플릿은 최신 생성순으로 먼저 보이도록 맞췄다.
|
||||
- 티어표 편집기에서 아이템을 클릭으로도 옮길 수 있게 해, 아이템을 한 번 클릭하면 선택 포커스가 표시되고 원하는 티어 셀이나 아이템 풀 빈 영역을 클릭하면 해당 위치로 이동하도록 보강했다.
|
||||
- 클릭 배치와 기존 드래그 배치가 충돌하지 않도록 드래그 시작 시 선택 상태를 해제하고, 드래그 직후 짧은 시간 동안 아이템 클릭 선택을 무시하는 보호를 추가했다.
|
||||
|
||||
## 2026-04-03 v1.4.41
|
||||
- 관리자 템플릿 기본 아이템 다중 업로드 제한을 한 번에 `100개`, 파일당 `20MB`까지 받을 수 있도록 백엔드 `multer` 설정과 업로드 라우트 배열 제한을 함께 상향했다.
|
||||
- 프런트 Nginx 프록시에도 `client_max_body_size 1024m`을 추가해, 여러 이미지를 한 번의 `FormData` 요청으로 올릴 때 합산 본문 크기 제한 때문에 먼저 `413`으로 막히는 상황을 줄였다.
|
||||
|
||||
## 2026-04-03 v1.4.40
|
||||
- 공유 링크로 여는 `preview=1` 화면을 `뷰어 모드`로 정의하고, 드래그/편집 없이 완성본만 보이는 상태에서 오른쪽 레일 상단에는 광고, 하단에는 공유·복사·수정 전환 액션을 노출하도록 정리했다.
|
||||
- 비로그인 사용자나 작성자 본인이 아닌 사용자가 일반 편집 URL로 저장된 티어표를 직접 열어도 자동으로 `preview=1` 뷰어 모드 주소로 전환되도록 로딩 후 라우팅을 보정했다.
|
||||
- 비로그인 사용자도 뷰어 모드에서 `공유하기` 버튼을 사용할 수 있고, 로그인한 타인 티어표는 `내 티어표로 복사`, 작성자 본인 티어표는 `수정 모드로 전환`을 사용할 수 있게 권한별 액션을 분기했다.
|
||||
- 작성자 본인이 수정 화면에 있을 때는 우측 패널에 `뷰어 모드로 보기`를 추가해, 본인도 공유 화면과 같은 뷰어 모드를 바로 확인할 수 있게 했다.
|
||||
- 뷰어 모드에서도 에디터 로컬 오른쪽 레일을 마운트하도록 공통 앱 셸 조건을 보정해, 광고와 액션 카드가 실제 우측 패널에 안정적으로 렌더링되게 맞췄다.
|
||||
|
||||
## 2026-04-03 v1.4.39
|
||||
- 기존 저장 티어표/복사본을 수정 가능한 상태로 다시 열 때, 저장본에 없던 최신 템플릿 기본 아이템만 미사용 풀 맨 뒤에 자동 합류하도록 병합 로딩을 추가했다.
|
||||
- 관리자 템플릿에서 삭제된 기본 아이템이라도 이미 저장된 티어표의 그룹/풀에 남아 있는 항목은 그대로 보존해, 과거 결과물이 템플릿 정리 때문에 깨지지 않도록 유지했다.
|
||||
- 주제별 공개 티어표 목록 화면은 좁은 브라우저 폭에서 상단 검색 툴바가 아래 줄로 내려오면 카드 목록과 간격이 붙어 보일 수 있었으므로, 홈/즐겨찾기 화면과 같은 `pageWrap` 구조로 감싸 상단 영역과 목록 사이 여백을 유지하도록 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.38
|
||||
- 관리자 회원 관리에서 운영자 계정으로는 최고 관리자 계정의 썸네일 변경, 비밀번호 초기화, 회원 삭제, 회원 정보 수정 버튼이 비활성화되도록 프런트 보호를 추가했고, 자기 자신 삭제 버튼도 함께 막았다.
|
||||
- 관리자 회원 정보 수정에서는 운영자/관리자 예약어가 들어간 닉네임도 저장할 수 있도록 서버 검증 예외를 분리했고, 일반 회원가입과 개인 프로필 수정의 예약어 차단은 그대로 유지했다.
|
||||
- `preview=1` 티어표 화면은 별도 프리뷰 셸을 걷어내고 공통 좌측 레일·중앙 헤더·우측 레일을 그대로 쓰도록 바꿨으며, 프리뷰 본문 제목도 홈 화면과 같은 `pageHead` 문법으로 맞췄다.
|
||||
- 프리뷰/일반 화면의 오른쪽 광고 레일은 같은 공통 레일 footer를 사용하게 되어 카피라이트 중앙 정렬이 맞춰졌고, 애드센스 영역 상단에는 `padding-top: 78px`를 적용해 시각적 시작점을 조금 아래로 내렸다.
|
||||
- 로컬에서 생성되는 `backend/uploads/assets/` 최적화 이미지가 Git 변경분에 섞이지 않도록 `.gitignore` 제외 경로에 추가했다.
|
||||
|
||||
## 2026-04-02 v1.4.33
|
||||
- 회원가입 시 닉네임 입력을 함께 받도록 바꾸고, 이메일 중복과 닉네임 중복을 서버에서 명확히 차단하도록 정리했다.
|
||||
- `admin`, `운영자`, `관리자`, `official`, `zenn`처럼 운영자·공식 계정으로 오해될 수 있는 닉네임은 예약어로 막고, 프로필 수정/관리자 회원 수정에서도 같은 규칙을 공유하도록 맞췄다.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
client_max_body_size 1024m;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
@@ -43,7 +43,9 @@ const authReady = computed(() => auth.hydrated)
|
||||
const isAdmin = computed(() => authReady.value && !!auth.user?.isAdmin)
|
||||
const isPreviewMode = computed(() => route.query.preview === '1')
|
||||
const isAdminRoute = computed(() => String(route.name || '').startsWith('admin'))
|
||||
const usesLocalRightRail = computed(() => ['editEditor', 'newEditor'].includes(String(route.name || '')) || isAdminRoute.value)
|
||||
const usesLocalRightRail = computed(
|
||||
() => ['editEditor', 'newEditor'].includes(String(route.name || '')) || isAdminRoute.value
|
||||
)
|
||||
const isRightRailOverlay = computed(() => viewportWidth.value <= 1200)
|
||||
const isMobileLayout = computed(() => viewportWidth.value <= 860)
|
||||
const avatarUrl = computed(() => (auth.user?.avatarSrc ? toApiUrl(auth.user.avatarSrc) : ''))
|
||||
@@ -424,7 +426,6 @@ function reloadApp() {
|
||||
<div
|
||||
class="appShell"
|
||||
:class="{
|
||||
'appShell--preview': isPreviewMode,
|
||||
'appShell--leftCollapsed': leftRailCollapsed,
|
||||
'appShell--rightClosed': !rightRailOpen,
|
||||
'appShell--rightOverlay': isRightRailOverlay,
|
||||
@@ -450,25 +451,6 @@ function reloadApp() {
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
<template v-else-if="isPreviewMode">
|
||||
<main class="appMain appMain--preview">
|
||||
<div class="previewShell">
|
||||
<div class="previewShell__main">
|
||||
<RouterView />
|
||||
</div>
|
||||
<aside class="previewShell__rail">
|
||||
<div class="previewShell__railInner">
|
||||
<RightRailAd class-name="previewShell__ad" />
|
||||
</div>
|
||||
<div class="previewShell__footer">
|
||||
<span>Copyright © 2026 </span>
|
||||
<a :href="RIGHT_RAIL_COPYRIGHT_URL" target="_blank" rel="noreferrer">zenn</a>
|
||||
<span>. All rights reserved.</span>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
<template v-else>
|
||||
<aside class="leftRail">
|
||||
<div class="leftRail__top railHeader">
|
||||
@@ -711,6 +693,7 @@ function reloadApp() {
|
||||
}
|
||||
|
||||
.backendFallback {
|
||||
min-width: 100dvw;
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -769,10 +752,6 @@ function reloadApp() {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.appShell--preview {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.leftRail,
|
||||
.rightRail {
|
||||
min-height: 100dvh;
|
||||
@@ -1234,47 +1213,6 @@ function reloadApp() {
|
||||
border-right: 1px solid var(--theme-border);
|
||||
}
|
||||
|
||||
.appMain--preview {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.previewShell {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 325px;
|
||||
}
|
||||
|
||||
.previewShell__main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.previewShell__rail {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 16px 18px 20px;
|
||||
border-left: 1px solid var(--theme-border);
|
||||
background: var(--theme-rail-bg);
|
||||
}
|
||||
|
||||
.previewShell__railInner {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.previewShell__footer {
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
color: var(--theme-text-faint);
|
||||
}
|
||||
|
||||
.previewShell__footer a {
|
||||
color: #00ffff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-rows: 56px minmax(0, 1fr);
|
||||
@@ -1886,14 +1824,6 @@ function reloadApp() {
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.previewShell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.previewShell__rail {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.guideModal__dialog {
|
||||
grid-template-columns: 1fr;
|
||||
height: min(860px, calc(100dvh - 40px));
|
||||
|
||||
@@ -67,6 +67,7 @@ onMounted(async () => {
|
||||
.rightRailAd {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding-top: 78px;
|
||||
}
|
||||
|
||||
.rightRailAd__eyebrow {
|
||||
|
||||
@@ -15,6 +15,10 @@ const props = defineProps({
|
||||
userDisplayName: { type: Function, required: true },
|
||||
userAvatarFallback: { type: Function, required: true },
|
||||
removeUserAvatar: { type: Function, required: true },
|
||||
canEditUserAvatar: { type: Function, required: true },
|
||||
canEditUserInfo: { type: Function, required: true },
|
||||
canResetUserPassword: { type: Function, required: true },
|
||||
canDeleteUser: { type: Function, required: true },
|
||||
roleLabelOf: { type: Function, required: true },
|
||||
fmt: { type: Function, required: true },
|
||||
openUserPasswordModal: { type: Function, required: true },
|
||||
@@ -76,7 +80,12 @@ const userSortDirectionModel = computed({
|
||||
@change="props.onUserAvatarChange(user, $event)"
|
||||
/>
|
||||
<div class="userAvatarWrap">
|
||||
<button class="userAvatar userAvatarButton" type="button" :disabled="user.isAvatarBusy" @click="props.openUserAvatarPicker(user)">
|
||||
<button
|
||||
class="userAvatar userAvatarButton"
|
||||
type="button"
|
||||
:disabled="user.isAvatarBusy || !props.canEditUserAvatar(user)"
|
||||
@click="props.openUserAvatarPicker(user)"
|
||||
>
|
||||
<img v-if="props.userAvatarUrl(user)" class="userAvatar__image" :src="props.userAvatarUrl(user)" :alt="props.userDisplayName(user)" />
|
||||
<span v-else class="userAvatar__fallback">{{ props.userAvatarFallback(user) }}</span>
|
||||
<span class="userAvatarButton__overlay">{{ user.isAvatarBusy ? '업데이트중...' : '수정' }}</span>
|
||||
@@ -86,7 +95,7 @@ const userSortDirectionModel = computed({
|
||||
class="userAvatarRemoveButton"
|
||||
type="button"
|
||||
title="회원 썸네일 삭제"
|
||||
:disabled="user.isAvatarBusy"
|
||||
:disabled="user.isAvatarBusy || !props.canEditUserAvatar(user)"
|
||||
@click.stop="props.removeUserAvatar(user)"
|
||||
>
|
||||
<SvgIcon class="userAvatarRemoveIcon" :src="props.deleteIcon" :size="12" />
|
||||
@@ -111,13 +120,32 @@ const userSortDirectionModel = computed({
|
||||
</div>
|
||||
|
||||
<div class="userCard__actions userCard__actions--compact">
|
||||
<button class="iconActionButton" type="button" title="비밀번호 초기화" @click="props.openUserPasswordModal(user)">
|
||||
<button
|
||||
class="iconActionButton"
|
||||
type="button"
|
||||
title="비밀번호 초기화"
|
||||
:disabled="!props.canResetUserPassword(user)"
|
||||
@click="props.openUserPasswordModal(user)"
|
||||
>
|
||||
<SvgIcon class="iconActionButton__icon" :src="props.lockResetIcon" :size="18" />
|
||||
</button>
|
||||
<button class="iconActionButton iconActionButton--danger" type="button" title="회원 삭제" @click="props.openUserDeleteModal(user)">
|
||||
<button
|
||||
class="iconActionButton iconActionButton--danger"
|
||||
type="button"
|
||||
title="회원 삭제"
|
||||
:disabled="!props.canDeleteUser(user)"
|
||||
@click="props.openUserDeleteModal(user)"
|
||||
>
|
||||
<SvgIcon class="iconActionButton__icon" :src="props.deleteIcon" :size="18" />
|
||||
</button>
|
||||
<button class="btn btn--ghost userSaveButton" type="button" @click="props.openUserEditModal(user)">회원 정보 수정</button>
|
||||
<button
|
||||
class="btn btn--ghost userSaveButton"
|
||||
type="button"
|
||||
:disabled="!props.canEditUserInfo(user)"
|
||||
@click="props.openUserEditModal(user)"
|
||||
>
|
||||
회원 정보 수정
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -38,6 +38,29 @@ export function useAdminUsers({
|
||||
return !modalTargetUser.value.isPrimaryAdmin
|
||||
})
|
||||
|
||||
function canManageUser(user) {
|
||||
if (!user?.isPrimaryAdmin) return true
|
||||
return !!auth.user?.isPrimaryAdmin
|
||||
}
|
||||
|
||||
function canEditUserAvatar(user) {
|
||||
return canManageUser(user)
|
||||
}
|
||||
|
||||
function canEditUserInfo(user) {
|
||||
return canManageUser(user)
|
||||
}
|
||||
|
||||
function canResetUserPassword(user) {
|
||||
return canManageUser(user)
|
||||
}
|
||||
|
||||
function canDeleteUser(user) {
|
||||
if (!canManageUser(user)) return false
|
||||
if (user?.isAdmin && !auth.user?.isPrimaryAdmin) return false
|
||||
return user?.id !== auth.user?.id
|
||||
}
|
||||
|
||||
const isUserEditDirty = computed(() => {
|
||||
if (!modalTargetUser.value) return false
|
||||
return (
|
||||
@@ -54,6 +77,7 @@ export function useAdminUsers({
|
||||
}
|
||||
|
||||
function openUserAvatarPicker(user) {
|
||||
if (!canEditUserAvatar(user)) return
|
||||
userAvatarInputs.value[user?.id]?.click()
|
||||
}
|
||||
|
||||
@@ -96,11 +120,13 @@ export function useAdminUsers({
|
||||
}
|
||||
|
||||
async function removeUserAvatar(user) {
|
||||
if (!canEditUserAvatar(user)) return
|
||||
if (!user?.avatarSrc) return
|
||||
await uploadUserAvatar(user, null, { remove: true })
|
||||
}
|
||||
|
||||
function openUserEditModal(user) {
|
||||
if (!canEditUserInfo(user)) return
|
||||
resetMessages()
|
||||
modalTargetUser.value = user ? { ...user } : null
|
||||
modalUserDraftEmail.value = user?.email || ''
|
||||
@@ -147,6 +173,7 @@ export function useAdminUsers({
|
||||
}
|
||||
|
||||
function openUserPasswordModal(user) {
|
||||
if (!canResetUserPassword(user)) return
|
||||
resetMessages()
|
||||
modalTargetUser.value = user ? { ...user } : null
|
||||
modalPasswordDraft.value = ''
|
||||
@@ -183,6 +210,7 @@ export function useAdminUsers({
|
||||
}
|
||||
|
||||
function openUserDeleteModal(user) {
|
||||
if (!canDeleteUser(user)) return
|
||||
resetMessages()
|
||||
modalTargetUser.value = user ? { ...user } : null
|
||||
userDeleteModalOpen.value = true
|
||||
@@ -242,6 +270,10 @@ export function useAdminUsers({
|
||||
return {
|
||||
setUserAvatarInput,
|
||||
canManageModalRole,
|
||||
canEditUserAvatar,
|
||||
canEditUserInfo,
|
||||
canResetUserPassword,
|
||||
canDeleteUser,
|
||||
isUserEditDirty,
|
||||
roleLabelOf,
|
||||
openUserAvatarPicker,
|
||||
|
||||
@@ -1039,6 +1039,10 @@ const {
|
||||
const {
|
||||
setUserAvatarInput,
|
||||
canManageModalRole,
|
||||
canEditUserAvatar,
|
||||
canEditUserInfo,
|
||||
canResetUserPassword,
|
||||
canDeleteUser,
|
||||
isUserEditDirty,
|
||||
roleLabelOf,
|
||||
openUserAvatarPicker,
|
||||
@@ -1795,6 +1799,10 @@ function userAvatarFallback(user) {
|
||||
:user-display-name="userDisplayName"
|
||||
:user-avatar-fallback="userAvatarFallback"
|
||||
:remove-user-avatar="removeUserAvatar"
|
||||
:can-edit-user-avatar="canEditUserAvatar"
|
||||
:can-edit-user-info="canEditUserInfo"
|
||||
:can-reset-user-password="canResetUserPassword"
|
||||
:can-delete-user="canDeleteUser"
|
||||
:role-label-of="roleLabelOf"
|
||||
:fmt="fmt"
|
||||
:open-user-password-modal="openUserPasswordModal"
|
||||
|
||||
@@ -30,6 +30,9 @@ const templates = computed(() => {
|
||||
const rankA = a.displayRank == null ? Number.MAX_SAFE_INTEGER : a.displayRank
|
||||
const rankB = b.displayRank == null ? Number.MAX_SAFE_INTEGER : b.displayRank
|
||||
if (rankA !== rankB) return rankA - rankB
|
||||
if (Number(a.createdAt || 0) !== Number(b.createdAt || 0)) {
|
||||
return Number(b.createdAt || 0) - Number(a.createdAt || 0)
|
||||
}
|
||||
return (a.name || '').localeCompare(b.name || '', 'ko')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { Teleport, computed, inject, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { Teleport, computed, inject, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import Sortable from 'sortablejs'
|
||||
import * as htmlToImage from 'html-to-image'
|
||||
@@ -8,8 +8,9 @@ import addColumnRightIcon from '../assets/icons/add_column_right.svg'
|
||||
import addRowBelowIcon from '../assets/icons/add_row_below.svg'
|
||||
import addPhotoAlternateIcon from '../assets/icons/add_photo_alternate.svg'
|
||||
import shareIcon from '../assets/icons/share.svg'
|
||||
import RightRailAd from '../components/RightRailAd.vue'
|
||||
import { api } from '../lib/api'
|
||||
import { editorNewPath, editorPath, homePath, loginPath, mePath, topicPath } from '../lib/paths'
|
||||
import { editorNewPath, editorPath, loginPath, mePath, topicPath } from '../lib/paths'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useToast } from '../composables/useToast'
|
||||
@@ -55,8 +56,10 @@ const templateRequestDraftDescription = ref('')
|
||||
const isDeleteModalOpen = ref(false)
|
||||
const isGroupDeleteModalOpen = ref(false)
|
||||
const isColumnDeleteModalOpen = ref(false)
|
||||
const isNavigationConfirmModalOpen = ref(false)
|
||||
const pendingRemoveGroupId = ref('')
|
||||
const pendingRemoveColumnIndex = ref(-1)
|
||||
const pendingNavigationPath = ref('')
|
||||
const ownerId = ref('')
|
||||
const authorName = ref('')
|
||||
const authorAccountName = ref('')
|
||||
@@ -73,6 +76,10 @@ const isFavorited = ref(false)
|
||||
const isRequestingTemplate = ref(false)
|
||||
const isDeleting = ref(false)
|
||||
const poolSearchQuery = ref('')
|
||||
const selectedItemId = ref('')
|
||||
const recentDragFinishedAt = ref(0)
|
||||
const savedEditorSnapshot = ref('')
|
||||
let editorLoadToken = 0
|
||||
|
||||
const boardEl = ref(null)
|
||||
const exportBoardEl = ref(null)
|
||||
@@ -86,7 +93,8 @@ const poolSortable = ref(null)
|
||||
const dropSortables = ref([])
|
||||
|
||||
const isNewTierList = computed(() => tierListId.value === 'new')
|
||||
const canEdit = computed(() => !!auth.user && (!ownerId.value || ownerId.value === auth.user.id))
|
||||
const isOwnTierList = computed(() => !!auth.user && !!ownerId.value && ownerId.value === auth.user.id)
|
||||
const canEdit = computed(() => !!auth.user && !previewMode.value && (!ownerId.value || ownerId.value === auth.user.id))
|
||||
const iconSizeOptions = [48, 64, 80, 96, 112]
|
||||
const hasCustomTitle = computed(() => !!(title.value || '').trim())
|
||||
const fallbackTimestamp = computed(() => (updatedAt.value ? updatedAt.value : Date.now()))
|
||||
@@ -114,11 +122,13 @@ const untitledWarning = computed(
|
||||
'제목 없이 저장된 티어표는 무분별한 도배 방지를 위해 관리자에 의해 임의 삭제될 수 있어요.'
|
||||
)
|
||||
const canFavorite = computed(() => !!auth.user && !isNewTierList.value && !canEdit.value)
|
||||
const canDuplicate = computed(() => !!auth.user && hasSavedTierList.value)
|
||||
const canDuplicate = computed(() => !!auth.user && hasSavedTierList.value && !isOwnTierList.value)
|
||||
const canSwitchToViewerMode = computed(() => isOwnTierList.value && hasSavedTierList.value && !previewMode.value)
|
||||
const canSwitchToEditMode = computed(() => isOwnTierList.value && hasSavedTierList.value && previewMode.value)
|
||||
const copiedFromLabel = computed(() => {
|
||||
if (!sourceTierListId.value) return ''
|
||||
const parts = []
|
||||
if (sourceSnapshotTitle.value) parts.push(`원본 ${sourceSnapshotTitle.value}`)
|
||||
if (sourceSnapshotTitle.value) parts.push(sourceSnapshotTitle.value)
|
||||
if (sourceSnapshotAuthor.value) parts.push(sourceSnapshotAuthor.value)
|
||||
return parts.join(' · ') || '복사해 온 티어표'
|
||||
})
|
||||
@@ -140,8 +150,6 @@ const shareTierListUrl = computed(() => {
|
||||
if (typeof window === 'undefined') return editorPath(templateId.value, savedTierListId, { preview: true })
|
||||
return new URL(editorPath(templateId.value, savedTierListId, { preview: true }), window.location.origin).toString()
|
||||
})
|
||||
const previewHomeUrl = computed(() => homePath())
|
||||
|
||||
watch(error, (message) => {
|
||||
if (!message) return
|
||||
toast.error(message)
|
||||
@@ -195,6 +203,20 @@ function getOrderedItems() {
|
||||
return getOrderedItemIds().map((itemId) => itemsById.value[itemId]).filter(Boolean)
|
||||
}
|
||||
|
||||
function mergeLatestTemplateItemsIntoPool(savedItemsMap, savedPoolIds, currentTemplateItems, groupedIds, editable) {
|
||||
const nextMap = { ...(savedItemsMap || {}) }
|
||||
const nextPoolIds = Array.isArray(savedPoolIds) ? [...savedPoolIds] : []
|
||||
if (!editable) return { nextMap, nextPoolIds }
|
||||
|
||||
;(currentTemplateItems || []).forEach((item) => {
|
||||
if (!item?.id || nextMap[item.id]) return
|
||||
nextMap[item.id] = item
|
||||
if (!groupedIds?.has(item.id)) nextPoolIds.push(item.id)
|
||||
})
|
||||
|
||||
return { nextMap, nextPoolIds }
|
||||
}
|
||||
|
||||
function isPoolItemVisible(itemId) {
|
||||
const query = normalizedPoolSearchQuery.value
|
||||
if (!query) return true
|
||||
@@ -262,6 +284,33 @@ function buildGroupPayload() {
|
||||
}))
|
||||
}
|
||||
|
||||
function createEditorSnapshot() {
|
||||
return JSON.stringify({
|
||||
title: (title.value || '').trim(),
|
||||
description: description.value || '',
|
||||
isPublic: !!isPublic.value,
|
||||
showCharacterNames: !!showCharacterNames.value,
|
||||
iconSize: Number(iconSize.value || 80),
|
||||
columns: columns.value.map((column) => ({ id: column.id, name: column.name || '' })),
|
||||
groups: buildGroupPayload(),
|
||||
pool: pool.value.map((itemId) => {
|
||||
const item = itemsById.value[itemId]
|
||||
return {
|
||||
id: item?.id || itemId,
|
||||
src: item?.src || '',
|
||||
label: item?.label || '',
|
||||
origin: item?.origin || 'template',
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function syncSavedEditorSnapshot() {
|
||||
savedEditorSnapshot.value = createEditorSnapshot()
|
||||
}
|
||||
|
||||
const hasUnsavedChanges = computed(() => canEdit.value && savedEditorSnapshot.value && createEditorSnapshot() !== savedEditorSnapshot.value)
|
||||
|
||||
function removeItemFromGroup(groupId, columnIndex, itemId) {
|
||||
if (!canEdit.value || !groupId || columnIndex == null || !itemId) return
|
||||
const targetGroup = groups.value.find((group) => group.id === groupId)
|
||||
@@ -271,6 +320,87 @@ function removeItemFromGroup(groupId, columnIndex, itemId) {
|
||||
targetGroup.cells = nextCells
|
||||
syncGroupItemIds(targetGroup)
|
||||
pool.value = [itemId, ...pool.value.filter((id) => id !== itemId)]
|
||||
if (selectedItemId.value === itemId) selectedItemId.value = ''
|
||||
}
|
||||
|
||||
function shouldIgnoreItemClick() {
|
||||
return Date.now() - recentDragFinishedAt.value < 180
|
||||
}
|
||||
|
||||
function getItemLocation(itemId) {
|
||||
if (!itemId) return { type: null, groupId: '', columnIndex: -1, index: -1 }
|
||||
|
||||
const poolIndex = pool.value.findIndex((id) => id === itemId)
|
||||
if (poolIndex >= 0) {
|
||||
return { type: 'pool', groupId: '', columnIndex: -1, index: poolIndex }
|
||||
}
|
||||
|
||||
for (const group of groups.value) {
|
||||
for (let columnIndex = 0; columnIndex < columns.value.length; columnIndex += 1) {
|
||||
const index = getGroupCellIds(group, columnIndex).findIndex((id) => id === itemId)
|
||||
if (index >= 0) {
|
||||
return { type: 'group', groupId: group.id, columnIndex, index }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { type: null, groupId: '', columnIndex: -1, index: -1 }
|
||||
}
|
||||
|
||||
function detachItemById(itemId) {
|
||||
if (!itemId) return
|
||||
pool.value = pool.value.filter((id) => id !== itemId)
|
||||
groups.value.forEach((group) => {
|
||||
group.cells = (group.cells || []).map((cell) => (cell || []).filter((id) => id !== itemId))
|
||||
syncGroupItemIds(group)
|
||||
})
|
||||
}
|
||||
|
||||
function selectItemByClick(itemId) {
|
||||
if (!canEdit.value || !itemId || shouldIgnoreItemClick()) return
|
||||
selectedItemId.value = selectedItemId.value === itemId ? '' : itemId
|
||||
}
|
||||
|
||||
function placeSelectedItemInGroup(groupId, columnIndex) {
|
||||
if (!canEdit.value || !selectedItemId.value || !groupId || !Number.isInteger(columnIndex)) return
|
||||
if (shouldIgnoreItemClick()) return
|
||||
|
||||
const targetGroup = groups.value.find((group) => group.id === groupId)
|
||||
if (!targetGroup) return
|
||||
|
||||
const selectedId = selectedItemId.value
|
||||
const currentLocation = getItemLocation(selectedId)
|
||||
const sameTarget =
|
||||
currentLocation.type === 'group' &&
|
||||
currentLocation.groupId === groupId &&
|
||||
currentLocation.columnIndex === columnIndex
|
||||
|
||||
if (sameTarget) {
|
||||
selectedItemId.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
detachItemById(selectedId)
|
||||
const nextCells = [...targetGroup.cells]
|
||||
nextCells[columnIndex] = [...getGroupCellIds(targetGroup, columnIndex), selectedId]
|
||||
targetGroup.cells = nextCells
|
||||
syncGroupItemIds(targetGroup)
|
||||
selectedItemId.value = ''
|
||||
}
|
||||
|
||||
function moveSelectedItemToPool() {
|
||||
if (!canEdit.value || !selectedItemId.value || shouldIgnoreItemClick()) return
|
||||
|
||||
const selectedId = selectedItemId.value
|
||||
const currentLocation = getItemLocation(selectedId)
|
||||
if (currentLocation.type === 'pool') {
|
||||
selectedItemId.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
detachItemById(selectedId)
|
||||
pool.value = [selectedId, ...pool.value]
|
||||
selectedItemId.value = ''
|
||||
}
|
||||
|
||||
function setGroupDropEl(groupId, columnIndex, el) {
|
||||
@@ -344,6 +474,12 @@ async function initSortables() {
|
||||
draggable: '[data-item-id]',
|
||||
ghostClass: 'ghost',
|
||||
chosenClass: 'chosen',
|
||||
onStart: () => {
|
||||
selectedItemId.value = ''
|
||||
},
|
||||
onEnd: () => {
|
||||
recentDragFinishedAt.value = Date.now()
|
||||
},
|
||||
onSort: () => normalizeSort(poolEl.value),
|
||||
onAdd: () => normalizeSort(poolEl.value),
|
||||
})
|
||||
@@ -355,6 +491,12 @@ async function initSortables() {
|
||||
draggable: '[data-item-id]',
|
||||
ghostClass: 'ghost',
|
||||
chosenClass: 'chosen',
|
||||
onStart: () => {
|
||||
selectedItemId.value = ''
|
||||
},
|
||||
onEnd: () => {
|
||||
recentDragFinishedAt.value = Date.now()
|
||||
},
|
||||
onSort: () => normalizeSort(el),
|
||||
onAdd: () => normalizeSort(el),
|
||||
})
|
||||
@@ -716,6 +858,8 @@ async function persistTierList({ showModal = false } = {}) {
|
||||
authorAccountName.value = res.tierList?.authorAccountName || authorAccountName.value
|
||||
favoriteCount.value = Number(res.tierList?.favoriteCount || favoriteCount.value || 0)
|
||||
isFavorited.value = !!res.tierList?.isFavorited
|
||||
await nextTick()
|
||||
syncSavedEditorSnapshot()
|
||||
if (showModal) isSaveModalOpen.value = true
|
||||
return { ...res, savedTierListId }
|
||||
}
|
||||
@@ -758,6 +902,44 @@ async function copyShareUrl() {
|
||||
}
|
||||
}
|
||||
|
||||
function openViewerMode() {
|
||||
if (!canSwitchToViewerMode.value) return
|
||||
router.push(editorPath(templateId.value, persistedTierListId.value || tierListId.value, { preview: true }))
|
||||
}
|
||||
|
||||
function openEditMode() {
|
||||
if (!canSwitchToEditMode.value) return
|
||||
router.push(editorPath(templateId.value, persistedTierListId.value || tierListId.value))
|
||||
}
|
||||
|
||||
function closeNavigationConfirmModal() {
|
||||
isNavigationConfirmModalOpen.value = false
|
||||
pendingNavigationPath.value = ''
|
||||
}
|
||||
|
||||
function requestEditorNavigation(path) {
|
||||
if (!path) return
|
||||
if (hasUnsavedChanges.value) {
|
||||
pendingNavigationPath.value = path
|
||||
isNavigationConfirmModalOpen.value = true
|
||||
return
|
||||
}
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
function confirmNavigationDiscard() {
|
||||
const nextPath = pendingNavigationPath.value
|
||||
closeNavigationConfirmModal()
|
||||
if (!nextPath) return
|
||||
savedEditorSnapshot.value = ''
|
||||
router.push(nextPath)
|
||||
}
|
||||
|
||||
function openSourceTierList() {
|
||||
if (!sourceTierListId.value) return
|
||||
requestEditorNavigation(editorPath(templateId.value, sourceTierListId.value))
|
||||
}
|
||||
|
||||
function closeSaveModal() {
|
||||
isSaveModalOpen.value = false
|
||||
}
|
||||
@@ -897,73 +1079,156 @@ async function requestTemplate(type) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
;(async () => {
|
||||
await auth.refresh()
|
||||
authorName.value = (auth.user?.nickname || '').trim()
|
||||
authorAccountName.value = ((auth.user?.email || '').trim().split('@')[0] || '').trim()
|
||||
function resetEditorStateForRoute() {
|
||||
destroySortables()
|
||||
if (thumbnailPreviewUrl.value) {
|
||||
URL.revokeObjectURL(thumbnailPreviewUrl.value)
|
||||
thumbnailPreviewUrl.value = ''
|
||||
}
|
||||
columns.value = [{ id: 'col-1', name: '' }]
|
||||
groups.value = normalizeLoadedGroups([], columns.value)
|
||||
pool.value = []
|
||||
itemsById.value = {}
|
||||
title.value = ''
|
||||
persistedTierListId.value = ''
|
||||
thumbnailSrc.value = ''
|
||||
pendingThumbnailFile.value = null
|
||||
description.value = ''
|
||||
isPublic.value = true
|
||||
showCharacterNames.value = false
|
||||
isSaveModalOpen.value = false
|
||||
isTemplateRequestModalOpen.value = false
|
||||
isTemplateUpdateModalOpen.value = false
|
||||
isDeleteModalOpen.value = false
|
||||
isGroupDeleteModalOpen.value = false
|
||||
isColumnDeleteModalOpen.value = false
|
||||
isNavigationConfirmModalOpen.value = false
|
||||
pendingRemoveGroupId.value = ''
|
||||
pendingRemoveColumnIndex.value = -1
|
||||
pendingNavigationPath.value = ''
|
||||
ownerId.value = ''
|
||||
authorName.value = ''
|
||||
authorAccountName.value = ''
|
||||
updatedAt.value = 0
|
||||
sourceTierListId.value = ''
|
||||
sourceSnapshotTitle.value = ''
|
||||
sourceSnapshotAuthor.value = ''
|
||||
isDragActive.value = false
|
||||
isThumbnailDragActive.value = false
|
||||
iconSize.value = 80
|
||||
isFavoriteBusy.value = false
|
||||
favoriteCount.value = 0
|
||||
isFavorited.value = false
|
||||
isRequestingTemplate.value = false
|
||||
isDeleting.value = false
|
||||
poolSearchQuery.value = ''
|
||||
selectedItemId.value = ''
|
||||
recentDragFinishedAt.value = 0
|
||||
savedEditorSnapshot.value = ''
|
||||
resetTemplateRequestDrafts()
|
||||
}
|
||||
|
||||
if (isNewTierList.value && !auth.user) {
|
||||
router.replace(loginPath(editorNewPath(templateId.value)))
|
||||
return
|
||||
}
|
||||
async function loadEditorState() {
|
||||
const loadToken = ++editorLoadToken
|
||||
resetEditorStateForRoute()
|
||||
await auth.refresh()
|
||||
if (loadToken !== editorLoadToken) return
|
||||
|
||||
authorName.value = (auth.user?.nickname || '').trim()
|
||||
authorAccountName.value = ((auth.user?.email || '').trim().split('@')[0] || '').trim()
|
||||
|
||||
if (isNewTierList.value && !auth.user) {
|
||||
router.replace(loginPath(editorNewPath(templateId.value)))
|
||||
return
|
||||
}
|
||||
|
||||
let currentTemplateItems = []
|
||||
try {
|
||||
const topicRes = await api.getTopic(templateId.value)
|
||||
if (loadToken !== editorLoadToken) return
|
||||
|
||||
templateName.value = topicRes.topic?.name || templateId.value
|
||||
const base = (topicRes.items || []).map((img) => ({
|
||||
id: img.id,
|
||||
src: img.src,
|
||||
label: img.label,
|
||||
origin: 'template',
|
||||
}))
|
||||
currentTemplateItems = base
|
||||
const map = {}
|
||||
base.forEach((it) => (map[it.id] = it))
|
||||
itemsById.value = map
|
||||
pool.value = base.map((it) => it.id)
|
||||
} catch (e) {
|
||||
if (loadToken !== editorLoadToken) return
|
||||
error.value = '기본 템플릿 이미지를 불러오지 못했어요.'
|
||||
}
|
||||
|
||||
if (tierListId.value && tierListId.value !== 'new') {
|
||||
try {
|
||||
const topicRes = await api.getTopic(templateId.value)
|
||||
templateName.value = topicRes.topic?.name || templateId.value
|
||||
const base = (topicRes.items || []).map((img) => ({
|
||||
id: img.id,
|
||||
src: img.src,
|
||||
label: img.label,
|
||||
origin: 'template',
|
||||
}))
|
||||
const map = {}
|
||||
base.forEach((it) => (map[it.id] = it))
|
||||
itemsById.value = map
|
||||
pool.value = base.map((it) => it.id)
|
||||
} catch (e) {
|
||||
error.value = '기본 템플릿 이미지를 불러오지 못했어요.'
|
||||
}
|
||||
const res = await api.getTierList(tierListId.value)
|
||||
if (loadToken !== editorLoadToken) return
|
||||
|
||||
if (tierListId.value && tierListId.value !== 'new') {
|
||||
try {
|
||||
const res = await api.getTierList(tierListId.value)
|
||||
const t = res.tierList
|
||||
ownerId.value = t.authorId
|
||||
persistedTierListId.value = t.id || ''
|
||||
title.value = t.title
|
||||
thumbnailSrc.value = t.thumbnailSrc || ''
|
||||
description.value = t.description || ''
|
||||
isPublic.value = !!t.isPublic
|
||||
showCharacterNames.value = !!t.showCharacterNames
|
||||
iconSize.value = Number(t.iconSize || 80)
|
||||
authorName.value = t.authorName || ''
|
||||
authorAccountName.value = t.authorAccountName || ''
|
||||
updatedAt.value = Number(t.updatedAt || 0)
|
||||
sourceTierListId.value = t.sourceTierListId || ''
|
||||
sourceSnapshotTitle.value = t.sourceSnapshotTitle || ''
|
||||
sourceSnapshotAuthor.value = t.sourceSnapshotAuthor || ''
|
||||
favoriteCount.value = Number(t.favoriteCount || 0)
|
||||
isFavorited.value = !!t.isFavorited
|
||||
columns.value = normalizeLoadedColumns(t.groups)
|
||||
groups.value = normalizeLoadedGroups(t.groups, columns.value)
|
||||
const map = {}
|
||||
;(t.pool || []).forEach((it) => (map[it.id] = it))
|
||||
itemsById.value = map
|
||||
const grouped = new Set()
|
||||
groups.value.forEach((group) => group.itemIds.forEach((id) => grouped.add(id)))
|
||||
pool.value = Object.keys(itemsById.value).filter((id) => !grouped.has(id))
|
||||
} catch (e) {
|
||||
error.value = '티어표를 불러오지 못했어요.'
|
||||
const t = res.tierList
|
||||
ownerId.value = t.authorId
|
||||
persistedTierListId.value = t.id || ''
|
||||
title.value = t.title
|
||||
thumbnailSrc.value = t.thumbnailSrc || ''
|
||||
description.value = t.description || ''
|
||||
isPublic.value = !!t.isPublic
|
||||
showCharacterNames.value = !!t.showCharacterNames
|
||||
iconSize.value = Number(t.iconSize || 80)
|
||||
authorName.value = t.authorName || ''
|
||||
authorAccountName.value = t.authorAccountName || ''
|
||||
updatedAt.value = Number(t.updatedAt || 0)
|
||||
sourceTierListId.value = t.sourceTierListId || ''
|
||||
sourceSnapshotTitle.value = t.sourceSnapshotTitle || ''
|
||||
sourceSnapshotAuthor.value = t.sourceSnapshotAuthor || ''
|
||||
favoriteCount.value = Number(t.favoriteCount || 0)
|
||||
isFavorited.value = !!t.isFavorited
|
||||
|
||||
if (!previewMode.value && !canEdit.value) {
|
||||
router.replace(editorPath(templateId.value, t.id, { preview: true }))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
if (canEdit.value) {
|
||||
await initSortables()
|
||||
columns.value = normalizeLoadedColumns(t.groups)
|
||||
groups.value = normalizeLoadedGroups(t.groups, columns.value)
|
||||
const map = {}
|
||||
;(t.pool || []).forEach((it) => (map[it.id] = it))
|
||||
const grouped = new Set()
|
||||
groups.value.forEach((group) => group.itemIds.forEach((id) => grouped.add(id)))
|
||||
const merged = mergeLatestTemplateItemsIntoPool(
|
||||
map,
|
||||
Object.keys(map).filter((id) => !grouped.has(id)),
|
||||
currentTemplateItems,
|
||||
grouped,
|
||||
canEdit.value && !previewMode.value
|
||||
)
|
||||
itemsById.value = merged.nextMap
|
||||
pool.value = merged.nextPoolIds
|
||||
} catch (e) {
|
||||
if (loadToken !== editorLoadToken) return
|
||||
error.value = '티어표를 불러오지 못했어요.'
|
||||
}
|
||||
})()
|
||||
})
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
if (loadToken !== editorLoadToken) return
|
||||
|
||||
syncSavedEditorSnapshot()
|
||||
if (canEdit.value) {
|
||||
await initSortables()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [route.params.topicId, route.params.tierListId, route.query.preview],
|
||||
() => {
|
||||
loadEditorState()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
if (thumbnailPreviewUrl.value) URL.revokeObjectURL(thumbnailPreviewUrl.value)
|
||||
@@ -973,13 +1238,14 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<section v-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>
|
||||
<p v-if="description" class="pageHead__desc">{{ description }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="previewOnly__sheet">
|
||||
<a class="previewOnly__brand" :href="previewHomeUrl">
|
||||
<span class="previewOnly__brandMark">TM</span>
|
||||
<span class="previewOnly__brandText">Tier Maker</span>
|
||||
</a>
|
||||
<div class="previewOnly__title">{{ effectiveTitle }}</div>
|
||||
<div v-if="description" class="previewOnly__description">{{ description }}</div>
|
||||
<div v-if="columns.length > 1" class="previewOnly__columns">
|
||||
<div class="previewOnly__columnsSpacer" aria-hidden="true"></div>
|
||||
<div class="previewOnly__columnsGrid" :style="{ '--column-count': columns.length }">
|
||||
@@ -1015,6 +1281,31 @@ onUnmounted(() => {
|
||||
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport :to="localRightRailTarget">
|
||||
<template v-if="globalRightRailOpen">
|
||||
<RightRailAd />
|
||||
|
||||
<div class="viewerSidebar__section">
|
||||
<div class="viewerSidebar__eyebrow">Viewer Mode</div>
|
||||
<div class="viewerSidebar__title">공유 티어표 보기</div>
|
||||
<p class="viewerSidebar__desc">
|
||||
{{ canSwitchToEditMode ? '지금은 드래그 없는 뷰어 모드입니다. 원하면 바로 수정 모드로 돌아갈 수 있어요.' : '드래그나 편집 없이 완성본만 보는 뷰어 모드입니다.' }}
|
||||
</p>
|
||||
<div class="viewerSidebar__actions">
|
||||
<button v-if="hasSavedTierList" class="btn btn--ghost viewerSidebar__button" type="button" @click="copyShareUrl">
|
||||
공유하기
|
||||
</button>
|
||||
<button v-if="canDuplicate" class="btn btn--save viewerSidebar__button" type="button" @click="duplicateCurrentTierList">
|
||||
내 티어표로 복사
|
||||
</button>
|
||||
<button v-if="canSwitchToEditMode" class="btn btn--save viewerSidebar__button" type="button" @click="openEditMode">
|
||||
수정 모드로 전환
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Teleport>
|
||||
</section>
|
||||
|
||||
<template v-else>
|
||||
@@ -1029,6 +1320,19 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isNavigationConfirmModalOpen" class="modalOverlay" @click.self="closeNavigationConfirmModal">
|
||||
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="navigationConfirmTitle">
|
||||
<div id="navigationConfirmTitle" class="modalCard__title">원본 티어표로 이동</div>
|
||||
<div class="modalCard__desc">
|
||||
아직 저장하지 않은 수정 내용이 있어요. 이대로 이동하면 현재 변경 내용은 사라집니다.
|
||||
</div>
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--ghost" type="button" @click="closeNavigationConfirmModal">계속 편집</button>
|
||||
<button class="btn btn--danger" type="button" @click="confirmNavigationDiscard">저장 없이 이동</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isTemplateRequestModalOpen" class="modalOverlay" @click.self="closeTemplateRequestModal">
|
||||
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="templateRequestTitle">
|
||||
<div id="templateRequestTitle" class="modalCard__title">템플릿 등록 요청</div>
|
||||
@@ -1146,8 +1450,8 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="sourceTierListId" class="editorMain__sourceNote">
|
||||
<span>복사본</span>
|
||||
<button class="editorMain__sourceLink" type="button" @click="router.push(editorPath(templateId, sourceTierListId))">{{ copiedFromLabel }}</button>
|
||||
<span>원본</span>
|
||||
<button class="editorMain__sourceLink" type="button" @click="openSourceTierList">{{ copiedFromLabel }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1227,10 +1531,19 @@ onUnmounted(() => {
|
||||
:data-group-id="g.id"
|
||||
:data-column-index="columnIndex"
|
||||
:ref="(el) => setGroupDropEl(g.id, columnIndex, el)"
|
||||
:class="{ 'row__drop--clickTarget': canEdit && !!selectedItemId }"
|
||||
@click="placeSelectedItemInGroup(g.id, columnIndex)"
|
||||
>
|
||||
<div v-if="columns.length > 1" class="row__columnBadge">{{ column.name || '열 ' + (columnIndex + 1) }}</div>
|
||||
<div v-if="!isExporting" class="row__empty" v-show="getGroupCellIds(g, columnIndex).length === 0">여기로 드래그해서 배치</div>
|
||||
<div v-for="id in getGroupCellIds(g, columnIndex)" :key="id" class="cell" :data-item-id="id">
|
||||
<div
|
||||
v-for="id in getGroupCellIds(g, columnIndex)"
|
||||
:key="id"
|
||||
class="cell"
|
||||
:class="{ 'cell--selected': selectedItemId === id }"
|
||||
:data-item-id="id"
|
||||
@click.stop="selectItemByClick(id)"
|
||||
>
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
|
||||
<button
|
||||
@@ -1284,7 +1597,7 @@ onUnmounted(() => {
|
||||
<div class="sidebar__count">{{ visiblePoolCount }} / {{ pool.length }}</div>
|
||||
</div>
|
||||
<div class="sidebar__hint">
|
||||
{{ canEdit ? '등록된 아이템 리스트입니다. 드래그해서 표에 넣을 수 있습니다.' : '공개 티어표는 보기 전용입니다.' }}
|
||||
{{ canEdit ? '아이템을 드래그하거나, 클릭으로 선택한 뒤 원하는 셀/풀을 클릭해서 옮길 수 있어요.' : '공개 티어표는 보기 전용입니다.' }}
|
||||
</div>
|
||||
<input
|
||||
v-model="poolSearchQuery"
|
||||
@@ -1293,13 +1606,24 @@ onUnmounted(() => {
|
||||
maxlength="60"
|
||||
placeholder="아이템 이름 검색"
|
||||
/>
|
||||
<div ref="poolEl" class="pool" data-list-type="pool">
|
||||
<div
|
||||
ref="poolEl"
|
||||
class="pool"
|
||||
:class="{ 'pool--clickTarget': canEdit && !!selectedItemId }"
|
||||
data-list-type="pool"
|
||||
@click.self="moveSelectedItemToPool"
|
||||
>
|
||||
<div
|
||||
v-for="id in pool"
|
||||
:key="id"
|
||||
class="poolItem"
|
||||
:class="{ 'poolItem--readonly': !canEdit, 'poolItem--hidden': !isPoolItemVisible(id) }"
|
||||
:class="{
|
||||
'poolItem--readonly': !canEdit,
|
||||
'poolItem--hidden': !isPoolItemVisible(id),
|
||||
'poolItem--selected': selectedItemId === id,
|
||||
}"
|
||||
:data-item-id="id"
|
||||
@click.stop="selectItemByClick(id)"
|
||||
>
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
||||
@@ -1401,6 +1725,7 @@ onUnmounted(() => {
|
||||
<button v-if="canEdit" class="btn btn--save editorSidebar__button" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
|
||||
</div>
|
||||
<div class="editorSidebar__utilityLinks">
|
||||
<button v-if="canSwitchToViewerMode" class="editorSidebar__utilityLink" @click="openViewerMode">뷰어 모드로 보기</button>
|
||||
<button v-if="hasSavedTierList" class="editorSidebar__utilityLink editorSidebar__utilityLink--share" @click="copyShareUrl">
|
||||
<SvgIcon :src="shareIcon" :size="16" />
|
||||
<span>공유하기</span>
|
||||
@@ -1475,55 +1800,18 @@ onUnmounted(() => {
|
||||
cursor: pointer;
|
||||
}
|
||||
.previewOnly {
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(96, 165, 250, 0.14), transparent 38%),
|
||||
var(--theme-shell-bg);
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.previewOnly__sheet {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.previewOnly__brand {
|
||||
width: fit-content;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-decoration: none;
|
||||
color: var(--theme-text-strong);
|
||||
}
|
||||
.previewOnly__brandMark {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--theme-accent-bg);
|
||||
color: var(--theme-accent-text);
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.previewOnly__brandText {
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.previewOnly__title {
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.previewOnly__description {
|
||||
margin-top: -8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
opacity: 0.76;
|
||||
padding: 18px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--theme-card-border);
|
||||
background: var(--theme-card-bg);
|
||||
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
|
||||
}
|
||||
.previewOnly__columns {
|
||||
display: grid;
|
||||
@@ -1622,6 +1910,48 @@ onUnmounted(() => {
|
||||
color: var(--theme-text-soft);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.viewerSidebar__section {
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 18px;
|
||||
border-radius: 22px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-pill-bg);
|
||||
}
|
||||
|
||||
.viewerSidebar__eyebrow {
|
||||
color: var(--theme-text-faint);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.viewerSidebar__title {
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--theme-text-strong);
|
||||
}
|
||||
|
||||
.viewerSidebar__desc {
|
||||
margin: 0;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.viewerSidebar__actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.viewerSidebar__button {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
margin-top: 0;
|
||||
}
|
||||
.toggleSwitch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -2141,6 +2471,11 @@ onUnmounted(() => {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.row__drop--clickTarget {
|
||||
cursor: copy;
|
||||
border-color: rgba(96, 165, 250, 0.42);
|
||||
box-shadow: inset 0 0 0 1px rgba(96, 165, 250, 0.12);
|
||||
}
|
||||
.row__empty {
|
||||
opacity: 0.6;
|
||||
font-size: 13px;
|
||||
@@ -2154,6 +2489,11 @@ onUnmounted(() => {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.cell--selected {
|
||||
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.92), 0 0 0 6px rgba(96, 165, 250, 0.18);
|
||||
}
|
||||
.itemNameOverlay {
|
||||
position: absolute;
|
||||
@@ -2533,6 +2873,9 @@ onUnmounted(() => {
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
}
|
||||
.pool--clickTarget {
|
||||
cursor: copy;
|
||||
}
|
||||
.poolItem {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
@@ -2544,11 +2887,17 @@ onUnmounted(() => {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||
background: var(--theme-pill-bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.poolItem--readonly {
|
||||
cursor: default;
|
||||
opacity: 0.58;
|
||||
filter: grayscale(0.25) brightness(0.78);
|
||||
}
|
||||
.poolItem--selected {
|
||||
border-color: rgba(96, 165, 250, 0.58);
|
||||
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.92), 0 0 0 6px rgba(96, 165, 250, 0.18);
|
||||
}
|
||||
.poolItem .thumb {
|
||||
width: 100%;
|
||||
max-width: var(--thumb-size, 80px);
|
||||
|
||||
@@ -96,7 +96,8 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pageHead">
|
||||
<section class="pageWrap">
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Collection</div>
|
||||
<h2 class="pageHead__title">{{ topicTitle }}</h2>
|
||||
@@ -106,10 +107,10 @@ watch(
|
||||
<input v-model="query" class="input" placeholder="제목 또는 작성자 검색" @keydown.enter.prevent="submitSearch" />
|
||||
<button class="btn" @click="submitSearch">검색</button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<section class="panel">
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<section class="panel">
|
||||
<div v-if="tierLists.length === 0" class="empty">아직 공개 티어표가 없어요.</div>
|
||||
<div v-else class="list" :class="{ 'list--table': isListView }">
|
||||
<article v-for="t in tierLists" :key="t.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
|
||||
@@ -137,6 +138,7 @@ watch(
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user