diff --git a/backend/src/db.js b/backend/src/db.js index 150c675..1cf0a58 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -666,13 +666,18 @@ function applyFavoriteMetaToTierLists(tierLists, favoriteStats) { })) } -async function listPublicTierLists(gameId, currentUserId = '') { +async function listPublicTierLists(gameId, currentUserId = '', queryText = '') { const params = [] let whereClause = 'WHERE t.is_public = 1' if (gameId) { whereClause += ' AND t.game_id = ?' params.push(gameId) } + if ((queryText || '').trim()) { + const search = `%${queryText.trim()}%` + whereClause += ' AND (t.title LIKE ? OR u.nickname LIKE ? OR u.email LIKE ?)' + params.push(search, search, search) + } const rows = await query( ` @@ -716,6 +721,67 @@ async function listPublicTierLists(gameId, currentUserId = '') { return applyFavoriteMetaToTierLists(tierLists, favoriteStats) } +async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited' } = {}) { + const allowedSort = new Set(['favorited', 'updated', 'favorites']) + const normalizedSort = allowedSort.has(sort) ? sort : 'favorited' + const params = [userId] + let whereClause = 'WHERE f.user_id = ?' + + if ((queryText || '').trim()) { + const search = `%${queryText.trim()}%` + whereClause += ' AND (t.title LIKE ? OR g.name LIKE ? OR u.nickname LIKE ? OR u.email LIKE ?)' + params.push(search, search, search, search) + } + + const orderClause = + normalizedSort === 'updated' + ? 'ORDER BY t.updated_at DESC, f.created_at DESC' + : normalizedSort === 'favorites' + ? 'ORDER BY favorite_count DESC, t.updated_at DESC' + : 'ORDER BY f.created_at DESC, t.updated_at DESC' + + const rows = await query( + ` + SELECT + t.id, + t.author_id, + t.game_id, + g.name AS game_name, + t.title, + t.thumbnail_src, + t.description, + t.is_public, + t.groups_json, + t.pool_json, + t.created_at, + t.updated_at, + f.created_at AS favorited_at, + u.nickname, + u.email, + u.avatar_src, + ( + SELECT COUNT(*) + FROM favorite_tierlists ff + WHERE ff.tierlist_id = t.id + ) AS favorite_count + FROM favorite_tierlists f + INNER JOIN tierlists t ON t.id = f.tierlist_id + INNER JOIN users u ON u.id = t.author_id + INNER JOIN games g ON g.id = t.game_id + ${whereClause} + ${orderClause} + `, + params + ) + + return rows.map((row) => ({ + ...mapTierListRow(row), + favoritedAt: Number(row.favorited_at || 0), + favoriteCount: Number(row.favorite_count || 0), + isFavorited: true, + })) +} + async function listUserTierLists(userId) { const rows = await query( ` @@ -971,6 +1037,7 @@ module.exports = { listCustomItems, findUnusedCustomItems, listPublicTierLists, + listFavoriteTierLists, listUserTierLists, listAdminTierLists, findTierListById, diff --git a/backend/src/routes/tierlists.js b/backend/src/routes/tierlists.js index 0c95a50..ec90ad5 100644 --- a/backend/src/routes/tierlists.js +++ b/backend/src/routes/tierlists.js @@ -6,6 +6,7 @@ const { nanoid } = require('nanoid') const { findTierListById, listPublicTierLists, + listFavoriteTierLists, listUserTierLists, deleteTierList, saveTierList, @@ -89,7 +90,8 @@ const tierListUpsertSchema = z.object({ router.get('/public', async (req, res) => { const gameId = req.query.gameId - const lists = await listPublicTierLists(gameId, req.session?.userId || '') + const queryText = typeof req.query.q === 'string' ? req.query.q : '' + const lists = await listPublicTierLists(gameId, req.session?.userId || '', queryText) res.json({ tierLists: lists }) }) @@ -98,6 +100,13 @@ router.get('/me', requireAuth, async (req, res) => { res.json({ tierLists: lists }) }) +router.get('/favorites/me', requireAuth, async (req, res) => { + const queryText = typeof req.query.q === 'string' ? req.query.q : '' + const sort = typeof req.query.sort === 'string' ? req.query.sort : 'favorited' + const lists = await listFavoriteTierLists(req.session.userId, { queryText, sort }) + res.json({ tierLists: lists }) +}) + router.get('/:id', async (req, res) => { const t = await findTierListById(req.params.id, req.session?.userId || '') if (!t) return res.status(404).json({ error: 'not_found' }) diff --git a/docs/history.md b/docs/history.md index 898b306..2bf8ea5 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,10 @@ # 의사결정 이력 +## 2026-03-27 v0.1.44 +- 전역 토스트는 공통 composable을 템플릿에 넘길 때 top-level ref로 풀어줘야 하므로, 렌더링 구조를 단순하게 유지하는 편이 안전하다고 정리했다. +- 공개 티어표가 많아질수록 게임별 목록 안에서 바로 제목/작성자 검색이 가능해야 하므로, 검색은 별도 페이지보다 각 게임 허브 안에서 먼저 제공하기로 결정했다. +- 즐겨찾기는 저장만으로 끝나면 활용도가 낮으므로, 별도 `내 즐겨찾기` 화면과 정렬 옵션을 함께 제공해야 실사용성이 높다고 판단했다. + ## 2026-03-27 v0.1.43 - 화면 상단 인라인 경고는 스크롤이 생기면 놓치기 쉬우므로, 저장/삭제/가져오기 같은 사용자 행동 피드백은 전역 우측 상단 토스트로 통일하는 편이 더 적합하다고 정리했다. - 관리자 티어표 관리의 목적지 선택은 상단 고정 셀렉트보다 액션 직전 모달이 더 명확하므로, `기존 템플릿에 추가 / 새 템플릿 만들기`를 그 순간에 고르게 하기로 결정했다. diff --git a/docs/map.md b/docs/map.md index 67aa027..258ec59 100644 --- a/docs/map.md +++ b/docs/map.md @@ -7,7 +7,7 @@ ## `/games/:gameId` - 화면 파일: `frontend/src/views/GameHubView.vue` -- 역할: 선택한 게임 정보 표시, 공개 티어표 목록 표시, 티어표별 상단 썸네일/작성자 표시, 즐겨찾기 토글, 새 티어표 작성 진입 +- 역할: 선택한 게임 정보 표시, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 상단 썸네일/작성자 표시, 즐겨찾기 토글, 새 티어표 작성 진입 - 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite` ## `/editor/:gameId/new`, `/editor/:gameId/:tierListId` @@ -25,6 +25,11 @@ - 역할: 내 티어표 목록 조회, 상단 썸네일 카드 표시, 편집 화면으로 이동, 작성자 본인 티어표 삭제 - 연동 API: `GET /api/tierlists/me`, `DELETE /api/tierlists/:id` +## `/favorites` +- 화면 파일: `frontend/src/views/FavoriteTierListsView.vue` +- 역할: 즐겨찾기한 티어표 목록 조회, 검색/정렬, 편집 화면 이동, 즐겨찾기 해제 +- 연동 API: `GET /api/tierlists/favorites/me`, `DELETE /api/tierlists/:id/favorite` + ## `/admin` - 화면 파일: `frontend/src/views/AdminView.vue` - 역할: `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭 분리, 선택된 게임의 썸네일 관리, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/완성본 이동, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 게임 템플릿화, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제 diff --git a/docs/spec.md b/docs/spec.md index e826a82..348d647 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -81,6 +81,7 @@ - 티어표 - `GET /api/tierlists/public` - `GET /api/tierlists/me` + - `GET /api/tierlists/favorites/me` - `GET /api/tierlists/:id` - `POST /api/tierlists/:id/favorite` - `DELETE /api/tierlists/:id/favorite` @@ -130,6 +131,8 @@ - 비로그인 사용자는 공개된 티어표를 열람만 할 수 있고, 편집 UI와 저장 동작은 비활성화된다. - 비공개 티어표라도 관리자는 편집 화면에서 완성본을 열람할 수 있다. - 공개 티어표는 목록과 상세 화면에서 즐겨찾기 토글 및 개수를 함께 표시한다. +- 공개 티어표 목록은 현재 게임 기준으로 제목/작성자 검색을 지원한다. +- `내 즐겨찾기` 화면에서는 즐겨찾기한 순, 최신 업데이트순, 인기순 정렬을 제공한다. - 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다. - 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다. - 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다. diff --git a/docs/todo.md b/docs/todo.md index b93ded5..3e8e1e0 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -9,8 +9,9 @@ - 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다. - 관리자 티어표 관리의 템플릿 생성은 현재 `freeform`만 직접 지원하므로, 필요하면 일반 게임 티어표의 전체 아이템을 복제한 파생 템플릿 생성 UX도 검토한다. - 관리자 티어표 관리의 추가 아이템 승격은 현재 커스텀(origin=`custom`) 아이템 기준이므로, 필요하면 “기존 게임 아이템과 비교한 차집합” 기준으로 더 정교하게 확장할 수 있다. -- 즐겨찾기는 현재 저장/토글만 지원하므로, 필요하면 `내 즐겨찾기` 목록 화면이나 즐겨찾기순 정렬을 추가 검토한다. +- 즐겨찾기는 현재 `내 즐겨찾기` 목록과 정렬까지 지원하므로, 필요하면 폴더 분류나 메모 같은 개인 정리 기능을 추가 검토한다. - 전역 토스트는 기본 시간 기반 자동 종료만 지원하므로, 필요하면 중복 합치기나 액션 링크 포함 형태로 확장할 수 있다. +- 공개 티어표 검색은 현재 게임별 허브 안에서만 제공하므로, 필요하면 홈 전역 통합 검색도 검토한다. ## 배포 전 작업 - NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다. diff --git a/docs/update.md b/docs/update.md index 7def1e8..df11722 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-03-27 v0.1.44 +- **토스트 렌더링 버그 수정**: 전역 토스트가 빈 카드 여러 개로 보이던 ref 참조 문제를 수정해 실제 메시지만 표시되도록 정리 +- **공개 티어표 검색 추가**: 게임별 공개 티어표 목록에서 제목/작성자 기준 검색이 가능하도록 검색창과 API 쿼리 지원 추가 +- **내 즐겨찾기 페이지 추가**: 사용자별 즐겨찾기 목록 화면과 `즐겨찾기한 순 / 최신 업데이트순 / 인기순` 정렬 옵션을 추가 + ## 2026-03-27 v0.1.43 - **전역 토스트 알림 추가**: 저장/삭제/가져오기 같은 사용자 행동 피드백을 상단 인라인 경고 대신 우측 상단 토스트로 통일해 잠시 표시 후 자동으로 사라지도록 변경 - **관리자 티어표 아이템 가져오기 모달화**: 티어표 관리의 추가 아이템 영역을 소형 그리드로 다듬고, 가져오기 시점에 `기존 템플릿에 추가 / 새 템플릿 만들기`를 선택하는 모달 흐름으로 재정리 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index d14b76c..582055f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -8,7 +8,7 @@ import { useToast } from './composables/useToast' const route = useRoute() const router = useRouter() const auth = useAuthStore() -const toast = useToast() +const { toasts, dismissToast } = useToast() const isAdmin = computed(() => !!auth.user?.isAdmin) const avatarUrl = computed(() => { if (!auth.user?.avatarSrc) return '' @@ -65,6 +65,7 @@ async function logout() {