Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f8e623b56 | |||
| 5dbc83c79e | |||
| 110242f8e9 | |||
| 4fbd4a2845 | |||
| 76de4b940a | |||
| 3163a671de | |||
| b6e382468c | |||
| f9702a50a1 |
13
.env.production
Normal file
13
.env.production
Normal file
@@ -0,0 +1,13 @@
|
||||
MARIADB_ROOT_PASSWORD=wps!xldj180204
|
||||
MARIADB_DATABASE=tier_db
|
||||
MARIADB_USER=zenn
|
||||
MARIADB_PASSWORD=wps!xldj180204
|
||||
SESSION_SECRET=291fbf7d5d112786a84d8ff62dd2839ce343a820c47d88da746ec78e11d640b3
|
||||
APP_ORIGIN=https://tmaker.sori.studio
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=465
|
||||
SMTP_SECURE=true
|
||||
SMTP_USER=zenn.sori.studio@gmail.com
|
||||
SMTP_PASS=kcasoehxcspqdoxz
|
||||
SMTP_FROM="Tier Maker <zenn.sori.studio@gmail.com>"
|
||||
NICKNAME_CHANGE_INTERVAL_DAYS=14
|
||||
@@ -32,10 +32,10 @@ const EMAIL_VERIFICATION_TTL_MS = 24 * 60 * 60 * 1000
|
||||
const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000
|
||||
|
||||
function resolveNicknameChangeIntervalMs() {
|
||||
const rawMs = String(process.env.NICKNAME_CHANGE_INTERVAL_MS || '').trim()
|
||||
if (rawMs) {
|
||||
const parsed = Number(rawMs)
|
||||
if (Number.isFinite(parsed) && parsed >= 0) return parsed
|
||||
const rawHours = String(process.env.NICKNAME_CHANGE_INTERVAL_HOURS || '').trim()
|
||||
if (rawHours) {
|
||||
const parsed = Number(rawHours)
|
||||
if (Number.isFinite(parsed) && parsed >= 0) return parsed * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
const rawDays = String(process.env.NICKNAME_CHANGE_INTERVAL_DAYS || '').trim()
|
||||
@@ -44,7 +44,7 @@ function resolveNicknameChangeIntervalMs() {
|
||||
if (Number.isFinite(parsed) && parsed >= 0) return parsed * 24 * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
return 14 * 24 * 60 * 60 * 1000
|
||||
return 0
|
||||
}
|
||||
|
||||
function formatNicknameChangeIntervalLabel(intervalMs) {
|
||||
|
||||
@@ -1,10 +1,36 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-04-07 v1.1.29
|
||||
- 프리뷰 여부는 페이지 맥락과 우측 뷰어 카드만으로 충분히 전달되므로, 헤더 아이브로우에 `Preview`를 한 줄 더 두는 것은 중복 정보라고 정리했다.
|
||||
- `커스텀 티어표 만들기`는 버튼 문구로 다소 길고 반복될수록 무거워 보여, 주요 CTA 에서는 `커스텀 티어표`로 짧게 통일하는 편이 더 낫다고 판단했다.
|
||||
- 스켈레톤과 드래그 라이브러리를 함께 쓸 때는 “실제 드래그 대상 DOM이 렌더링된 뒤에만 초기화”가 핵심이라고 다시 확인했다. 로딩 placeholder 상태에서 초기화한 `Sortable` 인스턴스는 비어 있는 타깃을 잡아 드래그 회귀를 만들 수 있다.
|
||||
|
||||
## 2026-04-07 v1.1.27
|
||||
- 티어표 화면 헤더도 다른 주요 페이지와 같은 `eyebrow / title / desc` 문법을 따라야 전체 앱 톤이 정리된다고 판단했다.
|
||||
- 편집 가능한 화면과 보기 전용 화면은 헤더에서 전달해야 할 정보가 다르므로, 같은 문법은 유지하되 내용 규칙은 분리하는 편이 맞다고 정리했다. 편집 화면은 “무엇을 만들고 있는가”, 보기 화면은 “무엇을 보고 있는가”가 더 중요하다.
|
||||
- 템플릿 이동 액션은 제목보다 아이브로우가 더 자연스럽다. 제목은 티어표 자체 이름으로 남겨두고, 상위 맥락인 템플릿 이름만 링크 역할을 맡기는 쪽이 이해하기 쉽다고 정리했다.
|
||||
|
||||
## 2026-04-07 v1.1.26
|
||||
- `주제 불러오는 중...` 같은 임시 문구는 스켈레톤보다 더 거슬리게 보일 수 있으므로, 짧은 로딩 구간에서는 텍스트 fallback 대신 빈 자리 + 스켈레톤으로 처리하는 편이 낫다고 정리했다.
|
||||
- 저장용 랜덤 제목이나 내부 `tierListId`는 시스템 식별자 역할일 뿐 화면 표시용 카피가 아니므로, 사용자가 보는 제목 fallback 으로 직접 노출하지 않는 쪽이 맞다고 정리했다.
|
||||
- 같은 컴포넌트 안에서 라우트 파라미터만 바뀌는 화면은 일반 watch 보다 더 이른 시점에 로딩 상태를 세우는 편이 깜빡임 완화에 유리하다고 판단했다.
|
||||
|
||||
## 2026-04-07 v1.1.25
|
||||
- 이번 로딩 문제는 “매우 짧은 대기시간 + 미완성 실제 화면 노출”이 핵심이므로, 풀스크린 로딩 화면을 계속 번쩍 띄우는 것보다 `초기 인증 게이트 + 페이지 스켈레톤` 조합이 더 맞다고 정리했다.
|
||||
- 로그인 상태가 아직 hydrate 되지 않았을 때는 비로그인 UI를 잠깐 먼저 보여주지 않고, 최소 시간 있는 부트 게이트로 가려 두는 편이 완성도가 높다고 판단했다.
|
||||
- 티어표 화면도 실제 데이터가 오기 전 내부 ID나 빈 보드가 먼저 보이면 사이트가 덜 다듬어진 인상을 주므로, “미완성 진짜 화면”보다 “완성형 뼈대 화면”을 먼저 보여주는 쪽으로 정리했다.
|
||||
- 에디터 내부에서 인증 복원을 매번 다시 치는 구조는 최초 진입 외에는 체감 지연만 늘릴 수 있으므로, 앱 셸에서 이미 hydrate 된 경우에는 중복 `refresh()`를 피하는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-07 v1.1.18
|
||||
- 설정 화면은 자주 바꾸지 않는 계정 정보를 상시 입력 폼으로 펼쳐두기보다, 현재 상태를 먼저 보여주고 필요할 때만 모달로 수정하는 편이 더 차분하고 완성도 높게 보인다고 정리했다.
|
||||
- 닉네임은 공개 작성자 이름에 직접 반영되는 정보라 악용 가능성을 줄이기 위해 2주 제한을 두는 편이 맞다고 판단했다. 초기 가입 시점의 닉네임도 같은 규칙에 포함되도록 가입 시각을 기본 기준선으로 삼는다.
|
||||
- 다만 이 제한은 테스트와 운영 상황에 따라 조절할 수 있어야 하므로, 기간 자체는 코드 고정보다 환경변수로 바꾸는 편이 맞다고 정리했다. `0`으로 꺼서 QA하거나, `1일` 같은 짧은 값으로 운영 실험을 할 수 있어야 한다.
|
||||
- 프로필 이미지는 자주 다루지 않는 항목이고 변경 직후 결과를 바로 확인할 수 있으므로, 별도 저장 버튼보다 자동 저장이 더 자연스럽다고 정리했다. 대신 닉네임/비밀번호/로그아웃처럼 명시적 행위가 필요한 액션은 작은 아이콘 버튼으로 분리한다.
|
||||
- 닉네임 제한은 설정 본문에 계속 설명을 남기기보다, 버튼이 나타나는 조건과 모달 내부 안내로만 전달하는 편이 더 깔끔하다고 정리했다.
|
||||
- 이메일은 현재 시스템에서 개인 설정 수정 흐름보다 로그인 식별자 의미가 더 강하고, 인증/중복/세션 전환을 함께 다뤄야 하므로 일단 읽기 전용으로 분리해 두는 편이 맞다고 정리했다.
|
||||
- 환경변수 이름만 설명하는 것보다 실제 배포 파일에 샘플 값을 한 줄 남겨두는 편이 운영자 입장에서 훨씬 덜 헷갈리므로, `.env.production`에 20일 예시를 직접 두는 편이 낫다고 정리했다.
|
||||
- 모바일에서는 왼쪽 레일까지 상단에 고정해 둘 필요가 없고, 콘텐츠 영역을 넓히는 편이 더 중요하다고 판단했다. 그래서 `860px` 이하에서는 좌우 레일을 모두 오버레이로 띄우고, 목록 보기 전환 버튼도 모바일에서는 숨기는 쪽이 더 단순하다고 정리했다.
|
||||
- 편집 화면 상단의 템플릿 제목은 같은 화면 안 스크롤보다 “이 주제의 다른 공개 티어표와 원본 템플릿으로 돌아가는 입구” 역할이 더 중요하다고 판단했다. 그래서 제목 클릭을 주제 허브 이동으로 바꾸되, 미저장 변경 보호는 기존 확인 모달을 재사용하는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-07 v1.1.17
|
||||
- 가이드 모달은 같은 기능의 이동 수단을 중복으로 두기보다, 화살표와 점 네비게이션만 유지하는 편이 더 깔끔하다고 정리했다.
|
||||
|
||||
11
docs/map.md
11
docs/map.md
@@ -12,12 +12,12 @@
|
||||
|
||||
## `/topics/:topicId`
|
||||
- 화면 파일: `frontend/src/views/TopicHubView.vue`
|
||||
- 역할: 선택한 주제 slug 기준 정보 표시, 관리자 추천 티어표 상단 강조 섹션과 일반 공개 티어표 목록 분리 표시, 왼쪽 공통 검색창으로 해당 주제의 공개 티어표만 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
|
||||
- 역할: 선택한 주제 slug 기준 정보 표시, 관리자 추천 티어표 상단 강조 섹션과 일반 공개 티어표 목록 분리 표시, 왼쪽 공통 검색창으로 해당 주제의 공개 티어표만 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입하며, 주제명은 데이터 준비 전 `주제 불러오는 중...` 같은 텍스트 대신 제목 영역 스켈레톤으로 대기
|
||||
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`
|
||||
|
||||
## `/editor/:topicId/new`, `/editor/:topicId/:tierListId`
|
||||
- 화면 파일: `frontend/src/views/TierEditorView.vue`
|
||||
- 역할: 주제 slug 기반 에디터 진입, 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 아이템 클릭 선택 후 셀/풀 재배치, 아이템 우클릭 메뉴 기반 복제본 생성, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 읽기 전용 상태의 즐겨찾기 단독 CTA, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, 댓글 카드 표시, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰와 하단 댓글 카드를 렌더링하며, 우측 뷰어 카드(`공유 티어표 보기`)는 스폰서 카드 바로 아래에서 유지하고 즐겨찾기 CTA도 함께 노출
|
||||
- 역할: 주제 slug 기반 에디터 진입, 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 아이템 클릭 선택 후 셀/풀 재배치, 아이템 우클릭 메뉴 기반 복제본 생성, 헤더는 공통 `pageHead` 문법을 따르며 편집 가능 상태에서는 `NEW/EDIT + 템플릿 이름 + 작업 가이드`, 보기 전용 상태에서는 `템플릿 이름 + 티어표 제목 + 설명` 구조를 사용하고 아이브로우 클릭 시 해당 주제 허브로 이동하며 별도 `Preview` 아이브로우는 두지 않음, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 읽기 전용 상태의 즐겨찾기 단독 CTA, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, 댓글 카드 표시, 데이터가 준비되기 전에는 편집 모드/프리뷰 모드 모두 전용 스켈레톤 레이아웃을 먼저 보여주고, 저장용 랜덤 제목이나 내부 ID는 화면 표시용 제목으로 직접 노출하지 않으며, 스켈레톤이 내려간 뒤 실제 보드/풀 DOM에서만 드래그 초기화를 실행하고, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰와 하단 댓글 카드를 렌더링하며, 우측 뷰어 카드(`공유 티어표 보기`)는 스폰서 카드 바로 아래에서 유지하고 즐겨찾기 CTA도 함께 노출
|
||||
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/:id`, `GET /api/tierlists/:id/comments`, `POST /api/tierlists/:id/comments`, `DELETE /api/tierlists/:id/comments/:commentId`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`, `POST /api/tierlists/template-request`
|
||||
|
||||
## `/comments`
|
||||
@@ -62,18 +62,19 @@
|
||||
|
||||
## `/profile`
|
||||
- 화면 파일: `frontend/src/views/ProfileView.vue`
|
||||
- 역할: `settingsThemePanel` 계열 톤의 요약 카드 중심 설정 화면, 아바타 원형 버튼 클릭 기반 프로필 이미지 선택/삭제 즉시 저장, 닉네임 현재 상태와 변경 제한 안내 및 아이콘 버튼 기반 닉네임 변경 모달, 비밀번호 변경 아이콘 버튼, 이메일 읽기 전용 표시, 로그아웃 아이콘 버튼 처리
|
||||
- 역할: `settingsThemePanel` 계열 톤의 요약 카드 중심 설정 화면, 아바타 원형 버튼 클릭 기반 프로필 이미지 선택/삭제 즉시 저장, 닉네임 변경 가능 시점에만 노출되는 아이콘 버튼 기반 닉네임 변경 모달, 비밀번호 변경 아이콘 버튼, 로그인 계정 이메일의 읽기 전용 표시, 로그아웃 아이콘 버튼 처리
|
||||
- 연동 API: `GET /api/auth/me`, `POST /api/auth/profile`, `POST /api/auth/password`
|
||||
|
||||
## 공통 레이아웃
|
||||
- 앱 셸 파일: `frontend/src/App.vue`
|
||||
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, `홈 / 템플릿 / 댓글 관리` 네비게이션과 화면별 검색 placeholder 전환, `preview=1` 공유 프리뷰에서도 같은 좌우 레일과 중앙 헤더 유지, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 댓글 알림 unread dot, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링, 관리자/에디터 화면이 Teleport로 사용하는 `#local-right-rail-root` 대상 DOM을 상시 유지해 라우트 전환 중 우측 레일 언마운트 순서를 안정화, `S/ㄴ`, `G/ㅎ`, `L/ㅣ`, `A/ㅁ` 같은 전역 단축키 처리, 설정 가이드 모달 단계 이동/높이 안정화
|
||||
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker`와 서비스 설명이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
|
||||
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, `홈 / 템플릿 / 댓글 관리` 네비게이션과 화면별 검색 placeholder 전환, `preview=1` 공유 프리뷰에서도 같은 좌우 레일과 중앙 헤더 유지, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 댓글 알림 unread dot, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링, 앱 최초 진입 시 `auth.refresh()`와 최소 140ms를 묶은 `bootGate` 초기 스켈레톤 표시, 관리자/에디터 화면이 Teleport로 사용하는 `#local-right-rail-root` 대상 DOM을 상시 유지해 라우트 전환 중 우측 레일 언마운트 순서를 안정화, `S/ㄴ`, `G/ㅎ`, `L/ㅣ`, `A/ㅁ` 같은 전역 단축키 처리, 설정 가이드 모달 단계 이동/높이 안정화
|
||||
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 모바일(`860px` 이하)에서는 좌우 패널 모두 오버레이로 뜨며, 중앙 헤더 오른쪽 버튼으로 각각 열고 닫는다. 중앙 헤더의 브랜드 `Tier Maker`는 홈(`/`)으로 이동하는 터치 타겟으로 유지한다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
|
||||
|
||||
## 백엔드 진입점
|
||||
- 서버 엔트리: `backend/index.js`
|
||||
- 데이터 초기화: `backend/src/db.js`
|
||||
- 세부: 댓글/알림 관련 테이블(`tierlist_comments`, `comment_notifications`)은 여기서 생성되고, 기존 DB에 누락된 컬럼이 있으면 서버 시작 시 자동 보강한다.
|
||||
- 운영 환경 변수 예시: 프로젝트 루트 [`.env.production`](/Users/bicute/Desktop/zenn.dev/tmaker/.env.production) 에 `NICKNAME_CHANGE_INTERVAL_DAYS=20` 를 두면 닉네임 재변경 대기 기간을 20일로 맞출 수 있다.
|
||||
- 로컬 DB 실행 설정: `docker-compose.yml`
|
||||
- 로컬 MariaDB 가이드: `docs/local-mariadb.md`
|
||||
- 인증 라우트: `backend/src/routes/auth.js`
|
||||
|
||||
15
docs/spec.md
15
docs/spec.md
@@ -12,6 +12,8 @@
|
||||
- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, `preview=1` 모드에서도 같은 셸을 유지한 채 중앙 본문만 완성본 프리뷰로 렌더링한다.
|
||||
- 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 우측 패널은 상단 토글 버튼으로 접고 펼칠 수 있다.
|
||||
- 좌측 패널은 필요 시 축소형 레일로 접을 수 있으며, 접힌 상태에서는 아이콘 중심 내비게이션과 축약된 바로가기만 유지한다.
|
||||
- 모바일(`860px` 이하)에서는 좌측 패널도 고정 열을 차지하지 않고, 우측 패널과 같은 오버레이 방식으로 띄운다.
|
||||
- 앱 최초 진입 시에는 인증 상태 복원과 최소 140ms 대기 시간을 함께 묶은 `bootGate` 초기 게이트를 먼저 보여주고, 그 뒤에만 실제 앱 셸을 렌더링한다.
|
||||
- 이 3단 셸 구조는 홈, 게임 허브, 에디터, 관리자 등 일반 페이지 전반의 공통 뼈대로 유지하고, 페이지별 차이는 중앙/우측에 어떤 콘텐츠를 넣는지만 달라지도록 관리한다.
|
||||
- 비로그인 상태의 로그인 유도는 좌측 하단 버튼으로만 노출하고, 좌측 상단 사용자 카드 영역에는 별도 게스트 안내 카드를 렌더링하지 않는다.
|
||||
- 공통 셸의 좌측 내비, 우측 패널, 빠른 점프 버튼은 간단한 선형 SVG 아이콘과 두꺼운 카드형 버튼 문법을 공유한다.
|
||||
@@ -49,6 +51,7 @@
|
||||
- `featuredTierLists`: 상단 추천 티어표
|
||||
- `tierLists`: 추천 제외 최신 공개 티어표
|
||||
- 홈, 템플릿, 나의 티어표, 즐겨찾기, 팔로우 피드 화면은 공통 `viewToggle`로 `그리드 / 리스트` 보기를 전환하며, 상태는 현재 라우트의 `?view=list` 쿼리로 반영한다.
|
||||
- 단, 모바일 브레이크포인트(`860px` 이하)에서는 `viewToggle`을 노출하지 않는다.
|
||||
- 전역 단축키
|
||||
- `S/ㄴ`: 검색 포커스. 편집 화면에서는 아이템 검색창, 그 외 화면에서는 왼쪽 공통 검색창
|
||||
- `G/ㅎ`: 그리드 보기
|
||||
@@ -56,7 +59,10 @@
|
||||
- `A/ㅁ`: 관리자 계정일 때 관리자 화면으로 이동
|
||||
- 설정의 가이드 모달은 좌우 화살표, 점 네비게이션, 좌측 단계 목록으로만 이동하고, 설명 영역은 최소 4줄 높이를 유지해 페이지별 높이 차이를 줄인다.
|
||||
- 설정(`/profile`) 화면은 상시 입력 폼보다 `현재 상태 요약 카드 + 필요 시 모달 편집` 흐름을 기본으로 한다. 닉네임과 비밀번호는 작은 액션 버튼으로만 모달을 열어 변경하고, 이메일은 현재 로그인 계정 정보로 읽기 전용 표시를 유지한다. 프로필 이미지는 아바타 원형 버튼 자체를 눌러 변경하며, 선택/삭제 시 즉시 자동 저장한다.
|
||||
- 이메일은 로그인 계정 식별자 역할을 하므로 현재 개인 설정 화면에서는 변경 기능을 제공하지 않는다.
|
||||
- 닉네임 카드 본문에는 제한 설명을 상시 노출하지 않고, 변경 가능한 시점에만 아이콘 버튼을 보여준다. 제한 안내는 닉네임 변경 모달과 가이드 문구에서만 전달한다.
|
||||
- 닉네임 변경 제한 기간은 기본 14일이지만, 서버 환경변수 `NICKNAME_CHANGE_INTERVAL_MS` 또는 `NICKNAME_CHANGE_INTERVAL_DAYS`로 조절할 수 있다. `0`이면 제한을 끈다. 인증 응답에는 `nicknameUpdatedAt`, `nicknameChangeAvailableAt`, `nicknameChangeIntervalMs`, `nicknameChangeIntervalLabel`를 함께 포함하고, 프로필 저장 API는 제한 기간 안의 닉네임 변경 요청에 `nickname_change_locked` 오류를 반환한다.
|
||||
- 운영 환경 예시로는 `.env.production`에 `NICKNAME_CHANGE_INTERVAL_DAYS=20`처럼 정수 일수를 넣어 주기를 바꾼다.
|
||||
- 왼쪽 공통 검색창은 현재 화면 범위만 검색한다.
|
||||
- 홈: 전체 공개 티어표
|
||||
- 템플릿: 공개 템플릿
|
||||
@@ -65,6 +71,8 @@
|
||||
- 팔로우 피드: 팔로우한 작성자의 공개 티어표
|
||||
- 즐겨찾기: 내가 즐겨찾기한 티어표
|
||||
- 위 네 화면의 목록 데이터는 현재 페이지네이션이나 무한 스크롤 없이 조회 결과 전체를 한 번에 렌더링한다.
|
||||
- 특정 주제 화면(`TopicHubView`)의 헤더는 데이터 준비 전 문자열 fallback 대신 제목/설명 스켈레톤만 보여준다.
|
||||
- `freeform` 시작 액션과 주요 CTA 문구는 `커스텀 티어표`로 통일한다.
|
||||
- 저장된 티어표에는 댓글 스레드가 붙을 수 있다. 작성자 본인 편집 화면에서는 `작업 팁` 아래, 작성자가 아닌 사용자의 보기 전용 화면에서는 `preview` 보드 아래에서 같은 댓글 카드를 사용한다.
|
||||
- 댓글 알림 메뉴는 좌측 사이드 `댓글 관리`로 노출하며, 읽지 않은 댓글이 하나라도 있으면 빨간 dot을 표시한다.
|
||||
- 댓글 관리(`/comments`)는 기본적으로 `안 읽은 댓글만 보기`를 켠 상태로 시작한다.
|
||||
@@ -79,6 +87,7 @@
|
||||
- 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다.
|
||||
- 공통 토글 버튼은 패널이 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 각각 아이콘만 표시하는 방식으로 동작한다.
|
||||
- 오른쪽 패널 토글은 열기/닫기 모두 `dock_to_left`, 왼쪽 패널 토글은 `dock_to_right` 아이콘으로 통일한다.
|
||||
- 모바일에서는 중앙 `workspaceHead` 오른쪽에 좌/우 패널 버튼을 함께 두고, 브랜드 타이틀을 터치하면 홈(`/`)으로 이동한다.
|
||||
- 좌우 레일의 주요 CTA는 스크롤되는 본문과 분리된 하단 `56px` 액션 영역에 배치한다.
|
||||
- 하단 액션은 화면 바닥에 바로 붙지 않도록 푸터 내부에 추가 하단 여백을 둔다.
|
||||
- 홈 화면 기준 우측 패널은 임시 정보 카드 여러 개보다 핵심 CTA 하나만 남겨, 시안처럼 단순한 보조 레일 역할을 우선 유지한다.
|
||||
@@ -87,7 +96,13 @@
|
||||
- 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다.
|
||||
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 보드 영역은 메인 컬럼에, 우측 `320px` 편집 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
|
||||
- 공통 상단 토글 버튼은 Teleport로 이동한 로컬 편집 패널의 접힘/펼침 상태와도 연결되어, 우측 패널을 숨기면 중앙 보드 영역이 확장된다.
|
||||
- 편집 모드와 `preview=1` 뷰어 모드 모두 제목/보드/우측 패널 데이터가 준비되기 전까지는 실제 화면 대신 전용 스켈레톤 레이아웃을 먼저 보여준다.
|
||||
- 저장용 랜덤 제목은 내부 저장/도배 방지용으로만 쓰고, 화면 표시용 제목 fallback 에서는 내부 `tierListId`나 랜덤 문자열을 직접 노출하지 않는다.
|
||||
- 헤더 문법은 공통 `pageHead` 흐름을 따르되, 편집 가능 상태에서는 `NEW/EDIT + 현재 템플릿 이름 + 작업 가이드`, 보기 전용 상태에서는 `템플릿 이름 + 티어표 제목 + 티어표 설명` 규칙을 사용한다.
|
||||
- 보기 전용 헤더에는 별도 `Preview` 아이브로우를 두지 않고, 템플릿 이름 아이브로우만 남긴다.
|
||||
- 드래그 초기화는 스켈레톤이 내려간 뒤 실제 보드/풀 DOM이 렌더링된 다음 `Sortable`을 붙이는 순서를 따른다.
|
||||
- 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다. 템플릿 등록/업데이트 요청 버튼은 저장된 티어표가 있을 때만 노출하며, 제목이 비어 있는 상태에서 저장하면 랜덤 고유 제목을 먼저 부여해 저장본을 만든다.
|
||||
- 보기 전용 상태에서 상단 아이브로우(템플릿 이름)는 해당 주제 허브로 이동하는 액션으로 사용하며, 편집 중 미저장 변경이 있으면 이동 전에 확인 모달을 띄운다.
|
||||
- 보드 바로 옆에는 드래그용 아이템 풀을 별도 패널로 두고, 커스텀 아이템 이름 정리 목록은 우측 편집 패널 내부에서 관리한다.
|
||||
- 관리자 화면
|
||||
- 공통 우측 패널 대신 전용 로컬 운영 패널을 사용한다.
|
||||
|
||||
18
docs/todo.md
18
docs/todo.md
@@ -1,12 +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` 이후 템플릿 A에서 템플릿 B로 빠르게 연속 이동해도 이전 주제의 공개 티어표 카드가 잠깐 남지 않는지 확인한다.
|
||||
- `v1.1.26` 이후 저장 제목이 없는 티어표를 열 때 내부 ID나 자동 생성 문자열 대신 주제명 기반 표시 제목만 보이는지 확인한다.
|
||||
- `v1.1.25` 이후 첫 진입 시 로그인 상태가 비로그인처럼 잠깐 보였다가 바뀌는 깜빡임이 실제로 줄었는지, 느린 네트워크/빠른 네트워크 양쪽에서 확인한다.
|
||||
- `v1.1.25` 이후 티어표 화면 진입 시 제목 자리에 내부 ID가 먼저 노출되지 않고, 편집 모드/프리뷰 모드 둘 다 스켈레톤 뒤에 실제 데이터가 자연스럽게 붙는지 확인한다.
|
||||
- 초기 인증 게이트는 최소 140ms를 기다리므로, 초고속 응답에서도 오히려 부트 스켈레톤이 과하게 눈에 띄지 않는지 확인한다.
|
||||
- `v1.1.18` 이후 설정 화면이 데스크톱/태블릿/모바일에서 너무 넓게 퍼지지 않고, 요약 카드 간 간격과 제목/설명 밀도가 다른 대시보드 화면과 자연스럽게 맞는지 확인한다.
|
||||
- 프로필 이미지 변경 후 저장, 이미지 제거 후 저장, 저장하지 않고 페이지 이탈 세 경우가 모두 의도대로 동작하는지 확인한다.
|
||||
- 프로필 이미지 자동 저장으로 바뀐 뒤, 파일 선택 직후와 삭제 직후에 즉시 반영되고 연속 클릭 시 중복 저장 요청이 과도하게 쌓이지 않는지 확인한다.
|
||||
- 닉네임 변경 모달에서 제한 안내 문구와 실제 저장 차단 시점이 일치하는지, 제한 중에는 버튼이 비활성화되고 다음 가능 시각이 자연스럽게 보이는지 확인한다.
|
||||
- `NICKNAME_CHANGE_INTERVAL_DAYS=1`, `NICKNAME_CHANGE_INTERVAL_MS=0` 같은 운영/테스트 값에서 설정 화면 문구와 백엔드 차단 동작이 함께 바뀌는지 확인한다.
|
||||
- 설정 화면의 아이콘 버튼(`닉네임 변경`, `비밀번호 변경`, `로그아웃`)이 좁은 화면에서도 겹치지 않고, `title/aria-label` 기준 접근성도 자연스러운지 확인한다.
|
||||
- 닉네임 변경 제한 중일 때 설정 카드에서 아이콘이 완전히 사라지는 흐름이 사용성 측면에서 충분히 명확한지 실제 계정으로 확인한다.
|
||||
- 이메일 카드의 읽기 전용 문구만으로도 “로그인용 계정 이메일은 여기서 바꾸지 않는다”는 점이 충분히 전달되는지 확인한다.
|
||||
- 운영 배포 환경에서 닉네임 변경 주기를 바꿀 때는 `.env.production`의 `NICKNAME_CHANGE_INTERVAL_DAYS` 값만 바꾸면 된다는 점을 배포 문서에도 추후 분리해둘지 검토한다.
|
||||
- `860px` 이하 모바일 폭에서 좌우 레일이 모두 오버레이로 동작할 때, 헤더 버튼으로 열기/닫기와 바깥 영역 탭 닫기가 자연스러운지 확인한다.
|
||||
- 모바일에서는 목록 화면 `viewToggle`을 숨기도록 바뀌었으니 홈/템플릿/나의 티어표/즐겨찾기/팔로우 피드에서 헤더 액션 영역이 더 여유롭게 보이는지 확인한다.
|
||||
- 편집 화면 상단 템플릿 제목 클릭 시 해당 주제 허브로 이동하고, 미저장 변경이 있을 때는 `저장 없이 이동` 확인 모달이 먼저 뜨는지 확인한다.
|
||||
- 오래전에 가입한 기존 계정은 `nickname_updated_at` 백필 후에도 바로 변경 가능하고, 최근 가입/최근 변경 계정은 정확히 14일 제한이 걸리는지 서버 기준으로 확인한다.
|
||||
- 비밀번호 변경이 요약 카드의 작은 액션으로만 열리더라도 접근성이 떨어지지 않는지, 모달 `Esc` 닫기와 포커스 이동이 자연스러운지 확인한다.
|
||||
- `v1.1.17` 이후 설정의 가이드 모달에서 페이지를 넘길 때 썸네일 영역 위치가 이전보다 안정적으로 유지되는지 확인한다.
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-07 v1.1.29
|
||||
- 보기 전용 티어표 헤더에서 중복이던 `Preview` 아이브로우를 제거했다. 이제 프리뷰 화면 헤더는 템플릿 이름 아이브로우 한 줄만 남고, 그 아래 티어표 제목과 설명이 이어진다.
|
||||
- 앱 전반의 `커스텀 티어표 만들기` 문구를 `커스텀 티어표`로 짧게 통일했다. 좌측 빠른 시작 버튼, 템플릿 화면 우측 액션, 가이드 설명 문구까지 같은 표현을 사용한다.
|
||||
- 스켈레톤 로딩 도입 후 드래그가 동작하지 않던 회귀를 수정했다. 원인은 `isEditorLoading`이 아직 true인 상태에서 `Sortable`을 먼저 초기화해 실제 드래그 대상 DOM이 없던 것이었고, 이제는 스켈레톤을 먼저 내린 뒤 `nextTick()` 후 실제 에디터 DOM에서 `initSortables()`를 실행한다.
|
||||
- 이 수정으로 클릭 이동은 유지하면서도 `풀 → 보드`, `보드 → 보드`, `보드 → 풀` 드래그가 다시 정상 동작한다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 2026-04-07 v1.1.27
|
||||
- 티어표 편집 화면 헤더를 다른 주요 화면과 같은 `pageHead__main` 3단 문법으로 재구성했다. 이제 편집 가능한 화면도 `eyebrow / title / desc` 흐름을 그대로 따른다.
|
||||
- 보기 전용 티어표 화면은 `템플릿 이름 / 티어표 제목 / 티어표 설명` 규칙으로 정리했다. 기존처럼 제목 클릭으로 템플릿 화면으로 이동하지 않고, 아이브로우(템플릿 이름)만 클릭 이동 대상으로 바꾸고 hover/focus 스타일도 함께 넣었다.
|
||||
- 편집 가능한 티어표 화면은 제목/설명이 우측 패널에 따로 있는 점을 반영해 `NEW 또는 EDIT / 현재 템플릿 이름 / 작업 가이드 문구` 구조로 정리했다.
|
||||
- 커스텀 티어표(freeform)는 템플릿 이름이 비어도 `커스텀 티어표`라는 편집 헤더 제목이 보이도록 보정했다.
|
||||
- 내보내기용 제목도 사용자 표시용 제목 기준으로 맞춰, 제목이 비어 있는 티어표에서 내부 식별자나 랜덤 문자열이 직접 노출되지 않게 정리했다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 2026-04-07 v1.1.26
|
||||
- 템플릿 주제 화면(`TopicHubView`)의 제목 fallback 문자열 `주제 불러오는 중...`을 제거했다. 이제 주제 데이터가 준비되기 전에는 실제 문구 대신 제목/설명 영역 스켈레톤을 보여준다.
|
||||
- 주제 전환 시 이전 템플릿의 공개 티어표 목록이 잠깐 남아 보이지 않도록, `topicName`, `featuredTierLists`, `tierLists`, `brokenThumbnailIds`를 먼저 비우고 다시 불러오게 정리했다.
|
||||
- 티어표 편집 화면은 저장용 랜덤 제목과 화면 표시용 제목을 분리했다. 이제 로딩 전후에 내부 `tierListId`나 자동 생성 문자열이 사용자 제목처럼 먼저 드러나지 않고, 표시용 제목은 `주제명 티어표` 또는 `제목 없는 티어표`로만 보여준다.
|
||||
- 편집 화면 라우트 감시 watch 는 `flush: 'sync'`로 조정해, 같은 컴포넌트 안에서 파라미터가 바뀔 때도 로딩 상태가 더 이른 시점에 반영되도록 보강했다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 2026-04-07 v1.1.25
|
||||
- 앱 최초 진입 시 로그인 상태가 잠깐 비로그인처럼 보였다가 다시 로그인 상태로 바뀌는 깜빡임을 줄이기 위해, 공통 앱 셸에 `bootGate` 초기 게이트를 추가했다. `App.vue`는 `auth.refresh()`와 최소 140ms 대기 시간을 함께 기다린 뒤 셸을 렌더링해, 아주 짧은 인증 복원에서도 화면이 번쩍 바뀌지 않게 한다.
|
||||
- `TierEditorView`는 제목/보드/우측 아이템 풀이 준비되기 전까지 실제 편집 화면 대신 같은 서비스 톤의 스켈레톤 레이아웃을 먼저 보여주도록 바꿨다. 이로써 티어표 진입 직후 내부 ID나 빈 보드가 먼저 드러나는 현상을 줄였다.
|
||||
- 에디터 라우트는 앱 전체에서 이미 인증 복원이 끝난 뒤에는 다시 `auth.refresh()`를 중복 호출하지 않도록 보정했다. 최초 진입이 아니면 현재 인증 상태를 그대로 사용해 편집 화면 초기 로딩 지연을 줄인다.
|
||||
- 스켈레톤은 `preview=1` 뷰어 모드와 편집 모드 모두에 대응하며, 제목/행/셀/우측 패널이 실제 화면 구조와 비슷한 밀도로 먼저 자리잡은 뒤 데이터가 준비되면 한 번에 교체된다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 2026-04-07 v1.1.18
|
||||
- 설정(`/profile`) 화면을 전면 재구성했다. 기존처럼 넓은 2단 입력 폼을 상시 노출하지 않고, `settingsThemePanel` 톤을 참고한 요약 카드 레이아웃으로 바꿔 더 차분하고 통일된 계정 화면으로 정리했다.
|
||||
- 프로필 영역은 `닉네임 / 이메일 / 프로필 이미지`의 현재 상태를 먼저 보여주고, 자주 바꾸지 않는 정보는 필요할 때만 모달을 열어 변경하도록 바꿨다. 비밀번호 변경도 별도 카드 전체를 차지하지 않고 작은 액션으로 열리는 모달 흐름으로 정리했다.
|
||||
@@ -9,6 +38,12 @@
|
||||
- 닉네임 변경 제한 기간은 고정 14일 상수가 아니라 환경변수로 바꿨다. `NICKNAME_CHANGE_INTERVAL_MS` 또는 `NICKNAME_CHANGE_INTERVAL_DAYS`로 조절할 수 있고, `0`이면 제한을 끌 수 있다. 프런트 문구도 응답의 `nicknameChangeIntervalLabel`을 따라가도록 맞췄다.
|
||||
- 프로필 이미지 요약 카드는 제거하고, 아바타 선택/삭제 시 즉시 저장되는 자동 저장 흐름으로 바꿨다. 비밀번호 변경과 로그아웃 액션은 좁은 화면에서도 문구가 뭉개지지 않도록 아이콘 버튼(`key.svg`, `logout.svg`)으로 바꿨다.
|
||||
- 닉네임은 `open_in_new.svg` 아이콘 버튼으로 모달을 여는 구조로 정리했고, 이메일은 현재 백엔드 기능 범위를 유지해 읽기 전용 상태임을 카드 안에서 더 명확히 표시했다.
|
||||
- 닉네임 카드 본문에서는 변경 제한 설명을 제거하고, 실제 변경 가능한 시점에만 아이콘 버튼이 나타나도록 바꿨다. 제한 안내는 닉네임 변경 모달 안에서만 `한 번 변경하면 X가 지나야 다시 바꿀 수 있다`는 문구로 보여준다.
|
||||
- 설정 가이드 마지막 단계에도 닉네임 변경 버튼은 제한 기간이 지나야 다시 나타난다는 안내를 추가했다.
|
||||
- 이메일 카드는 `현재 로그인에 사용하는 계정 이메일이며, 설정 화면에서는 변경할 수 없다`는 안내로 보정해 로그인 계정 이메일의 성격을 더 분명하게 드러냈다.
|
||||
- 운영 환경에서 바로 이해할 수 있도록 `.env.production`에 `NICKNAME_CHANGE_INTERVAL_DAYS=20` 샘플 값을 추가했다. 닉네임 제한을 20일로 바꾸고 싶다면 이 값을 그대로 두고, 다른 기간을 원하면 숫자만 바꾸면 된다.
|
||||
- 모바일 브레이크포인트(`860px` 이하)에서는 목록 화면의 `그리드/리스트` 전환 버튼을 숨기고, 왼쪽 레일도 오른쪽 레일처럼 오버레이 패널로 띄우도록 앱 셸 구조를 바꿨다. 모바일 상단 헤더 오른쪽에는 왼쪽 패널 열기와 오른쪽 패널 열기/닫기 버튼을 함께 배치한다.
|
||||
- 티어표 편집 화면 상단의 템플릿 제목은 더 이상 본문 상단 스크롤 버튼이 아니다. 이제 제목을 누르면 해당 주제 템플릿 화면으로 이동하고, 편집 중 미저장 변경이 있으면 기존 `저장 없이 이동` 확인 모달을 먼저 보여준다.
|
||||
- 확인: `node --check backend/src/db.js`, `node --check backend/src/routes/auth.js`, `npm run build`
|
||||
|
||||
## 2026-04-07 v1.1.17
|
||||
|
||||
@@ -51,6 +51,7 @@ const backendState = ref('online')
|
||||
const backendMessage = ref('')
|
||||
const isFullscreenActive = ref(false)
|
||||
const unreadCommentCount = ref(0)
|
||||
const isBootReady = ref(false)
|
||||
provide('rightRailOpen', rightRailOpen)
|
||||
provide('localRightRailTarget', '#local-right-rail-root')
|
||||
|
||||
@@ -100,8 +101,9 @@ const guideSteps = [
|
||||
id: 'select-topic',
|
||||
title: '주제 또는 양식 선택',
|
||||
summary: '주제 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작합니다.',
|
||||
summary: '주제 템플릿을 고르거나 커스텀 티어표로 바로 시작합니다.',
|
||||
description:
|
||||
'홈 화면에서는 주제 템플릿을 선택하거나 커스텀 티어표 만들기로 바로 새 보드를 열 수 있어요. 원하는 주제를 먼저 고르면 해당 주제의 공개 티어표도 같이 살펴볼 수 있어서, 완전히 처음 만드는지 기존 흐름을 참고할지 결정하기 쉽습니다.',
|
||||
'홈 화면에서는 주제 템플릿을 선택하거나 커스텀 티어표로 바로 새 보드를 열 수 있어요. 원하는 주제를 먼저 고르면 해당 주제의 공개 티어표도 같이 살펴볼 수 있어서, 완전히 처음 만드는지 기존 흐름을 참고할지 결정하기 쉽습니다.',
|
||||
},
|
||||
{
|
||||
id: 'arrange-board',
|
||||
@@ -157,7 +159,7 @@ const guideSteps = [
|
||||
title: '단축키로 빠른 조작',
|
||||
summary: '사이드 패널과 전체 화면을 키보드로 빠르게 전환합니다.',
|
||||
description:
|
||||
'[ 키는 왼쪽 사이드를 열고 닫고, ] 키는 오른쪽 사이드를 열고 닫습니다. F/ㄹ은 전체 화면, S/ㄴ은 검색 포커스(편집 화면에서는 아이템 검색), G/ㅎ은 그리드 보기, L/ㅣ는 리스트 보기입니다. 각종 모달은 Esc 키로 닫을 수 있고, 입력칸에 글을 쓰는 중에는 단축키가 동작하지 않도록 처리되어 있습니다.',
|
||||
'[ 키는 왼쪽 사이드를 열고 닫고, ] 키는 오른쪽 사이드를 열고 닫습니다. F/ㄹ은 전체 화면, S/ㄴ은 검색 포커스(편집 화면에서는 아이템 검색), G/ㅎ은 그리드 보기, L/ㅣ는 리스트 보기입니다. 설정 화면의 닉네임 변경 버튼은 변경 가능 기간이 지난 뒤에만 다시 나타납니다. 각종 모달은 Esc 키로 닫을 수 있고, 입력칸에 글을 쓰는 중에는 단축키가 동작하지 않도록 처리되어 있습니다.',
|
||||
},
|
||||
]
|
||||
const currentGuideStep = computed(() => guideSteps[guideStepIndex.value] || guideSteps[0])
|
||||
@@ -166,17 +168,17 @@ const isGuideNextDisabled = computed(() => guideStepIndex.value >= guideSteps.le
|
||||
const isLightTheme = computed(() => themeMode.value === 'light')
|
||||
const themeToggleLabel = computed(() => (isLightTheme.value ? '다크 모드' : '라이트 모드'))
|
||||
const showSettingsThemePanel = computed(() => route.name === 'profile')
|
||||
const showTopicViewToggle = computed(() => ['home', 'templates', 'topicHub', 'me', 'favorites', 'followingFeed'].includes(String(route.name || '')))
|
||||
const showTopicViewToggle = computed(() => !isMobileLayout.value && ['home', 'templates', 'topicHub', 'me', 'favorites', 'followingFeed'].includes(String(route.name || '')))
|
||||
const topicViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
|
||||
const showBackendFallback = computed(() => !isPreviewMode.value && ['maintenance', 'offline'].includes(backendState.value))
|
||||
const shouldLockRightRailBodyScroll = computed(() => isRightRailOverlay.value && rightRailOpen.value && !showBackendFallback.value)
|
||||
const leftBottomPrimaryAction = computed(() => {
|
||||
if (!authReady.value) return null
|
||||
if (route.name === 'home' && auth.user) {
|
||||
return { label: '커스텀 티어표 만들기', to: editorNewPath('freeform'), iconSrc: iconDashboardCustomize }
|
||||
return { label: '커스텀 티어표', to: editorNewPath('freeform'), iconSrc: iconDashboardCustomize }
|
||||
}
|
||||
if (route.name === 'templates' && auth.user) {
|
||||
return { label: '커스텀 티어표 만들기', to: editorNewPath('freeform'), iconSrc: iconDashboardCustomize }
|
||||
return { label: '커스텀 티어표', to: editorNewPath('freeform'), iconSrc: iconDashboardCustomize }
|
||||
}
|
||||
if (route.name === 'topicHub') {
|
||||
const target = editorNewPath(currentTopicId.value)
|
||||
@@ -191,7 +193,7 @@ const routeMeta = computed(() => {
|
||||
title: '홈',
|
||||
subtitle: '공개 티어표 피드',
|
||||
contextTitle: '빠른 시작',
|
||||
contextText: auth.user ? '추천 티어표와 최신 공개 티어표를 둘러보고 바로 새 작업을 시작할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
|
||||
contextText: auth.user ? '추천 티어표와 최신 공개 티어표를 둘러보고 바로 새 작업을 시작할 수 있어요.' : '로그인하면 커스텀 티어표와 개인 목록 관리가 열립니다.',
|
||||
actionLabel: '템플릿 보기',
|
||||
action: () => {
|
||||
router.push(templatesPath())
|
||||
@@ -203,8 +205,8 @@ const routeMeta = computed(() => {
|
||||
title: '템플릿',
|
||||
subtitle: '주제 템플릿 선택',
|
||||
contextTitle: '빠른 시작',
|
||||
contextText: auth.user ? '주제 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
|
||||
actionLabel: auth.user ? '커스텀 티어표 만들기' : '로그인하러 가기',
|
||||
contextText: auth.user ? '주제 템플릿을 고르거나 커스텀 티어표로 바로 시작할 수 있어요.' : '로그인하면 커스텀 티어표와 개인 목록 관리가 열립니다.',
|
||||
actionLabel: auth.user ? '커스텀 티어표' : '로그인하러 가기',
|
||||
action: () => {
|
||||
router.push(auth.user ? editorNewPath('freeform') : loginPath())
|
||||
},
|
||||
@@ -378,7 +380,11 @@ onMounted(async () => {
|
||||
if (savedTheme === 'light' || savedTheme === 'dark') applyTheme(savedTheme)
|
||||
else applyTheme('dark')
|
||||
}
|
||||
await auth.refresh()
|
||||
await Promise.all([
|
||||
auth.refresh(),
|
||||
new Promise((resolve) => setTimeout(resolve, 140)),
|
||||
])
|
||||
isBootReady.value = true
|
||||
if (typeof window !== 'undefined') {
|
||||
syncViewportWidth()
|
||||
syncFullscreenState()
|
||||
@@ -691,10 +697,30 @@ function reloadApp() {
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
<template v-else-if="!isBootReady">
|
||||
<main class="bootGate">
|
||||
<section class="bootGate__shell">
|
||||
<div class="bootGate__brand">
|
||||
<div class="bootGate__logo"></div>
|
||||
<div class="bootGate__copy">
|
||||
<div class="bootGate__title">Tier Maker</div>
|
||||
<div class="bootGate__desc">계정 상태와 화면 구성을 준비하고 있어요.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bootGate__panels">
|
||||
<div class="bootGate__panel bootGate__panel--nav"></div>
|
||||
<div class="bootGate__panel bootGate__panel--main"></div>
|
||||
<div class="bootGate__panel bootGate__panel--side"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
<template v-else>
|
||||
<aside class="leftRail">
|
||||
<button v-if="isMobileLayout && mobileLeftNavOpen" class="leftRailBackdrop" type="button" aria-label="왼쪽 패널 닫기" @click="toggleLeftRail"></button>
|
||||
|
||||
<aside class="leftRail" :class="{ 'leftRail--overlay': isMobileLayout }" :aria-hidden="isMobileLayout && !mobileLeftNavOpen">
|
||||
<div class="leftRail__top railHeader">
|
||||
<button v-if="!isMobileLayout" class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="왼쪽 패널 토글" @click="toggleLeftRail">
|
||||
<button class="ghostIcon ghostIcon--iconOnly" type="button" :aria-label="isMobileLayout ? '왼쪽 패널 닫기' : '왼쪽 패널 토글'" @click="toggleLeftRail">
|
||||
<SvgIcon :src="iconDockToRight" :size="24" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -709,16 +735,6 @@ function reloadApp() {
|
||||
<div class="appUserCard__name">{{ accountName }}</div>
|
||||
<div class="appUserCard__email" :class="{ 'appUserCard__email--hint': isAccountEmailHint }">{{ accountEmail }}</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="isMobileLayout"
|
||||
class="appUserCard__navToggle"
|
||||
type="button"
|
||||
:aria-label="mobileLeftNavOpen ? '네비게이션 메뉴 닫기' : '네비게이션 메뉴 열기'"
|
||||
:aria-expanded="mobileLeftNavOpen"
|
||||
@click="toggleLeftRail"
|
||||
>
|
||||
<SvgIcon :src="mobileLeftNavOpen ? iconDockToLeft : iconDockToRight" :size="24" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -794,9 +810,15 @@ function reloadApp() {
|
||||
<SvgIcon :src="iconLists" :size="24" />
|
||||
</button>
|
||||
</div>
|
||||
<button v-if="isMobileLayout" class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="왼쪽 패널 열기" @click="toggleLeftRail">
|
||||
<SvgIcon :src="iconDockToRight" :size="24" />
|
||||
</button>
|
||||
<button v-if="!rightRailOpen" class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="패널 열기" @click="toggleRightRail">
|
||||
<SvgIcon :src="iconDockToLeft" :size="24" />
|
||||
</button>
|
||||
<button v-else-if="isMobileLayout" class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="오른쪽 패널 닫기" @click="toggleRightRail">
|
||||
<SvgIcon :src="iconDockToLeft" :size="24" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="workspaceBody" :class="{ 'workspaceBody--localRail': usesLocalRightRail }">
|
||||
@@ -1006,6 +1028,95 @@ function reloadApp() {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bootGate {
|
||||
min-width: 100dvw;
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: var(--theme-shell-bg);
|
||||
}
|
||||
|
||||
.bootGate__shell {
|
||||
width: min(100%, 1180px);
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.bootGate__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 18px 20px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-rail-bg);
|
||||
}
|
||||
|
||||
.bootGate__logo,
|
||||
.bootGate__panel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bootGate__logo::after,
|
||||
.bootGate__panel::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform: translateX(-100%);
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.08), transparent);
|
||||
animation: bootGateShimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.bootGate__logo {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-surface-soft);
|
||||
}
|
||||
|
||||
.bootGate__copy {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bootGate__title {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--theme-text-strong);
|
||||
}
|
||||
|
||||
.bootGate__desc {
|
||||
color: var(--theme-text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.bootGate__panels {
|
||||
display: grid;
|
||||
grid-template-columns: 248px minmax(0, 1fr) 320px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.bootGate__panel {
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-rail-bg);
|
||||
min-height: 72dvh;
|
||||
}
|
||||
|
||||
.bootGate__panel--main {
|
||||
background: var(--theme-shell-bg);
|
||||
}
|
||||
|
||||
@keyframes bootGateShimmer {
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.leftRail,
|
||||
.rightRail {
|
||||
min-height: 100dvh;
|
||||
@@ -2151,10 +2262,10 @@ function reloadApp() {
|
||||
grid-template-columns: var(--left-rail-width, 248px) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.rightRailBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: block;
|
||||
.rightRailBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: block;
|
||||
border: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 29;
|
||||
@@ -2181,6 +2292,32 @@ function reloadApp() {
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.bootGate {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.bootGate__shell {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.bootGate__brand {
|
||||
padding: 16px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.bootGate__panels {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.bootGate__panel--nav,
|
||||
.bootGate__panel--side {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bootGate__panel--main {
|
||||
min-height: 72dvh;
|
||||
}
|
||||
|
||||
.guideModal {
|
||||
padding: 20px 12px;
|
||||
}
|
||||
@@ -2263,19 +2400,49 @@ function reloadApp() {
|
||||
}
|
||||
|
||||
.leftRail {
|
||||
min-height: auto;
|
||||
height: auto;
|
||||
min-height: 100dvh;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--theme-border);
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.leftRailBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: block;
|
||||
border: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 29;
|
||||
}
|
||||
|
||||
.leftRail--overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: min(340px, calc(100vw - 20px));
|
||||
height: 100dvh;
|
||||
z-index: 30;
|
||||
background: var(--theme-shell-bg);
|
||||
border-right: 1px solid var(--theme-border);
|
||||
box-shadow: 18px 0 36px rgba(0, 0, 0, 0.34);
|
||||
transition:
|
||||
transform 220ms ease,
|
||||
opacity 220ms ease;
|
||||
}
|
||||
|
||||
.appShell--mobileNavClosed .leftRail--overlay {
|
||||
transform: translateX(calc(-100% - 24px));
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leftRail__top {
|
||||
display: none;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.leftRail__body {
|
||||
max-height: none;
|
||||
padding: 12px 14px;
|
||||
padding: 12px 14px calc(18px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.appUserCard {
|
||||
@@ -2290,10 +2457,6 @@ function reloadApp() {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.appUserCard__navToggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.workspaceHead .ghostIcon--iconOnly,
|
||||
.rightRail__top .ghostIcon--iconOnly {
|
||||
width: 42px;
|
||||
@@ -2392,18 +2555,6 @@ function reloadApp() {
|
||||
margin: 14px 14px 0;
|
||||
}
|
||||
|
||||
.appShell--mobileNavClosed .leftRail__mobileMenu {
|
||||
max-height: 0;
|
||||
margin-top: -8px;
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.appShell--mobileNavClosed .leftRail__bottom {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rightRail--overlay .rightRail__body {
|
||||
padding: 14px 20px calc(32px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ watch(() => route.query.q, loadHomeFeed)
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Feed</div>
|
||||
<h1 class="pageHead__title">홈</h1>
|
||||
<div class="pageHead__desc">사용자가 공개한 티어표를 최신순으로 살펴보고, 추천 티어표는 상단에서 바로 볼 수 있어요.</div>
|
||||
<div class="pageHead__desc">다른 사용자들이 공개한 티어표를 살펴볼 수 있습니다.</div>
|
||||
<div v-if="query" class="pageHead__searchState">"{{ query }}"에 맞는 공개 티어표만 보고 있어요.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -80,7 +80,7 @@ function openList(t) {
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Tier Lists</div>
|
||||
<h2 class="pageHead__title">나의 티어표</h2>
|
||||
<div class="pageHead__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 수 있어요.</div>
|
||||
<div class="pageHead__desc">직접 저장한 티어표를 관리할 수 있는 페이지입니다.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -72,11 +72,6 @@ const canChangeNicknameNow = computed(() => {
|
||||
if (!nicknameUpdatedAt.value) return true
|
||||
return Date.now() >= nicknameChangeAvailableAt.value
|
||||
})
|
||||
const nicknameCooldownText = computed(() => {
|
||||
if (nicknameChangeIntervalMs.value <= 0) return '닉네임 변경 제한이 없습니다.'
|
||||
if (!nicknameUpdatedAt.value || canChangeNicknameNow.value) return `닉네임은 ${nicknameChangeIntervalLabel.value}에 한 번만 변경할 수 있어요.`
|
||||
return `다음 변경 가능 시점: ${formatDateTime(nicknameChangeAvailableAt.value)}`
|
||||
})
|
||||
onMounted(async () => {
|
||||
if (!auth.hydrated) await auth.refresh()
|
||||
if (!auth.user) {
|
||||
@@ -313,7 +308,7 @@ async function logout() {
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Account</div>
|
||||
<h2 class="pageHead__title">설정</h2>
|
||||
<div class="pageHead__desc">수시로 바꾸지 않는 정보는 요약해서 보여주고, 필요할 때만 모달로 열어 바꾸는 흐름으로 정리했어요.</div>
|
||||
<div class="pageHead__desc">닉네임은 변경은 쿨타임이 있으니 신중하게 설정해주세요.</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -360,9 +355,9 @@ async function logout() {
|
||||
<div class="settingsSummaryItem__valueRow">
|
||||
<div class="settingsSummaryItem__value">{{ nickname || '미설정' }}</div>
|
||||
<button
|
||||
v-if="canChangeNicknameNow"
|
||||
class="settingsIconAction"
|
||||
type="button"
|
||||
:disabled="!canChangeNicknameNow"
|
||||
aria-label="닉네임 변경"
|
||||
title="닉네임 변경"
|
||||
@click="openNicknameModal"
|
||||
@@ -370,16 +365,13 @@ async function logout() {
|
||||
<SvgIcon :src="openInNewIcon" :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="settingsSummaryItem__meta">{{ nicknameCooldownText }}</div>
|
||||
</div>
|
||||
|
||||
<div class="settingsSummaryItem">
|
||||
<div class="settingsSummaryItem__label">이메일</div>
|
||||
<div class="settingsSummaryItem__label">ID (이메일)</div>
|
||||
<div class="settingsSummaryItem__valueRow">
|
||||
<div class="settingsSummaryItem__value">{{ authEmail }}</div>
|
||||
<span class="settingsSummaryItem__status">읽기 전용</span>
|
||||
</div>
|
||||
<div class="settingsSummaryItem__meta">현재 로그인에 사용하는 계정 이메일입니다. 지금은 설정 화면에서 직접 변경하지 않습니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -387,7 +379,7 @@ async function logout() {
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="settingsThemeCard">
|
||||
<article class="settingsThemeCard settingsThemeCard--compact">
|
||||
<div class="settingsThemeCard__eyebrow">Security</div>
|
||||
<div class="settingsThemeCard__title">보안 설정</div>
|
||||
<div class="settingsCompactRow">
|
||||
@@ -401,7 +393,7 @@ async function logout() {
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="settingsThemeCard">
|
||||
<article class="settingsThemeCard settingsThemeCard--compact">
|
||||
<div class="settingsThemeCard__eyebrow">Session</div>
|
||||
<div class="settingsThemeCard__title">계정 상태</div>
|
||||
<div class="settingsCompactList">
|
||||
@@ -430,7 +422,7 @@ async function logout() {
|
||||
<div id="nicknameModalTitle" class="settingsModalCard__title">닉네임 변경</div>
|
||||
<div class="settingsModalCard__desc">
|
||||
닉네임은 공개 티어표의 작성자 이름으로 보이며,
|
||||
{{ nicknameChangeIntervalMs > 0 ? `악용 방지를 위해 ${nicknameChangeIntervalLabel}에 한 번만 변경할 수 있어요.` : '현재는 변경 주기 제한이 없습니다.' }}
|
||||
{{ nicknameChangeIntervalMs > 0 ? `한 번 변경하면 ${nicknameChangeIntervalLabel}이 지나야 다시 바꿀 수 있어요.` : '현재는 변경 주기 제한이 없습니다.' }}
|
||||
</div>
|
||||
<label class="field">
|
||||
<span class="field__label">새 닉네임</span>
|
||||
@@ -543,6 +535,10 @@ async function logout() {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.settingsThemeCard--compact {
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.settingsThemeCard__eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
@@ -697,17 +693,6 @@ async function logout() {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.settingsSummaryItem__status {
|
||||
flex-shrink: 0;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-pill-bg);
|
||||
color: var(--theme-text-soft);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settingsActionRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -718,6 +703,7 @@ async function logout() {
|
||||
.settingsCompactList {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.settingsCompactRow {
|
||||
@@ -725,6 +711,7 @@ async function logout() {
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
min-height: 100%;
|
||||
padding: 16px 18px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--theme-border);
|
||||
@@ -741,6 +728,7 @@ async function logout() {
|
||||
margin-top: 4px;
|
||||
color: var(--theme-text-muted);
|
||||
line-height: 1.6;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.field {
|
||||
|
||||
@@ -83,7 +83,7 @@ function templateThumbUrl(template) {
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Topic</div>
|
||||
<h1 class="pageHead__title">템플릿</h1>
|
||||
<p class="pageHead__desc">자주 쓰는 주제 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 수 있어요.</p>
|
||||
<p class="pageHead__desc">미리 설정된 템플릿을 이용하여 쉽고 빠르게 만들 수 있습니다.</p>
|
||||
<p v-if="query" class="pageHead__searchState">"{{ query }}"에 맞는 주제 템플릿만 보고 있어요.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -86,6 +86,7 @@ const itemContextMenu = ref({
|
||||
y: 0,
|
||||
itemId: '',
|
||||
})
|
||||
const isEditorLoading = ref(true)
|
||||
let editorLoadToken = 0
|
||||
|
||||
const boardEl = ref(null)
|
||||
@@ -124,13 +125,19 @@ const effectiveAuthorName = computed(() => {
|
||||
return (authorAccountName.value || '').trim() || 'unknown'
|
||||
})
|
||||
const autoGeneratedTitle = ref(createAutoTierListTitle())
|
||||
const effectiveTitle = computed(() => {
|
||||
const saveTitle = computed(() => {
|
||||
const customTitle = (title.value || '').trim()
|
||||
if (customTitle) return customTitle
|
||||
if (persistedTierListId.value) return persistedTierListId.value
|
||||
if (tierListId.value && tierListId.value !== 'new') return tierListId.value
|
||||
return autoGeneratedTitle.value
|
||||
})
|
||||
const displayTitle = computed(() => {
|
||||
const customTitle = (title.value || '').trim()
|
||||
if (customTitle) return customTitle
|
||||
const topicName = (templateName.value || '').trim()
|
||||
if (topicName) return `${topicName} 티어표`
|
||||
if (isEditorLoading.value) return ''
|
||||
return '제목 없는 티어표'
|
||||
})
|
||||
const displayThumbnailUrl = computed(() => thumbnailPreviewUrl.value || (thumbnailSrc.value ? resolveItemSrc({ src: thumbnailSrc.value }) : ''))
|
||||
const untitledWarning = computed(
|
||||
() =>
|
||||
@@ -166,6 +173,17 @@ const currentUserId = computed(() => auth.user?.id || '')
|
||||
const canSubmitTemplateCreateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
|
||||
const canSubmitTemplateUpdateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
|
||||
const templateRequestTargetLabel = computed(() => (templateId.value === 'freeform' ? '새로운 템플릿' : (templateName.value || templateId.value || '선택한 주제')))
|
||||
const editorEyebrowLabel = computed(() => (isNewTierList.value ? 'NEW' : 'EDIT'))
|
||||
const editorHeaderTitle = computed(() => {
|
||||
const currentTemplateName = (templateName.value || '').trim()
|
||||
if (currentTemplateName) return currentTemplateName
|
||||
return templateId.value === 'freeform' ? '커스텀 티어표' : '티어표 만들기'
|
||||
})
|
||||
const editorHeaderDescription = computed(() =>
|
||||
canEdit.value
|
||||
? '행/열 이름과 순서를 바꾸고 아이템을 드래그해서 배치할 수 있어요.'
|
||||
: '공개된 티어표를 보는 중입니다. 로그인한 작성자만 수정할 수 있어요.'
|
||||
)
|
||||
const shareTierListUrl = computed(() => {
|
||||
const savedTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
|
||||
if (!savedTierListId) return ''
|
||||
@@ -367,11 +385,6 @@ function closeItemContextMenu() {
|
||||
}
|
||||
}
|
||||
|
||||
function scrollWorkspaceBodyToTop() {
|
||||
const workspaceBody = document.querySelector('.workspaceBody')
|
||||
workspaceBody?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
function updateEditorSidebarMaxHeight() {
|
||||
if (typeof window === 'undefined' || !sidebarEl.value) return
|
||||
const bottomGap = 14
|
||||
@@ -920,7 +933,7 @@ async function downloadImage() {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${effectiveTitle.value.trim()}.png`
|
||||
a.download = `${(displayTitle.value || 'tier-maker').trim()}.png`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
@@ -984,7 +997,7 @@ async function uploadPendingThumbnail() {
|
||||
}
|
||||
|
||||
function buildPayload(existingId) {
|
||||
const finalTitle = effectiveTitle.value
|
||||
const finalTitle = saveTitle.value
|
||||
return {
|
||||
id: existingId || undefined,
|
||||
topicId: templateId.value,
|
||||
@@ -1104,6 +1117,11 @@ function confirmNavigationDiscard() {
|
||||
router.push(nextPath)
|
||||
}
|
||||
|
||||
function openTemplateTopic() {
|
||||
if (!templateId.value) return
|
||||
requestEditorNavigation(topicPath(templateId.value))
|
||||
}
|
||||
|
||||
function openSourceTierList() {
|
||||
if (!sourceTierListId.value) return
|
||||
requestEditorNavigation(editorPath(templateId.value, sourceTierListId.value))
|
||||
@@ -1266,6 +1284,7 @@ function resetEditorStateForRoute() {
|
||||
groups.value = normalizeLoadedGroups([], columns.value)
|
||||
pool.value = []
|
||||
itemsById.value = {}
|
||||
templateName.value = ''
|
||||
title.value = ''
|
||||
persistedTierListId.value = ''
|
||||
thumbnailSrc.value = ''
|
||||
@@ -1302,6 +1321,7 @@ function resetEditorStateForRoute() {
|
||||
selectedItemId.value = ''
|
||||
recentDragFinishedAt.value = 0
|
||||
savedEditorSnapshot.value = ''
|
||||
isEditorLoading.value = true
|
||||
closeItemContextMenu()
|
||||
resetTemplateRequestDrafts()
|
||||
}
|
||||
@@ -1309,7 +1329,9 @@ function resetEditorStateForRoute() {
|
||||
async function loadEditorState() {
|
||||
const loadToken = ++editorLoadToken
|
||||
resetEditorStateForRoute()
|
||||
await auth.refresh()
|
||||
if (!auth.hydrated) {
|
||||
await auth.refresh()
|
||||
}
|
||||
if (loadToken !== editorLoadToken) return
|
||||
|
||||
authorName.value = (auth.user?.nickname || '').trim()
|
||||
@@ -1391,9 +1413,11 @@ async function loadEditorState() {
|
||||
} catch (e) {
|
||||
if (loadToken !== editorLoadToken) return
|
||||
error.value = '티어표를 불러오지 못했어요.'
|
||||
isEditorLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
isEditorLoading.value = false
|
||||
await nextTick()
|
||||
if (loadToken !== editorLoadToken) return
|
||||
|
||||
@@ -1409,7 +1433,7 @@ watch(
|
||||
() => {
|
||||
loadEditorState()
|
||||
},
|
||||
{ immediate: true }
|
||||
{ immediate: true, flush: 'sync' }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
@@ -1444,11 +1468,39 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="previewMode" class="previewOnly" :style="{ '--thumb-size': `${iconSize}px` }">
|
||||
<section v-if="previewMode && isEditorLoading" class="editorSkeleton editorSkeleton--preview">
|
||||
<header class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Preview</div>
|
||||
<h1 class="pageHead__title">{{ effectiveTitle }}</h1>
|
||||
<div class="editorSkeleton__line editorSkeleton__line--eyebrow"></div>
|
||||
<div class="editorSkeleton__line editorSkeleton__line--title"></div>
|
||||
<div class="editorSkeleton__line editorSkeleton__line--desc"></div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="editorSkeleton__previewCard">
|
||||
<div class="editorSkeleton__board">
|
||||
<div class="editorSkeleton__row" v-for="index in 4" :key="`preview-row-${index}`">
|
||||
<div class="editorSkeleton__label"></div>
|
||||
<div class="editorSkeleton__cells">
|
||||
<div class="editorSkeleton__cell" v-for="cellIndex in 3" :key="`preview-cell-${index}-${cellIndex}`"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="previewMode" class="previewOnly" :style="{ '--thumb-size': `${iconSize}px` }">
|
||||
<header class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<button
|
||||
class="pageHead__eyebrow pageHead__eyebrowButton"
|
||||
type="button"
|
||||
title="이 템플릿 화면으로 이동"
|
||||
@click="openTemplateTopic"
|
||||
@keydown.space.prevent="openTemplateTopic"
|
||||
>
|
||||
{{ templateName || templateId }}
|
||||
</button>
|
||||
<h1 class="pageHead__title">{{ displayTitle }}</h1>
|
||||
<p v-if="description" class="pageHead__desc">{{ description }}</p>
|
||||
</div>
|
||||
</header>
|
||||
@@ -1521,6 +1573,29 @@ onUnmounted(() => {
|
||||
</Teleport>
|
||||
</section>
|
||||
|
||||
<template v-else-if="isEditorLoading">
|
||||
<section class="editorSkeleton" :style="{ '--thumb-size': `${iconSize}px` }">
|
||||
<div class="editorSkeleton__head">
|
||||
<div class="editorSkeleton__line editorSkeleton__line--title"></div>
|
||||
<div class="editorSkeleton__line editorSkeleton__line--desc"></div>
|
||||
</div>
|
||||
<div class="editorSkeleton__body">
|
||||
<div class="editorSkeleton__board">
|
||||
<div class="editorSkeleton__toolbar">
|
||||
<div class="editorSkeleton__chip" v-for="index in 3" :key="`toolbar-${index}`"></div>
|
||||
</div>
|
||||
<div class="editorSkeleton__row" v-for="index in 4" :key="`editor-row-${index}`">
|
||||
<div class="editorSkeleton__label"></div>
|
||||
<div class="editorSkeleton__cells">
|
||||
<div class="editorSkeleton__cell" v-for="cellIndex in 3" :key="`editor-cell-${index}-${cellIndex}`"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editorSkeleton__side"></div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
|
||||
<div v-if="isSaveModalOpen" class="modalOverlay" @click.self="closeSaveModal">
|
||||
@@ -1535,7 +1610,7 @@ onUnmounted(() => {
|
||||
|
||||
<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 id="navigationConfirmTitle" class="modalCard__title">다른 화면으로 이동</div>
|
||||
<div class="modalCard__desc">
|
||||
아직 저장하지 않은 수정 내용이 있어요. 이대로 이동하면 현재 변경 내용은 사라집니다.
|
||||
</div>
|
||||
@@ -1652,24 +1727,10 @@ onUnmounted(() => {
|
||||
<section class="layout" :style="{ '--thumb-size': `${iconSize}px` }">
|
||||
<div class="editorMain">
|
||||
<section class="head">
|
||||
<div class="editorMain__headCopy">
|
||||
<button
|
||||
class="editorMain__title editorMain__titleButton"
|
||||
type="button"
|
||||
title="본문을 화면 위로 이동"
|
||||
@click="scrollWorkspaceBodyToTop"
|
||||
@keydown.space.prevent="scrollWorkspaceBodyToTop"
|
||||
>
|
||||
{{ templateName || templateId }}
|
||||
</button>
|
||||
<div class="editorMain__subtitle">
|
||||
<template v-if="canEdit">
|
||||
행/열 이름과 순서를 바꾸고 아이템을 드래그해서 배치할 수 있어요.
|
||||
</template>
|
||||
<template v-else>
|
||||
공개된 티어표를 보는 중입니다. 로그인한 작성자만 수정할 수 있어요.
|
||||
</template>
|
||||
</div>
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">{{ editorEyebrowLabel }}</div>
|
||||
<div class="pageHead__title">{{ editorHeaderTitle }}</div>
|
||||
<div class="pageHead__desc">{{ editorHeaderDescription }}</div>
|
||||
<div v-if="sourceTierListId" class="editorMain__sourceNote">
|
||||
<span>원본</span>
|
||||
<button class="editorMain__sourceLink" type="button" @click="openSourceTierList">{{ copiedFromLabel }}</button>
|
||||
@@ -1704,7 +1765,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
<div ref="exportBoardEl" class="exportBoard" :class="{ 'exportBoard--active': isExporting }">
|
||||
<div v-if="isExporting" class="exportBoard__title">{{ effectiveTitle }}</div>
|
||||
<div v-if="isExporting" class="exportBoard__title">{{ displayTitle }}</div>
|
||||
<div v-if="isExporting && description" class="exportBoard__description">{{ description }}</div>
|
||||
<div v-if="columns.length > 1" class="boardColumnsHeader" :class="{ 'boardColumnsHeader--export': isExporting }">
|
||||
<div class="boardColumnsHeader__spacer" aria-hidden="true"></div>
|
||||
@@ -2023,6 +2084,127 @@ onUnmounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editorSkeleton {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.editorSkeleton__head {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.editorSkeleton__body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, clamp(680px, 58vw, 960px)) minmax(280px, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
.editorSkeleton__previewCard,
|
||||
.editorSkeleton__board,
|
||||
.editorSkeleton__side,
|
||||
.editorSkeleton__line,
|
||||
.editorSkeleton__label,
|
||||
.editorSkeleton__cell,
|
||||
.editorSkeleton__chip {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.editorSkeleton__previewCard,
|
||||
.editorSkeleton__board,
|
||||
.editorSkeleton__side {
|
||||
border-radius: 22px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--theme-card-bg) 96%, transparent),
|
||||
color-mix(in srgb, var(--theme-card-bg-hover) 92%, transparent)
|
||||
);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.editorSkeleton__previewCard,
|
||||
.editorSkeleton__board {
|
||||
padding: 20px;
|
||||
}
|
||||
.editorSkeleton__side {
|
||||
min-height: 540px;
|
||||
}
|
||||
.editorSkeleton__previewCard::after,
|
||||
.editorSkeleton__board::after,
|
||||
.editorSkeleton__side::after,
|
||||
.editorSkeleton__line::after,
|
||||
.editorSkeleton__label::after,
|
||||
.editorSkeleton__cell::after,
|
||||
.editorSkeleton__chip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.08) 48%, transparent 100%);
|
||||
transform: translateX(-100%);
|
||||
animation: editorSkeletonShimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
.editorSkeleton__line {
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--theme-surface-soft-2) 88%, transparent);
|
||||
}
|
||||
.editorSkeleton__line--eyebrow {
|
||||
width: 92px;
|
||||
height: 12px;
|
||||
}
|
||||
.editorSkeleton__line--title {
|
||||
width: min(420px, 78%);
|
||||
height: 34px;
|
||||
}
|
||||
.editorSkeleton__line--desc {
|
||||
width: min(560px, 92%);
|
||||
height: 14px;
|
||||
}
|
||||
.editorSkeleton__toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.editorSkeleton__chip {
|
||||
width: 88px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--theme-surface-soft-2) 84%, transparent);
|
||||
}
|
||||
.editorSkeleton__row {
|
||||
display: grid;
|
||||
grid-template-columns: 132px 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.editorSkeleton__row + .editorSkeleton__row {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.editorSkeleton__label {
|
||||
min-height: 110px;
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--theme-surface-soft-2) 88%, transparent);
|
||||
}
|
||||
.editorSkeleton__cells {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.editorSkeleton__cell {
|
||||
min-height: 110px;
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--theme-surface-soft) 74%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--theme-border) 88%, transparent);
|
||||
}
|
||||
.editorSkeleton--preview .editorSkeleton__board {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@keyframes editorSkeletonShimmer {
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.head {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -2050,7 +2232,12 @@ onUnmounted(() => {
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.editorMain__titleButton {
|
||||
.editorMain__subtitle {
|
||||
color: var(--theme-text-soft);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.pageHead__eyebrowButton {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
border: 0;
|
||||
@@ -2059,20 +2246,17 @@ onUnmounted(() => {
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: color 160ms ease, opacity 160ms ease;
|
||||
}
|
||||
.editorMain__titleButton:hover {
|
||||
.pageHead__eyebrowButton:hover {
|
||||
color: var(--theme-text-strong);
|
||||
opacity: 1;
|
||||
}
|
||||
.editorMain__titleButton:focus-visible {
|
||||
.pageHead__eyebrowButton:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--theme-accent) 70%, white);
|
||||
outline-offset: 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.editorMain__subtitle {
|
||||
color: var(--theme-text-soft);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.editorMain__sourceNote {
|
||||
margin-top: 4px;
|
||||
display: inline-flex;
|
||||
@@ -3309,6 +3493,21 @@ onUnmounted(() => {
|
||||
border-radius: 14px;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.editorSkeleton__body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.editorSkeleton__side {
|
||||
min-height: 320px;
|
||||
}
|
||||
.editorSkeleton__row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.editorSkeleton__label {
|
||||
min-height: 44px;
|
||||
}
|
||||
.editorSkeleton__cells {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.previewOnly__row,
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -3389,6 +3588,15 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.editorSkeleton__previewCard,
|
||||
.editorSkeleton__board,
|
||||
.editorSkeleton__side {
|
||||
border-radius: 18px;
|
||||
}
|
||||
.editorSkeleton__previewCard,
|
||||
.editorSkeleton__board {
|
||||
padding: 16px;
|
||||
}
|
||||
.previewOnly {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ const error = ref('')
|
||||
const brokenThumbnailIds = ref({})
|
||||
const isTopicLoading = ref(false)
|
||||
const isListView = computed(() => route.query.view === 'list')
|
||||
const topicTitle = computed(() => topicName.value || (isTopicLoading.value ? '주제 불러오는 중...' : ''))
|
||||
const topicTitle = computed(() => topicName.value || '')
|
||||
const publicTierLists = computed(() => tierLists.value.filter((tierList) => !tierList.isFeatured))
|
||||
|
||||
function fmt(ts) {
|
||||
@@ -88,6 +88,9 @@ watch(
|
||||
[topicId, query],
|
||||
() => {
|
||||
topicName.value = ''
|
||||
featuredTierLists.value = []
|
||||
tierLists.value = []
|
||||
brokenThumbnailIds.value = {}
|
||||
error.value = ''
|
||||
loadTierLists()
|
||||
},
|
||||
@@ -100,8 +103,16 @@ watch(
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Collection</div>
|
||||
<h2 class="pageHead__title">{{ topicTitle }}</h2>
|
||||
<div class="pageHead__desc">이 주제의 공개 티어표를 같은 카드 레이아웃으로 살펴보고 이어서 새 티어표를 만들 수 있어요.</div>
|
||||
<template v-if="isTopicLoading && !topicTitle">
|
||||
<div class="topicHeadSkeleton" aria-hidden="true">
|
||||
<div class="topicHeadSkeleton__line topicHeadSkeleton__line--title"></div>
|
||||
<div class="topicHeadSkeleton__line topicHeadSkeleton__line--desc"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h2 class="pageHead__title">{{ topicTitle }}</h2>
|
||||
<div class="pageHead__desc">이 주제의 공개 티어표를 같은 카드 레이아웃으로 살펴보고 이어서 새 티어표를 만들 수 있어요.</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -194,6 +205,39 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.topicHeadSkeleton {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.topicHeadSkeleton__line {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--theme-surface-soft-2) 88%, transparent);
|
||||
}
|
||||
.topicHeadSkeleton__line::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.08) 48%, transparent 100%);
|
||||
transform: translateX(-100%);
|
||||
animation: topicHeadSkeletonShimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
.topicHeadSkeleton__line--title {
|
||||
width: min(280px, 72%);
|
||||
height: 34px;
|
||||
}
|
||||
.topicHeadSkeleton__line--desc {
|
||||
width: min(560px, 94%);
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
@keyframes topicHeadSkeletonShimmer {
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
|
||||
Reference in New Issue
Block a user