홈 피드와 템플릿 분리

This commit is contained in:
2026-04-07 12:30:20 +09:00
parent 8fc8872114
commit 2c0b5268fa
22 changed files with 652 additions and 229 deletions

View File

@@ -136,6 +136,11 @@ function canManageAdminRole(actingUser, primaryAdmin) {
return !!actingUser?.isAdmin && primaryAdmin?.id === actingUser.id return !!actingUser?.isAdmin && primaryAdmin?.id === actingUser.id
} }
router.get('/templates', requireAdmin, async (req, res) => {
const templates = await listTopics('', { includePrivate: true })
res.json({ topics: templates })
})
router.post('/templates', requireAdmin, async (req, res) => { router.post('/templates', requireAdmin, async (req, res) => {
const schema = z.object({ const schema = z.object({
slug: z.string().trim().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), slug: z.string().trim().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),

View File

@@ -5,7 +5,7 @@ const { requireAuth } = require('../middleware/auth')
const router = express.Router() const router = express.Router()
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
const topics = await listTopics(req.session?.userId || '', { includePrivate: !!req.session?.isAdmin }) const topics = await listTopics(req.session?.userId || '', { includePrivate: false })
res.json({ topics }) res.json({ topics })
}) })

View File

@@ -1,5 +1,10 @@
# 의사결정 이력 # 의사결정 이력
## 2026-04-07 v1.1.0
- 홈은 템플릿 진입 화면과 성격이 다르므로, 공개 티어표 피드와 템플릿 목록을 분리하는 편이 정보 구조상 더 자연스럽다고 정리했다.
- 비공개 템플릿은 관리자라도 일반 사용자 화면 문법 안에서는 보이지 않아야 하므로, 일반 목록과 관리자 관리 목록 API를 분리하는 방향을 택했다.
- 아바타 fallback 은 이메일 계정명보다 사용자가 직접 정한 닉네임을 우선하는 편이 화면 인상이 더 일관적이라고 정리했다.
## 2026-04-07 기준 복원 메모 ## 2026-04-07 기준 복원 메모
- 최근 회귀는 개별 버그 패치로 좁히기보다, 마지막 안정 버전인 `v1.0.104`를 기준선으로 되돌린 뒤 기능을 다시 쌓는 편이 더 안전하다고 판단했다. - 최근 회귀는 개별 버그 패치로 좁히기보다, 마지막 안정 버전인 `v1.0.104`를 기준선으로 되돌린 뒤 기능을 다시 쌓는 편이 더 안전하다고 판단했다.
- 특히 티어표 편집 화면은 새로고침/라우트 전환 안정성이 핵심이므로, 이후 기능 추가는 편집기 내부 생명주기를 건드리는 범위를 최소화하는 방향으로 진행한다. - 특히 티어표 편집 화면은 새로고침/라우트 전환 안정성이 핵심이므로, 이후 기능 추가는 편집기 내부 생명주기를 건드리는 범위를 최소화하는 방향으로 진행한다.

View File

@@ -2,8 +2,13 @@
## `/` ## `/`
- 화면 파일: `frontend/src/views/HomeView.vue` - 화면 파일: `frontend/src/views/HomeView.vue`
- 역할: 데스크톱 기본 4열 주제 카드 라이브러리 대시보드, 상단 메인 썸네일과 `주제명 + 작은 ID` 메타를 가진 템플릿 선택 카드, 주제 카드 클릭 이동, `직접 티어표 만들기` 진입 - 역할: 공개 티어표 홈 피드, 상단 `추천 티어표`와 아래 `최신 공개 티어표` 목록을 같은 카드 문법으로 표시, 검색어(`q`)가 있으면 공개 티어표 제목/작성자 기준으로 필터링, 카드 클릭 시 해당 티어표 화면으로 이동
- 연동 API: `GET /api/topics` - 연동 API: `GET /api/tierlists/public?q=...`
## `/templates`
- 화면 파일: `frontend/src/views/TemplatesView.vue`
- 역할: 공개 템플릿 전용 목록, 관리자 수동 순서와 즐겨찾기 여부를 반영한 주제 템플릿 카드 목록 표시, 템플릿 즐겨찾기 토글, 검색어(`q`)가 있으면 템플릿 이름/slug 기준으로 즉시 필터링
- 연동 API: `GET /api/topics`, `POST /api/topics/:topicId/favorite`, `DELETE /api/topics/:topicId/favorite`
## `/topics/:topicId` ## `/topics/:topicId`
- 화면 파일: `frontend/src/views/TopicHubView.vue` - 화면 파일: `frontend/src/views/TopicHubView.vue`
@@ -48,7 +53,7 @@
## `/admin` ## `/admin`
- 화면 파일: `frontend/src/views/AdminView.vue` - 화면 파일: `frontend/src/views/AdminView.vue`
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `템플릿 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 템플릿 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 템플릿 이름/slug 수정, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/받은 즐겨찾기 수 표시/인기순 정렬/최소 즐겨찾기 필터/추천 지정 토글/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원의 작성 티어표·팔로워·받은 즐겨찾기·최근 콘텐츠 활동·마지막 접속일 확인과 회원 정보·권한 수정 및 공개 프로필 보기, 파일 입력 초기화, 아이템 삭제, 템플릿 삭제 - 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `템플릿 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 템플릿 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 템플릿 이름/slug 수정, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/받은 즐겨찾기 수 표시/인기순 정렬/최소 즐겨찾기 필터/추천 지정 토글/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원의 작성 티어표·팔로워·받은 즐겨찾기·최근 콘텐츠 활동·마지막 접속일 확인과 회원 정보·권한 수정 및 공개 프로필 보기, 파일 입력 초기화, 아이템 삭제, 템플릿 삭제
- 연동 API: `POST /api/admin/templates`, `POST /api/admin/templates/:templateId/thumbnail`, `POST /api/admin/templates/:templateId/images`, `PATCH /api/admin/templates/:templateId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `GET /api/admin/tierlists/stats`, `PATCH /api/admin/tierlists/:tierListId/featured`, `GET /api/admin/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/template-requests/:requestId/link-template`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/templates/:templateId/items/:itemId`, `DELETE /api/admin/templates/:templateId` - 연동 API: `GET /api/admin/templates`, `POST /api/admin/templates`, `POST /api/admin/templates/:templateId/thumbnail`, `POST /api/admin/templates/:templateId/images`, `PATCH /api/admin/templates/:templateId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `GET /api/admin/tierlists/stats`, `PATCH /api/admin/tierlists/:tierListId/featured`, `GET /api/admin/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/template-requests/:requestId/link-template`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/templates/:templateId/items/:itemId`, `DELETE /api/admin/templates/:templateId`
## `/profile` ## `/profile`
- 화면 파일: `frontend/src/views/ProfileView.vue` - 화면 파일: `frontend/src/views/ProfileView.vue`
@@ -57,7 +62,7 @@
## 공통 레이아웃 ## 공통 레이아웃
- 앱 셸 파일: `frontend/src/App.vue` - 앱 셸 파일: `frontend/src/App.vue`
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, `preview=1` 공유 프리뷰에서도 같은 좌우 레일과 중앙 헤더 유지, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링, 관리자/에디터 화면이 Teleport로 사용하는 `#local-right-rail-root` 대상 DOM을 상시 유지해 라우트 전환 중 우측 레일 언마운트 순서를 안정화 - 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, `홈 / 템플릿` 분리 네비게이션과 화면별 검색 placeholder 전환, `preview=1` 공유 프리뷰에서도 같은 좌우 레일과 중앙 헤더 유지, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링, 관리자/에디터 화면이 Teleport로 사용하는 `#local-right-rail-root` 대상 DOM을 상시 유지해 라우트 전환 중 우측 레일 언마운트 순서를 안정화
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker`와 서비스 설명이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다. - 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker`와 서비스 설명이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
## 백엔드 진입점 ## 백엔드 진입점

View File

@@ -33,12 +33,21 @@
- 상단 토글 버튼은 항상 고정되어 있고, 패널을 축소하면 텍스트를 숨기고 아이콘 중심 레일로 전환한다. - 상단 토글 버튼은 항상 고정되어 있고, 패널을 축소하면 텍스트를 숨기고 아이콘 중심 레일로 전환한다.
- `Settings`는 별도 메뉴 항목으로만 진입하며, 사용자 카드 자체는 정보 표시 용도로만 사용한다. - `Settings`는 별도 메뉴 항목으로만 진입하며, 사용자 카드 자체는 정보 표시 용도로만 사용한다.
- 사용자 아바타는 원형 보더 스타일을 유지하고, `Favorites` 영역은 최근 즐겨찾기 티어표 최대 10개를 메인 메뉴보다 작은 밀도의 바로가기 목록으로 보여준 뒤 하단 `즐겨찾기 더 보기` 링크로 전체 즐겨찾기 화면에 연결한다. - 사용자 아바타는 원형 보더 스타일을 유지하고, `Favorites` 영역은 최근 즐겨찾기 티어표 최대 10개를 메인 메뉴보다 작은 밀도의 바로가기 목록으로 보여준 뒤 하단 `즐겨찾기 더 보기` 링크로 전체 즐겨찾기 화면에 연결한다.
- 사용자 아바타가 없을 때 표시하는 fallback 이니셜은 계정명보다 닉네임을 우선한다.
- 중앙 워크스페이스 - 중앙 워크스페이스
- 현재 라우트의 핵심 콘텐츠를 렌더링하는 영역이며, 홈/목록 계열 화면은 카드형 대시보드 레이아웃을 우선 적용한다. - 현재 라우트의 핵심 콘텐츠를 렌더링하는 영역이며, 홈/목록 계열 화면은 카드형 대시보드 레이아웃을 우선 적용한다.
- 공통 `workspaceBody`는 별도 외곽 카드 테두리 없이 셸 여백만 제공하고, 실제 카드/패널 레이어는 각 화면 내부에서만 구성한다. - 공통 `workspaceBody`는 별도 외곽 카드 테두리 없이 셸 여백만 제공하고, 실제 카드/패널 레이어는 각 화면 내부에서만 구성한다.
- 홈, 게임 허브, 내 티어표, 즐겨찾기, 검색 결과 화면은 같은 카드 문법(상단 16:9 썸네일, `제목+좋아요` 1행, `작성자+최종 수정일` 1행)을 공유하며, 데스크톱 기준 기본 4열 카드 그리드를 사용한다. - 홈, 게임 허브, 내 티어표, 즐겨찾기, 검색 결과 화면은 같은 카드 문법(상단 16:9 썸네일, `제목+좋아요` 1행, `작성자+최종 수정일` 1행)을 공유하며, 데스크톱 기준 기본 4열 카드 그리드를 사용한다.
- 단, 홈 게임 선택 카드는 템플릿 선택용이므로 상단 메인 썸네일은 유지하되, 하단 메타는 `게임명 + 작은 ID`만 간결하게 표시한다. - `/` 홈은 템플릿 선택 화면이 아니라 `공개 티어표 피드`이며, 추천 티어표와 최신 공개 티어표를 같은 보드 카드 문법으로 보여준다.
- `/templates`는 공개 템플릿 전용 화면이며, 템플릿 카드는 상단 메인 썸네일과 `주제명 + 작은 slug/id` 메타를 가진다.
- 목록 계열 화면의 상단 도구 영역은 통계 카드와 액션 버튼을 공통 높이/반경으로 맞춰, 같은 라이브러리 대시보드로 읽히도록 정리한다. - 목록 계열 화면의 상단 도구 영역은 통계 카드와 액션 버튼을 공통 높이/반경으로 맞춰, 같은 라이브러리 대시보드로 읽히도록 정리한다.
## 주요 라우트/데이터 규칙
- 일반 `GET /api/topics`는 로그인한 관리자라도 공개 템플릿만 반환한다.
- 관리자 전용 템플릿 목록은 `GET /api/admin/templates`를 사용하며, 비공개 템플릿까지 포함한다.
- 홈 피드(`/`)는 `GET /api/tierlists/public?q=...`를 사용한다.
- `featuredTierLists`: 상단 추천 티어표
- `tierLists`: 추천 제외 최신 공개 티어표
- 우측 패널 - 우측 패널
- 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다. - 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다.
- 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다. - 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다.

View File

@@ -1,5 +1,11 @@
# 할 일 및 이슈 # 할 일 및 이슈
## 단기 확인
- `v1.1.0`에서 홈을 공개 티어표 피드로, 템플릿을 `/templates`로 분리했으므로 왼쪽 사이드 `홈 / 템플릿 / 나의 티어표 / 설정` 흐름과 검색 placeholder가 각 화면에서 자연스럽게 바뀌는지 확인한다.
- 관리자 계정으로 일반 템플릿 목록(`/templates`)에 들어가도 비공개 템플릿이 보이지 않고, 관리자 화면에서는 여전히 비공개 템플릿이 관리 가능한지 확인한다.
- 홈 피드의 추천 티어표와 최신 공개 티어표 카드가 데스크톱/태블릿/모바일에서 overflow 없이 안정적으로 보이는지 확인한다.
- 아바타 fallback 이니셜이 썸네일 미등록 상태에서 계정명이 아니라 닉네임 첫 글자로 보이는지 홈/주제 허브/나의 티어표/즐겨찾기/팔로우 피드/검색 결과/사용자 프로필에서 각각 확인한다.
## 다음 작업자 인수인계 ## 다음 작업자 인수인계
- 현재 기준선은 `v1.0.104`다. 홈 피드와 댓글 기능은 이 버전 위에서 다시 구현해야 하며, 편집 화면 로딩/새로고침 안정성이 먼저다. - 현재 기준선은 `v1.0.104`다. 홈 피드와 댓글 기능은 이 버전 위에서 다시 구현해야 하며, 편집 화면 로딩/새로고침 안정성이 먼저다.
- 홈 피드는 기존 템플릿 메인 화면과 분리된 별도 `/` 화면으로 두되, 데이터 원천은 `공개 티어표 목록` API로만 시작한다. 첫 단계에서는 `최근 공개 티어표` 목록과 `관리자 추천 티어표` 상단 섹션만 붙이고, 템플릿 화면 카드 문법을 최대한 재사용한다. - 홈 피드는 기존 템플릿 메인 화면과 분리된 별도 `/` 화면으로 두되, 데이터 원천은 `공개 티어표 목록` API로만 시작한다. 첫 단계에서는 `최근 공개 티어표` 목록과 `관리자 추천 티어표` 상단 섹션만 붙이고, 템플릿 화면 카드 문법을 최대한 재사용한다.

View File

@@ -1,5 +1,12 @@
# 업데이트 로그 # 업데이트 로그
## 2026-04-07 v1.1.0
- 홈 화면을 템플릿 목록이 아니라 `공개 티어표 피드`로 다시 구성했다. 공개 티어표는 최신 업데이트순으로 표시하고, 관리자가 추천한 티어표는 상단 `추천 티어표` 섹션에 별도로 유지한다.
- 템플릿 목록은 새 `/templates` 화면으로 분리했다. 기존 템플릿 카드 문법과 즐겨찾기 토글은 그대로 유지하면서, 홈과 템플릿의 역할을 분리했다.
- 일반 `/api/topics`는 이제 관리자 계정이라도 `비공개 템플릿`을 포함하지 않는다. 관리자 화면은 별도 `/api/admin/templates` 목록 API를 사용해 비공개 템플릿까지 관리한다.
- 사용자 아바타가 없을 때 표시하는 fallback 이니셜 기준을 계정명보다 `닉네임 우선`으로 통일했다. 홈/주제 허브/나의 티어표/즐겨찾기/팔로우 피드/검색 결과/사용자 프로필/앱 셸까지 같은 helper를 사용한다.
- 확인: `npm run build`
## 2026-04-07 기준 복원 메모 ## 2026-04-07 기준 복원 메모
- 현재 작업 트리는 사용자 요청에 따라 `v1.0.104` 태그 기준으로 전체 복원했다. - 현재 작업 트리는 사용자 요청에 따라 `v1.0.104` 태그 기준으로 전체 복원했다.
- 복원 이유: 티어표 편집 화면에 `initial loading states`와 이후 홈 피드/댓글 기능을 얹는 과정에서 새로고침 시 사이트 전체가 멈추는 회귀가 발생했고, 편집 화면 진입 경로도 일부 저장본에서 `freeform`으로 잘못 열리는 문제가 겹쳤다. - 복원 이유: 티어표 편집 화면에 `initial loading states`와 이후 홈 피드/댓글 기능을 얹는 과정에서 새로고침 시 사이트 전체가 멈추는 회귀가 발생했고, 편집 화면 진입 경로도 일부 저장본에서 `freeform`으로 잘못 열리는 문제가 겹쳤다.

View File

@@ -2,7 +2,8 @@
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from './stores/auth' import { useAuthStore } from './stores/auth'
import { editorNewPath, favoritesPath, followingFeedPath, homePath, loginPath, mePath } from './lib/paths' import { editorNewPath, favoritesPath, followingFeedPath, homePath, loginPath, mePath, templatesPath } from './lib/paths'
import { displayInitialFrom } from './lib/display'
import { toApiUrl } from './lib/runtime' import { toApiUrl } from './lib/runtime'
import { useToast } from './composables/useToast' import { useToast } from './composables/useToast'
import iconDockToLeft from './assets/icons/dock_to_left.svg' import iconDockToLeft from './assets/icons/dock_to_left.svg'
@@ -30,7 +31,7 @@ const leftRailCollapsed = ref(false)
const mobileLeftNavOpen = ref(false) const mobileLeftNavOpen = ref(false)
const rightRailOpen = ref(true) const rightRailOpen = ref(true)
const searchQuery = ref('') const searchQuery = ref('')
const leftRailSearchPlaceholder = '주제 템플릿 검색' const leftRailSearchPlaceholder = computed(() => (route.name === 'templates' ? '주제 템플릿 검색' : '공개 티어표 검색'))
const isCollapsedSearchOpen = ref(false) const isCollapsedSearchOpen = ref(false)
const isGuideModalOpen = ref(false) const isGuideModalOpen = ref(false)
const themeMode = ref('dark') const themeMode = ref('dark')
@@ -70,7 +71,8 @@ const shellStyle = computed(() => ({
})) }))
const leftNavItems = computed(() => { const leftNavItems = computed(() => {
const items = [ const items = [
{ key: 'home', label: '주제 선택', path: '/', iconSrc: iconGridView }, { key: 'home', label: '', path: '/', iconSrc: iconGridView },
{ key: 'templates', label: '템플릿', path: '/templates', iconSrc: iconDashboardCustomize },
{ key: 'me', label: '나의 티어표', path: '/me', iconSrc: iconLists, requiresAuth: true }, { key: 'me', label: '나의 티어표', path: '/me', iconSrc: iconLists, requiresAuth: true },
{ key: 'favorites', label: '즐겨찾기', path: '/favorites', iconSrc: iconFavorite, requiresAuth: true }, { key: 'favorites', label: '즐겨찾기', path: '/favorites', iconSrc: iconFavorite, requiresAuth: true },
{ key: 'followingFeed', label: '팔로우 피드', path: '/following', iconSrc: iconKidStar, requiresAuth: true }, { key: 'followingFeed', label: '팔로우 피드', path: '/following', iconSrc: iconKidStar, requiresAuth: true },
@@ -161,6 +163,9 @@ const leftBottomPrimaryAction = computed(() => {
if (route.name === 'home' && auth.user) { if (route.name === 'home' && auth.user) {
return { label: '커스텀 티어표 만들기', to: editorNewPath('freeform'), iconSrc: iconDashboardCustomize } return { label: '커스텀 티어표 만들기', to: editorNewPath('freeform'), iconSrc: iconDashboardCustomize }
} }
if (route.name === 'templates' && auth.user) {
return { label: '커스텀 티어표 만들기', to: editorNewPath('freeform'), iconSrc: iconDashboardCustomize }
}
if (route.name === 'topicHub') { if (route.name === 'topicHub') {
const target = editorNewPath(currentTopicId.value) const target = editorNewPath(currentTopicId.value)
return { label: '새 티어표 만들기', to: auth.user ? target : loginPath(target), iconSrc: iconAddNotes } return { label: '새 티어표 만들기', to: auth.user ? target : loginPath(target), iconSrc: iconAddNotes }
@@ -171,10 +176,22 @@ const leftBottomPrimaryAction = computed(() => {
const routeMeta = computed(() => { const routeMeta = computed(() => {
if (route.name === 'home') { if (route.name === 'home') {
return { return {
title: '주제 선택', title: '',
subtitle: '주제 템플릿 선택과 커스텀 보드 시작', subtitle: '공개 티어표 피드',
contextTitle: '빠른 시작', contextTitle: '빠른 시작',
contextText: auth.user ? '커스텀 티어표를 만들거나 원하는 주제를 바로 선택할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.', contextText: auth.user ? '추천 티어표와 최신 공개 티어표를 둘러보고 바로 새 작업을 시작할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
actionLabel: '템플릿 보기',
action: () => {
router.push(templatesPath())
},
}
}
if (route.name === 'templates') {
return {
title: '템플릿',
subtitle: '주제 템플릿 선택',
contextTitle: '빠른 시작',
contextText: auth.user ? '주제 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
actionLabel: auth.user ? '커스텀 티어표 만들기' : '로그인하러 가기', actionLabel: auth.user ? '커스텀 티어표 만들기' : '로그인하러 가기',
action: () => { action: () => {
router.push(auth.user ? editorNewPath('freeform') : loginPath()) router.push(auth.user ? editorNewPath('freeform') : loginPath())
@@ -539,7 +556,7 @@ function handleLeftRailSearch() {
function submitGlobalSearch() { function submitGlobalSearch() {
const query = (searchQuery.value || '').trim() const query = (searchQuery.value || '').trim()
isCollapsedSearchOpen.value = false isCollapsedSearchOpen.value = false
router.push(homePath(query)) router.push(route.name === 'templates' ? templatesPath(query) : homePath(query))
} }
function reloadApp() { function reloadApp() {
@@ -593,7 +610,7 @@ function reloadApp() {
<div v-if="authReady" class="appUserCard"> <div v-if="authReady" class="appUserCard">
<div class="appUserCard__button"> <div class="appUserCard__button">
<img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" draggable="false" /> <img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" draggable="false" />
<div v-else class="appUserCard__avatar appUserCard__avatar--fallback">{{ accountName[0]?.toUpperCase() || 'U' }}</div> <div v-else class="appUserCard__avatar appUserCard__avatar--fallback">{{ displayInitialFrom(auth.user?.nickname, accountName, 'U') }}</div>
<div class="appUserCard__meta"> <div class="appUserCard__meta">
<div class="appUserCard__name">{{ accountName }}</div> <div class="appUserCard__name">{{ accountName }}</div>
<div class="appUserCard__email" :class="{ 'appUserCard__email--hint': isAccountEmailHint }">{{ accountEmail }}</div> <div class="appUserCard__email" :class="{ 'appUserCard__email--hint': isAccountEmailHint }">{{ accountEmail }}</div>

View File

@@ -67,6 +67,7 @@ export const api = {
logout: () => request('/api/auth/logout', { method: 'POST' }), logout: () => request('/api/auth/logout', { method: 'POST' }),
listTopics: () => request('/api/topics'), listTopics: () => request('/api/topics'),
listAdminTemplates: () => request('/api/admin/templates'),
getTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}`), getTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}`),
favoriteTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}/favorite`, { method: 'POST' }), favoriteTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}/favorite`, { method: 'POST' }),
unfavoriteTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}/favorite`, { method: 'DELETE' }), unfavoriteTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}/favorite`, { method: 'DELETE' }),

View File

@@ -0,0 +1,9 @@
export function displayInitialFrom(primaryName = '', fallbackName = '', emptyValue = 'U') {
const primary = String(primaryName || '').trim()
if (primary) return Array.from(primary)[0] || emptyValue
const fallback = String(fallbackName || '').trim()
if (fallback) return Array.from(fallback)[0] || emptyValue
return emptyValue
}

View File

@@ -7,6 +7,11 @@ export function homePath(query = '') {
return normalized ? `/?q=${encodeURIComponent(normalized)}` : '/' return normalized ? `/?q=${encodeURIComponent(normalized)}` : '/'
} }
export function templatesPath(query = '') {
const normalized = String(query || '').trim()
return normalized ? `/templates?q=${encodeURIComponent(normalized)}` : '/templates'
}
export function loginPath(redirect = '') { export function loginPath(redirect = '') {
const normalized = String(redirect || '').trim() const normalized = String(redirect || '').trim()
return normalized ? `/login?redirect=${encodeURIComponent(normalized)}` : '/login' return normalized ? `/login?redirect=${encodeURIComponent(normalized)}` : '/login'

View File

@@ -1,6 +1,7 @@
import { createRouter as _createRouter, createWebHistory } from 'vue-router' import { createRouter as _createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue' import HomeView from '../views/HomeView.vue'
import TemplatesView from '../views/TemplatesView.vue'
import TopicHubView from '../views/TopicHubView.vue' import TopicHubView from '../views/TopicHubView.vue'
import TierEditorView from '../views/TierEditorView.vue' import TierEditorView from '../views/TierEditorView.vue'
import LoginView from '../views/LoginView.vue' import LoginView from '../views/LoginView.vue'
@@ -18,6 +19,7 @@ export function createRouter() {
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
{ path: '/', name: 'home', component: HomeView }, { path: '/', name: 'home', component: HomeView },
{ path: '/templates', name: 'templates', component: TemplatesView },
{ path: '/topics/:topicId', name: 'topicHub', component: TopicHubView }, { path: '/topics/:topicId', name: 'topicHub', component: TopicHubView },
{ path: '/editor/:topicId/new', name: 'newEditor', component: TierEditorView }, { path: '/editor/:topicId/new', name: 'newEditor', component: TierEditorView },
{ path: '/editor/:topicId/:tierListId', name: 'editEditor', component: TierEditorView }, { path: '/editor/:topicId/:tierListId', name: 'editEditor', component: TierEditorView },

View File

@@ -946,7 +946,7 @@ async function selectAdminTemplate(templateId) {
async function refreshTemplates() { async function refreshTemplates() {
try { try {
const data = await api.listTopics() const data = await api.listAdminTemplates()
templates.value = data.topics || [] templates.value = data.topics || []
featuredTemplateIds.value = templates.value featuredTemplateIds.value = templates.value
.filter((template) => template.displayRank != null) .filter((template) => template.displayRank != null)

View File

@@ -5,6 +5,7 @@ import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime' import { toApiUrl } from '../lib/runtime'
import { useToast } from '../composables/useToast' import { useToast } from '../composables/useToast'
import { editorPath, loginPath } from '../lib/paths' import { editorPath, loginPath } from '../lib/paths'
import { displayInitialFrom } from '../lib/display'
const router = useRouter() const router = useRouter()
const toast = useToast() const toast = useToast()
@@ -30,7 +31,7 @@ function avatarSrcOf(tierList) {
} }
function avatarFallbackOf(tierList) { function avatarFallbackOf(tierList) {
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?' return displayInitialFrom(tierList.authorName, tierList.authorAccountName, '?')
} }
function tierListThumbnailUrl(tierList) { function tierListThumbnailUrl(tierList) {

View File

@@ -5,6 +5,7 @@ import { api } from '../lib/api'
import { editorPath, loginPath, userProfilePath } from '../lib/paths' import { editorPath, loginPath, userProfilePath } from '../lib/paths'
import { toApiUrl } from '../lib/runtime' import { toApiUrl } from '../lib/runtime'
import { useToast } from '../composables/useToast' import { useToast } from '../composables/useToast'
import { displayInitialFrom } from '../lib/display'
const router = useRouter() const router = useRouter()
const toast = useToast() const toast = useToast()
@@ -38,7 +39,7 @@ function avatarSrcOf(tierList) {
} }
function avatarFallbackOf(tierList) { function avatarFallbackOf(tierList) {
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?' return displayInitialFrom(tierList.authorName, tierList.authorAccountName, '?')
} }
function tierListThumbnailUrl(tierList) { function tierListThumbnailUrl(tierList) {

View File

@@ -2,123 +2,166 @@
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api' import { api } from '../lib/api'
import SvgIcon from '../components/SvgIcon.vue' import { editorPath } from '../lib/paths'
import kidStarIcon from '../assets/icons/kid_star.svg'
import { toApiUrl } from '../lib/runtime' import { toApiUrl } from '../lib/runtime'
import { loginPath, topicPath } from '../lib/paths' import { displayInitialFrom } from '../lib/display'
import { useAuthStore } from '../stores/auth'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const auth = useAuthStore()
const templateRecords = ref([]) const featuredTierLists = ref([])
const tierLists = ref([])
const error = ref('') const error = ref('')
const loadingFavoriteId = ref('') const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim() : ''))
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim().toLowerCase() : '')) const brokenThumbnailIds = ref({})
const templates = computed(() => {
const filtered = templateRecords.value
.filter((item) => item.id !== 'freeform')
.filter((item) => {
if (!query.value) return true
const haystack = `${item.name || ''} ${item.slug || ''}`.toLowerCase()
return haystack.includes(query.value)
})
return filtered.slice().sort((a, b) => { function fmt(ts) {
if (!!a.isFavorited !== !!b.isFavorited) return a.isFavorited ? -1 : 1 return new Date(ts).toLocaleDateString(undefined, {
const rankA = a.displayRank == null ? Number.MAX_SAFE_INTEGER : a.displayRank year: 'numeric',
const rankB = b.displayRank == null ? Number.MAX_SAFE_INTEGER : b.displayRank month: '2-digit',
if (rankA !== rankB) return rankA - rankB day: '2-digit',
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')
}) })
}) }
async function loadTemplates() { function displayNameOf(tierList) {
return tierList.authorName || '알 수 없음'
}
function avatarSrcOf(tierList) {
return tierList.authorAvatarSrc ? toApiUrl(tierList.authorAvatarSrc) : ''
}
function avatarFallbackOf(tierList) {
return displayInitialFrom(tierList.authorName, tierList.authorAccountName, '?')
}
function tierListThumbnailUrl(tierList) {
if (!tierList?.id || brokenThumbnailIds.value[tierList.id]) return ''
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
}
function handleThumbnailError(tierListId) {
if (!tierListId || brokenThumbnailIds.value[tierListId]) return
brokenThumbnailIds.value = { ...brokenThumbnailIds.value, [tierListId]: true }
}
async function loadHomeFeed() {
try { try {
const data = await api.listTopics() const data = await api.searchAllPublicTierLists(query.value)
templateRecords.value = data.topics || [] brokenThumbnailIds.value = {}
featuredTierLists.value = data.featuredTierLists || []
tierLists.value = data.tierLists || []
} catch (e) { } catch (e) {
error.value = '백엔드에 연결할 수 없어요. backend 서버가 실행 중인지 확인해주세요.' error.value = '공개 티어표를 불러오지 못했어요.'
} }
} }
onMounted(loadTemplates) function openTierList(tierList) {
watch(() => auth.user?.id, loadTemplates) router.push(editorPath(tierList.topicSlug || tierList.topicId, tierList.id))
function openTopic(template) {
router.push(topicPath(template?.slug || template?.id || ''))
} }
async function toggleFavorite(template, event) { onMounted(loadHomeFeed)
event?.stopPropagation() watch(() => route.query.q, loadHomeFeed)
if (!auth.user) {
router.push(loginPath(route.fullPath || '/'))
return
}
if (!template?.id || loadingFavoriteId.value === template.id) return
try {
loadingFavoriteId.value = template.id
const res = template.isFavorited ? await api.unfavoriteTopic(template.id) : await api.favoriteTopic(template.id)
templateRecords.value = templateRecords.value.map((entry) => (entry.id === template.id ? { ...entry, ...(res.topic || {}) } : entry))
} catch (e) {
error.value = '즐겨찾기 변경에 실패했어요.'
} finally {
loadingFavoriteId.value = ''
}
}
function templateThumbUrl(template) {
return template.thumbnailSrc ? toApiUrl(template.thumbnailSrc) : ''
}
</script> </script>
<template> <template>
<section class="pageHead"> <section class="pageWrap">
<div class="pageHead__main"> <section class="pageHead">
<div class="pageHead__eyebrow">Topic</div> <div class="pageHead__main">
<h1 class="pageHead__title">주제 선택</h1> <div class="pageHead__eyebrow">Feed</div>
<p class="pageHead__desc">자주 쓰는 주제 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 있어요.</p> <h1 class="pageHead__title"></h1>
<p v-if="query" class="pageHead__searchState">"{{ query }}" 맞는 주제 템플릿만 보고 있어요.</p> <div class="pageHead__desc">사용자가 공개한 티어표를 최신순으로 살펴보고, 추천 티어표는 상단에서 바로 있어요.</div>
</div> <div v-if="query" class="pageHead__searchState">"{{ query }}" 맞는 공개 티어표만 보고 있어요.</div>
</section>
<div v-if="error" class="error">{{ error }}</div>
<TransitionGroup v-if="templates.length" name="libraryCard" tag="section" class="libraryGrid">
<article v-for="template in templates" :key="template.id" class="libraryCard">
<button
class="libraryCard__favorite"
type="button"
:class="{ 'libraryCard__favorite--active': template.isFavorited }"
:disabled="loadingFavoriteId === template.id"
@click.stop="toggleFavorite(template, $event)"
>
<SvgIcon class="libraryCard__favoriteIcon" :src="kidStarIcon" :size="18" />
</button>
<button class="libraryCard__main" type="button" @click="openTopic(template)">
<div class="libraryCard__thumbWrap">
<img v-if="templateThumbUrl(template)" class="libraryCard__thumb" :src="templateThumbUrl(template)" :alt="template.name" draggable="false" />
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
</div> </div>
<div class="libraryCard__body"> </section>
<div class="libraryCard__title">{{ template.name }}</div>
<div class="libraryCard__meta">{{ template.slug || template.id }}</div> <div v-if="error" class="error">{{ error }}</div>
<section v-if="featuredTierLists.length" class="featuredPanel">
<div class="featuredHead">
<div>
<div class="featuredHead__eyebrow">Featured</div>
<h3 class="featuredHead__title">추천 티어표</h3>
</div> </div>
</button> <div class="featuredHead__count">{{ featuredTierLists.length }}</div>
</article> </div>
</TransitionGroup> <div class="list">
<div v-else class="libraryEmpty">{{ query ? '검색어에 맞는 주제 템플릿이 없어요.' : '표시할 주제 템플릿이 없어요.' }}</div> <article v-for="tierList in featuredTierLists" :key="`featured-${tierList.id}`" class="boardCard boardCard--featured">
<button class="boardCard__body" type="button" @click="openTierList(tierList)">
<div class="boardCard__thumbWrap">
<img
v-if="tierListThumbnailUrl(tierList)"
class="boardCard__thumb"
:src="tierListThumbnailUrl(tierList)"
:alt="tierList.title"
draggable="false"
@error="handleThumbnailError(tierList.id)"
/>
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div>
<div class="boardCard__head">
<div class="boardCard__titleRow">
<div class="boardCard__title">{{ tierList.title }}</div>
<div class="favoriteStat">{{ tierList.isFavorited ? '♥' : '♡' }} {{ tierList.favoriteCount || 0 }}</div>
</div>
<div class="boardCard__topic">{{ tierList.topicName || tierList.topicSlug || tierList.topicId }}</div>
<div class="boardCard__metaRow">
<div class="boardCard__author">
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" draggable="false" />
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
<span class="boardCard__authorName">{{ displayNameOf(tierList) }}</span>
</div>
<div class="boardCard__date">{{ fmt(tierList.updatedAt) }}</div>
</div>
</div>
</button>
</article>
</div>
</section>
<section class="panel">
<div class="sectionLabel">최신 공개 티어표</div>
<div v-if="tierLists.length === 0" class="empty">{{ query ? '검색어에 맞는 공개 티어표가 없어요.' : '아직 공개 티어표가 없어요.' }}</div>
<div v-else class="list">
<article v-for="tierList in tierLists" :key="tierList.id" class="boardCard">
<button class="boardCard__body" type="button" @click="openTierList(tierList)">
<div class="boardCard__thumbWrap">
<img
v-if="tierListThumbnailUrl(tierList)"
class="boardCard__thumb"
:src="tierListThumbnailUrl(tierList)"
:alt="tierList.title"
draggable="false"
@error="handleThumbnailError(tierList.id)"
/>
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div>
<div class="boardCard__head">
<div class="boardCard__titleRow">
<div class="boardCard__title">{{ tierList.title }}</div>
<div class="favoriteStat">{{ tierList.isFavorited ? '♥' : '♡' }} {{ tierList.favoriteCount || 0 }}</div>
</div>
<div class="boardCard__topic">{{ tierList.topicName || tierList.topicSlug || tierList.topicId }}</div>
<div class="boardCard__metaRow">
<div class="boardCard__author">
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" draggable="false" />
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
<span class="boardCard__authorName">{{ displayNameOf(tierList) }}</span>
</div>
<div class="boardCard__date">{{ fmt(tierList.updatedAt) }}</div>
</div>
</div>
</button>
</article>
</div>
</section>
</section>
</template> </template>
<style scoped> <style scoped>
.libraryGrid { .pageHead__searchState {
display: grid; margin-top: 8px;
grid-template-columns: repeat(4, minmax(0, 1fr)); color: var(--theme-text-muted);
gap: 18px;
} }
.error { .error {
margin: 0 0 16px; margin: 0 0 16px;
@@ -128,143 +171,204 @@ function templateThumbUrl(template) {
background: var(--theme-danger-bg); background: var(--theme-danger-bg);
color: var(--theme-text); color: var(--theme-text);
} }
.pageHead__searchState { .panel {
margin-top: 8px; background: transparent;
border-radius: 0;
padding: 0;
}
.featuredPanel {
margin-bottom: 28px;
padding: 24px;
border-radius: 28px;
border: 1px solid var(--theme-card-border);
background: linear-gradient(180deg, var(--theme-surface-soft) 0%, var(--theme-surface) 100%);
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
}
.featuredHead {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.featuredHead__eyebrow,
.sectionLabel {
font-size: 11px;
font-weight: 900;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--theme-text-faint);
}
.featuredHead__title {
margin: 6px 0 0;
font-size: 22px;
font-weight: 900;
color: var(--theme-text);
}
.featuredHead__count {
flex: 0 0 auto;
font-size: 13px;
font-weight: 800;
color: var(--theme-text-muted); color: var(--theme-text-muted);
} }
.libraryCard { .sectionLabel {
position: relative; margin-bottom: 14px;
text-align: left; }
padding: 14px; .empty {
opacity: 0.76;
}
.list {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.boardCard {
min-width: 0;
border-radius: 22px; border-radius: 22px;
border: 1px solid var(--theme-card-border); border: 1px solid var(--theme-card-border);
background: var(--theme-card-bg); background: var(--theme-card-bg);
color: var(--theme-text); color: var(--theme-text);
cursor: pointer; overflow: hidden;
display: grid;
gap: 12px;
box-shadow: inset 0 1px 0 var(--theme-card-shadow); box-shadow: inset 0 1px 0 var(--theme-card-shadow);
transition: transform 0.16s ease, background 0.16s ease; transition:
will-change: transform, opacity; transform 0.16s ease,
background 0.16s ease;
} }
.libraryCard:hover { .boardCard:hover {
background: var(--theme-card-bg-hover);
transform: translateY(-2px); transform: translateY(-2px);
background: var(--theme-card-bg-hover);
} }
.libraryCard__main { .boardCard__body {
display: grid; width: 100%;
gap: 12px; min-width: 0;
padding: 0; text-align: left;
cursor: pointer;
border: 0; border: 0;
background: transparent; background: transparent;
color: inherit; color: inherit;
text-align: left; padding: 0;
cursor: pointer; display: grid;
overflow: hidden;
} }
.libraryCard__favorite { .boardCard__thumbWrap {
position: absolute; min-width: 0;
bottom: 24px;
right: 14px;
width: 34px;
height: 34px;
border-radius: 999px;
border: 1px solid var(--theme-favorite-border);
background: var(--theme-favorite-bg);
color: var(--theme-favorite-icon);
font-size: 17px;
line-height: 1;
cursor: pointer;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
}
.libraryCard__favorite--active {
background: var(--theme-favorite-active-bg);
border-color: var(--theme-favorite-active-border);
}
.libraryCard__favoriteIcon {
opacity: 0.76;
color: var(--theme-favorite-icon);
}
.libraryCard__favorite--active .libraryCard__favoriteIcon {
opacity: 1;
color: var(--theme-favorite-active-icon);
}
.libraryCard__thumbWrap {
width: 100%; width: 100%;
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
border-radius: 14px; padding: 14px 14px 0;
border: 1px solid var(--theme-surface-soft-2); box-sizing: border-box;
background: var(--theme-thumb-fallback-bg);
overflow: hidden;
display: grid;
place-items: center;
} }
.libraryCard__thumb { .boardCard__thumb,
.boardCard__thumbPlaceholder {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: block;
border-radius: 18px;
}
.boardCard__thumb {
object-fit: cover; object-fit: cover;
} }
.libraryCard__thumbFallback { .boardCard__thumbPlaceholder {
font-size: 14px; background: var(--theme-thumb-fallback-bg);
color: var(--theme-text-faint);
}
.libraryCard__body {
display: grid; display: grid;
place-items: center;
color: var(--theme-text-faint);
font-size: 13px;
font-weight: 700;
} }
.libraryCard__title { .boardCard__head {
min-width: 0;
padding: 16px 18px 18px;
display: grid;
gap: 8px;
overflow: hidden;
}
.boardCard__titleRow,
.boardCard__metaRow {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
}
.boardCard__titleRow {
align-items: flex-start;
}
.boardCard__metaRow {
align-items: flex-end;
}
.boardCard__title {
min-width: 0;
font-weight: 800; font-weight: 800;
letter-spacing: -0.02em;
font-size: 18px; font-size: 18px;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
} }
.libraryCard__meta { .boardCard__topic {
min-width: 0;
color: var(--theme-text-soft); color: var(--theme-text-soft);
font-size: 13px; font-size: 13px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.libraryCard-move, .boardCard__author {
.libraryCard-enter-active, min-width: 0;
.libraryCard-leave-active { max-width: 100%;
transition: transform 280ms ease, opacity 220ms ease; display: inline-flex;
gap: 7px;
align-items: center;
font-size: 13px;
opacity: 0.86;
overflow: hidden;
} }
.boardCard__authorName {
.libraryCard-enter-from, min-width: 0;
.libraryCard-leave-to { white-space: nowrap;
opacity: 0; overflow: hidden;
transform: translateY(10px) scale(0.985); text-overflow: ellipsis;
} }
.boardCard__avatar {
.libraryCard-leave-active { width: 22px;
position: absolute; height: 22px;
width: calc(100% - 0px); border-radius: 9999px;
pointer-events: none; object-fit: cover;
border: 1px solid var(--theme-avatar-border);
background: var(--theme-border);
flex: 0 0 auto;
} }
.boardCard__avatar--fallback {
.libraryEmpty { display: grid;
padding: 20px 0; place-items: center;
color: var(--theme-text-muted); font-size: 11px;
font-weight: 800;
color: var(--theme-text);
}
.boardCard__date,
.favoriteStat {
flex: 0 0 auto;
font-size: 12px;
color: var(--theme-text-soft);
}
.favoriteStat {
font-weight: 800;
} }
@media (max-width: 1400px) { @media (max-width: 1400px) {
.libraryGrid { .list {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {
.libraryGrid { .list {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.libraryGrid { .list {
grid-template-columns: 1fr; grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 720px) {
.libraryGrid {
grid-template-columns: 1fr;
} }
} }
</style> </style>

View File

@@ -5,6 +5,7 @@ import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime' import { toApiUrl } from '../lib/runtime'
import { useToast } from '../composables/useToast' import { useToast } from '../composables/useToast'
import { editorPath, loginPath } from '../lib/paths' import { editorPath, loginPath } from '../lib/paths'
import { displayInitialFrom } from '../lib/display'
const router = useRouter() const router = useRouter()
const toast = useToast() const toast = useToast()
@@ -35,7 +36,7 @@ function avatarSrcOf(tierList) {
} }
function avatarFallbackOf(tierList) { function avatarFallbackOf(tierList) {
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?' return displayInitialFrom(tierList.authorName, tierList.authorAccountName, '?')
} }
function tierListThumbnailUrl(tierList) { function tierListThumbnailUrl(tierList) {

View File

@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api' import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime' import { toApiUrl } from '../lib/runtime'
import { editorPath } from '../lib/paths' import { editorPath } from '../lib/paths'
import { displayInitialFrom } from '../lib/display'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -30,7 +31,7 @@ function avatarSrcOf(tierList) {
} }
function avatarFallbackOf(tierList) { function avatarFallbackOf(tierList) {
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?' return displayInitialFrom(tierList.authorName, tierList.authorAccountName, '?')
} }
function tierListThumbnailUrl(tierList) { function tierListThumbnailUrl(tierList) {

View File

@@ -0,0 +1,263 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api'
import SvgIcon from '../components/SvgIcon.vue'
import kidStarIcon from '../assets/icons/kid_star.svg'
import { toApiUrl } from '../lib/runtime'
import { loginPath, topicPath } from '../lib/paths'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const templateRecords = ref([])
const error = ref('')
const loadingFavoriteId = ref('')
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim().toLowerCase() : ''))
const templates = computed(() => {
const filtered = templateRecords.value
.filter((item) => item.id !== 'freeform')
.filter((item) => {
if (!query.value) return true
const haystack = `${item.name || ''} ${item.slug || ''}`.toLowerCase()
return haystack.includes(query.value)
})
return filtered.slice().sort((a, b) => {
if (!!a.isFavorited !== !!b.isFavorited) return a.isFavorited ? -1 : 1
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')
})
})
async function loadTemplates() {
try {
const data = await api.listTopics()
templateRecords.value = data.topics || []
} catch (e) {
error.value = '백엔드에 연결할 수 없어요. backend 서버가 실행 중인지 확인해주세요.'
}
}
onMounted(loadTemplates)
watch(() => auth.user?.id, loadTemplates)
function openTopic(template) {
router.push(topicPath(template?.slug || template?.id || ''))
}
async function toggleFavorite(template, event) {
event?.stopPropagation()
if (!auth.user) {
router.push(loginPath(route.fullPath || '/templates'))
return
}
if (!template?.id || loadingFavoriteId.value === template.id) return
try {
loadingFavoriteId.value = template.id
const res = template.isFavorited ? await api.unfavoriteTopic(template.id) : await api.favoriteTopic(template.id)
templateRecords.value = templateRecords.value.map((entry) => (entry.id === template.id ? { ...entry, ...(res.topic || {}) } : entry))
} catch (e) {
error.value = '즐겨찾기 변경에 실패했어요.'
} finally {
loadingFavoriteId.value = ''
}
}
function templateThumbUrl(template) {
return template.thumbnailSrc ? toApiUrl(template.thumbnailSrc) : ''
}
</script>
<template>
<section class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Topic</div>
<h1 class="pageHead__title">템플릿</h1>
<p class="pageHead__desc">자주 쓰는 주제 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 있어요.</p>
<p v-if="query" class="pageHead__searchState">"{{ query }}" 맞는 주제 템플릿만 보고 있어요.</p>
</div>
</section>
<div v-if="error" class="error">{{ error }}</div>
<TransitionGroup v-if="templates.length" name="libraryCard" tag="section" class="libraryGrid">
<article v-for="template in templates" :key="template.id" class="libraryCard">
<button
class="libraryCard__favorite"
type="button"
:class="{ 'libraryCard__favorite--active': template.isFavorited }"
:disabled="loadingFavoriteId === template.id"
@click.stop="toggleFavorite(template, $event)"
>
<SvgIcon class="libraryCard__favoriteIcon" :src="kidStarIcon" :size="18" />
</button>
<button class="libraryCard__main" type="button" @click="openTopic(template)">
<div class="libraryCard__thumbWrap">
<img v-if="templateThumbUrl(template)" class="libraryCard__thumb" :src="templateThumbUrl(template)" :alt="template.name" draggable="false" />
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
</div>
<div class="libraryCard__body">
<div class="libraryCard__title">{{ template.name }}</div>
<div class="libraryCard__meta">{{ template.slug || template.id }}</div>
</div>
</button>
</article>
</TransitionGroup>
<div v-else class="libraryEmpty">{{ query ? '검색어에 맞는 주제 템플릿이 없어요.' : '표시할 주제 템플릿이 없어요.' }}</div>
</template>
<style scoped>
.libraryGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.error {
margin: 0 0 16px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--theme-danger-border);
background: var(--theme-danger-bg);
color: var(--theme-text);
}
.pageHead__searchState {
margin-top: 8px;
color: var(--theme-text-muted);
}
.libraryCard {
position: relative;
text-align: left;
padding: 14px;
border-radius: 22px;
border: 1px solid var(--theme-card-border);
background: var(--theme-card-bg);
color: var(--theme-text);
cursor: pointer;
display: grid;
gap: 12px;
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
transition: transform 0.16s ease, background 0.16s ease;
will-change: transform, opacity;
}
.libraryCard:hover {
background: var(--theme-card-bg-hover);
transform: translateY(-2px);
}
.libraryCard__main {
display: grid;
gap: 12px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.libraryCard__favorite {
position: absolute;
bottom: 24px;
right: 14px;
width: 34px;
height: 34px;
border-radius: 999px;
border: 1px solid var(--theme-favorite-border);
background: var(--theme-favorite-bg);
color: var(--theme-favorite-icon);
font-size: 17px;
line-height: 1;
cursor: pointer;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
}
.libraryCard__favorite--active {
background: var(--theme-favorite-active-bg);
border-color: var(--theme-favorite-active-border);
}
.libraryCard__favoriteIcon {
opacity: 0.76;
color: var(--theme-favorite-icon);
}
.libraryCard__favorite--active .libraryCard__favoriteIcon {
opacity: 1;
color: var(--theme-favorite-active-icon);
}
.libraryCard__thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 14px;
border: 1px solid var(--theme-surface-soft-2);
background: var(--theme-thumb-fallback-bg);
overflow: hidden;
display: grid;
place-items: center;
}
.libraryCard__thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
.libraryCard__thumbFallback {
font-size: 14px;
color: var(--theme-text-faint);
}
.libraryCard__body {
display: grid;
min-width: 0;
}
.libraryCard__title {
font-weight: 800;
letter-spacing: -0.02em;
font-size: 18px;
}
.libraryCard__meta {
color: var(--theme-text-soft);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.libraryCard-move,
.libraryCard-enter-active,
.libraryCard-leave-active {
transition: transform 280ms ease, opacity 220ms ease;
}
.libraryCard-enter-from,
.libraryCard-leave-to {
opacity: 0;
transform: translateY(10px) scale(0.985);
}
.libraryCard-leave-active {
position: absolute;
width: calc(100% - 0px);
pointer-events: none;
}
.libraryEmpty {
padding: 20px 0;
color: var(--theme-text-muted);
}
@media (max-width: 1400px) {
.libraryGrid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1200px) {
.libraryGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.libraryGrid {
grid-template-columns: minmax(0, 1fr);
}
}
</style>

View File

@@ -1756,16 +1756,6 @@ onUnmounted(() => {
> >
× ×
</button> </button>
<button
v-if="canRemoveEditorItem(id) && !isExporting"
class="cellDeleteBtn"
type="button"
title="커스텀 이미지 제거"
@pointerdown.stop
@click.stop="deleteEditorItem(id)"
>
삭제
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -1852,16 +1842,6 @@ onUnmounted(() => {
draggable="false" draggable="false"
/> />
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div> <div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
<button
v-if="canRemoveEditorItem(id)"
class="poolItem__deleteBtn"
type="button"
title="커스텀 이미지 제거"
@pointerdown.stop
@click.stop="deleteEditorItem(id)"
>
삭제
</button>
<div v-if="!canEdit" class="poolItem__state">미배치</div> <div v-if="!canEdit" class="poolItem__state">미배치</div>
</div> </div>
</div> </div>
@@ -3210,7 +3190,6 @@ onUnmounted(() => {
} }
.poolItem--selected { .poolItem--selected {
border-color: rgba(96, 165, 250, 0.58); 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 { .poolItem .thumb {
width: 100%; width: 100%;
@@ -3296,7 +3275,7 @@ onUnmounted(() => {
opacity: 0.3; opacity: 0.3;
} }
.chosen { .chosen {
outline: 2px solid rgba(110, 231, 183, 0.5); border: 1px solid rgba(110, 231, 183, 0.5);
border-radius: 14px; border-radius: 14px;
} }
@media (max-width: 980px) { @media (max-width: 980px) {

View File

@@ -5,6 +5,7 @@ import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime' import { toApiUrl } from '../lib/runtime'
import { editorNewPath, editorPath, loginPath } from '../lib/paths' import { editorNewPath, editorPath, loginPath } from '../lib/paths'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { displayInitialFrom } from '../lib/display'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -39,7 +40,7 @@ function avatarSrcOf(tierList) {
} }
function avatarFallbackOf(tierList) { function avatarFallbackOf(tierList) {
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?' return displayInitialFrom(tierList.authorName, tierList.authorAccountName, '?')
} }
function tierListThumbnailUrl(tierList) { function tierListThumbnailUrl(tierList) {

View File

@@ -6,6 +6,7 @@ import { editorPath, followingFeedPath, loginPath } from '../lib/paths'
import { toApiUrl } from '../lib/runtime' import { toApiUrl } from '../lib/runtime'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { useToast } from '../composables/useToast' import { useToast } from '../composables/useToast'
import { displayInitialFrom } from '../lib/display'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -23,7 +24,7 @@ const brokenThumbnailIds = ref({})
const profileAvatarUrl = computed(() => (profile.value?.avatarSrc ? toApiUrl(profile.value.avatarSrc) : '')) const profileAvatarUrl = computed(() => (profile.value?.avatarSrc ? toApiUrl(profile.value.avatarSrc) : ''))
const profileDisplayName = computed(() => profile.value?.nickname || profile.value?.accountName || '알 수 없음') const profileDisplayName = computed(() => profile.value?.nickname || profile.value?.accountName || '알 수 없음')
const profileFallback = computed(() => (profile.value?.accountName || 'u').trim().charAt(0).toUpperCase() || '?') const profileFallback = computed(() => displayInitialFrom(profile.value?.nickname, profile.value?.accountName, '?'))
const canFollow = computed(() => !!auth.user && !!profile.value && !profile.value.isSelf) const canFollow = computed(() => !!auth.user && !!profile.value && !profile.value.isSelf)
watch(error, (message) => { watch(error, (message) => {
@@ -49,7 +50,7 @@ function avatarSrcOf(tierList) {
} }
function avatarFallbackOf(tierList) { function avatarFallbackOf(tierList) {
return (tierList.authorAccountName || profile.value?.accountName || 'u').trim().charAt(0).toUpperCase() || '?' return displayInitialFrom(tierList.authorName, tierList.authorAccountName || profile.value?.accountName, '?')
} }
function tierListThumbnailUrl(tierList) { function tierListThumbnailUrl(tierList) {