diff --git a/backend/src/db.js b/backend/src/db.js index f329632..bdea22c 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -140,6 +140,9 @@ function mapTierListRow(row) { thumbnailSrc: row.thumbnail_src || '', description: row.description || '', isPublic: !!row.is_public, + isFeatured: !!row.is_featured, + featuredAt: Number(row.featured_at || 0), + featuredBy: row.featured_by || '', showCharacterNames: !!row.show_character_names, iconSize: Number(row.icon_size || 80), sourceTierListId: row.source_tierlist_id || '', @@ -378,6 +381,9 @@ async function ensureSchema() { thumbnail_src VARCHAR(255) NOT NULL DEFAULT '', description TEXT NOT NULL, is_public TINYINT(1) NOT NULL DEFAULT 0, + is_featured TINYINT(1) NOT NULL DEFAULT 0, + featured_at BIGINT NOT NULL DEFAULT 0, + featured_by VARCHAR(64) NOT NULL DEFAULT '', show_character_names TINYINT(1) NOT NULL DEFAULT 0, icon_size INT NOT NULL DEFAULT 80, source_tierlist_id VARCHAR(64) NULL DEFAULT NULL, @@ -390,6 +396,7 @@ async function ensureSchema() { INDEX idx_tierlists_author_id (author_id), INDEX idx_tierlists_topic_id (topic_id), INDEX idx_tierlists_public_topic_updated (is_public, topic_id, updated_at), + INDEX idx_tierlists_featured_topic (is_public, is_featured, topic_id, featured_at), CONSTRAINT fk_tierlists_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT fk_tierlists_topic FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 @@ -525,6 +532,18 @@ async function ensureSchema() { if (!tierListShowNamesColumns.length) { await query("ALTER TABLE tierlists ADD COLUMN show_character_names TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public") } + const tierListFeaturedColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'is_featured'") + if (!tierListFeaturedColumns.length) { + await query("ALTER TABLE tierlists ADD COLUMN is_featured TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public") + } + const tierListFeaturedAtColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'featured_at'") + if (!tierListFeaturedAtColumns.length) { + await query("ALTER TABLE tierlists ADD COLUMN featured_at BIGINT NOT NULL DEFAULT 0 AFTER is_featured") + } + const tierListFeaturedByColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'featured_by'") + if (!tierListFeaturedByColumns.length) { + await query("ALTER TABLE tierlists ADD COLUMN featured_by VARCHAR(64) NOT NULL DEFAULT '' AFTER featured_at") + } const tierListIconSizeColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'icon_size'") if (!tierListIconSizeColumns.length) { await query("ALTER TABLE tierlists ADD COLUMN icon_size INT NOT NULL DEFAULT 80 AFTER show_character_names") @@ -1997,6 +2016,9 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '') t.topic_id, t.title, t.thumbnail_src, + t.is_featured, + t.featured_at, + t.featured_by, t.created_at, t.updated_at, t.author_id, @@ -2006,8 +2028,8 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '') FROM tierlists t INNER JOIN users u ON u.id = t.author_id ${whereClause} - ORDER BY t.updated_at DESC - LIMIT 50 + ORDER BY t.is_featured DESC, t.featured_at DESC, t.updated_at DESC + LIMIT 200 `, params ) @@ -2017,6 +2039,8 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '') topicId: row.topic_id, title: row.title, thumbnailSrc: row.thumbnail_src || '', + isFeatured: !!row.is_featured, + featuredAt: Number(row.featured_at || 0), createdAt: Number(row.created_at), updatedAt: Number(row.updated_at), authorId: row.author_id, @@ -2029,7 +2053,22 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '') tierLists.map((tierList) => tierList.id), currentUserId ) - return applyFavoriteMetaToTierLists(tierLists, favoriteStats) + const mergedTierLists = applyFavoriteMetaToTierLists(tierLists, favoriteStats) + const featuredTierLists = mergedTierLists + .filter((tierList) => tierList.isFeatured) + .slice() + .sort( + (a, b) => + Number(b.featuredAt || 0) - Number(a.featuredAt || 0) || + Number(b.favoriteCount || 0) - Number(a.favoriteCount || 0) || + Number(b.updatedAt || 0) - Number(a.updatedAt || 0) + ) + .slice(0, 16) + + return { + featuredTierLists, + tierLists: mergedTierLists.filter((tierList) => !tierList.isFeatured), + } } async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited' } = {}) { @@ -2062,6 +2101,9 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited t.thumbnail_src, t.description, t.is_public, + t.is_featured, + t.featured_at, + t.featured_by, t.show_character_names, t.icon_size, t.source_tierlist_id, @@ -2207,6 +2249,9 @@ async function listAdminTierLists({ queryText = '', topicId = '', page = 1, limi t.thumbnail_src, t.description, t.is_public, + t.is_featured, + t.featured_at, + t.featured_by, t.show_character_names, t.icon_size, t.source_tierlist_id, @@ -2282,7 +2327,7 @@ async function summarizeAdminTierLists({ queryText = '', topicId = '' } = {}) { const whereClause = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '' const rows = await query( ` - SELECT t.is_public + SELECT t.is_public, t.is_featured FROM tierlists t INNER JOIN users u ON u.id = t.author_id INNER JOIN topics tp ON tp.id = t.topic_id @@ -2293,10 +2338,12 @@ async function summarizeAdminTierLists({ queryText = '', topicId = '' } = {}) { const total = rows.length const publicCount = rows.filter((row) => Number(row.is_public) === 1).length + const featuredCount = rows.filter((row) => Number(row.is_featured) === 1).length return { total, publicCount, privateCount: Math.max(0, total - publicCount), + featuredCount, } } @@ -2312,6 +2359,9 @@ async function findTierListById(id, currentUserId = '') { t.thumbnail_src, t.description, t.is_public, + t.is_featured, + t.featured_at, + t.featured_by, t.show_character_names, t.icon_size, t.source_tierlist_id, @@ -2520,13 +2570,38 @@ async function deleteTierList(id) { } async function updateAdminTierListMeta({ id, title, description = '', isPublic }) { + const nextUpdatedAt = now() + if (!isPublic) { + await query( + ` + UPDATE tierlists + SET title = ?, description = ?, is_public = 0, is_featured = 0, featured_at = 0, featured_by = '', updated_at = ? + WHERE id = ? + `, + [title, description || '', nextUpdatedAt, id] + ) + return findTierListById(id) + } + await query( ` UPDATE tierlists SET title = ?, description = ?, is_public = ?, updated_at = ? WHERE id = ? `, - [title, description || '', isPublic ? 1 : 0, now(), id] + [title, description || '', 1, nextUpdatedAt, id] + ) + return findTierListById(id) +} + +async function updateTierListFeaturedStatus({ id, isFeatured, adminUserId }) { + await query( + ` + UPDATE tierlists + SET is_featured = ?, featured_at = ?, featured_by = ? + WHERE id = ? + `, + [isFeatured ? 1 : 0, isFeatured ? now() : 0, isFeatured ? adminUserId || '' : '', id] ) return findTierListById(id) } @@ -2710,6 +2785,7 @@ module.exports = { summarizeAdminTierLists, findTierListById, updateAdminTierListMeta, + updateTierListFeaturedStatus, favoriteTopic, unfavoriteTopic, favoriteTierList, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 3093c70..2f99f35 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -38,6 +38,7 @@ const { summarizeAdminTierLists, findTierListById, updateAdminTierListMeta, + updateTierListFeaturedStatus, listAdminTemplateRequests, findTemplateRequestById, updateTemplateRequestStatus, @@ -380,6 +381,25 @@ router.get('/tierlists/stats', requireAdmin, async (req, res) => { res.json(result) }) +router.patch('/tierlists/:tierListId/featured', requireAdmin, async (req, res) => { + const schema = z.object({ + isFeatured: z.boolean(), + }) + const parsed = schema.safeParse(req.body) + if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) + + const tierList = await findTierListById(req.params.tierListId, req.session?.userId || '') + if (!tierList) return res.status(404).json({ error: 'not_found' }) + if (parsed.data.isFeatured && !tierList.isPublic) return res.status(400).json({ error: 'public_tierlist_required' }) + + const updated = await updateTierListFeaturedStatus({ + id: tierList.id, + isFeatured: parsed.data.isFeatured, + adminUserId: req.session.userId, + }) + res.json({ tierList: updated }) +}) + router.get('/template-requests', requireAdmin, async (req, res) => { const requests = await listAdminTemplateRequests({ statuses: ['pending', 'reviewing'] }) res.json({ requests }) diff --git a/backend/src/routes/tierlists.js b/backend/src/routes/tierlists.js index 115cd21..87a93cf 100644 --- a/backend/src/routes/tierlists.js +++ b/backend/src/routes/tierlists.js @@ -124,8 +124,8 @@ const tierListUpsertSchema = z.object({ router.get('/public', async (req, res) => { const topicId = typeof req.query.topicId === 'string' ? req.query.topicId : '' const queryText = typeof req.query.q === 'string' ? req.query.q : '' - const lists = await listPublicTierLists(topicId, req.session?.userId || '', queryText) - res.json({ tierLists: lists }) + const result = await listPublicTierLists(topicId, req.session?.userId || '', queryText) + res.json(result) }) router.get('/me', requireAuth, async (req, res) => { diff --git a/docs/history.md b/docs/history.md index e0e6ad5..c9b568c 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,10 @@ # 의사결정 이력 +## 2026-04-03 v1.4.51 +- 불특정 다수가 만드는 공개 티어표를 전부 같은 선상에 두면 첫 화면 품질 편차가 너무 커질 수 있으므로, 주제별 목록 상단에 관리자 큐레이션 `추천 티어표` 섹션을 두고 아래 일반 공개 목록과 분리하는 편이 맞다고 판단했다. +- 추천 선정은 처음부터 완전 자동화보다 운영자가 직접 지정/해제할 수 있는 수동 큐레이션을 먼저 넣는 편이 안전하므로, 좋아요 수 기반 자동 후보 필터와 팔로우 피드는 후속 작업으로 미루고 이번 릴리스에서는 추천 표시 구조와 관리자 토글만 먼저 완성했다. +- 비공개 글이 추천 섹션에 올라가면 접근 정책이 꼬이므로, 추천 지정은 공개 글만 허용하고 공개글을 비공개로 바꾸면 추천 상태도 함께 해제하는 쪽으로 정리했다. + ## 2026-04-03 v1.4.49 - 프로필 저장 실패를 하나의 일반 실패 메시지로만 보여주면 사용자가 “서버가 고장났나?”라고 오해하기 쉬우므로, 중복 닉네임/예약어 닉네임처럼 사용자가 직접 고칠 수 있는 입력 오류는 원인별 안내를 분리하는 편이 맞다고 판단했다. - 비밀번호를 잊은 사용자뿐 아니라 로그인 중인 사용자도 보안상 주기적으로 비밀번호를 직접 바꿀 수 있어야 하므로, 설정 화면에 현재 비밀번호 확인 기반 변경 폼을 추가하는 쪽으로 정리했다. diff --git a/docs/map.md b/docs/map.md index 7207284..ccb82b5 100644 --- a/docs/map.md +++ b/docs/map.md @@ -7,7 +7,7 @@ ## `/topics/:topicId` - 화면 파일: `frontend/src/views/TopicHubView.vue` -- 역할: 선택한 주제 정보 표시, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입 +- 역할: 선택한 주제 정보 표시, 관리자 추천 티어표 상단 강조 섹션과 일반 공개 티어표 목록 분리 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 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` @@ -37,8 +37,8 @@ ## `/admin` - 화면 파일: `frontend/src/views/AdminView.vue` -- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `템플릿 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 템플릿 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, 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/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` +- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `템플릿 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 템플릿 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/추천 지정 토글/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, 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` ## `/profile` - 화면 파일: `frontend/src/views/ProfileView.vue` diff --git a/docs/spec.md b/docs/spec.md index 77482ef..90da192 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -109,6 +109,9 @@ - 사용자가 직접 지정하지 않으면 저장 시 티어표 대표 아이템 이미지로 자동 채운다. - `description`: string - `isPublic`: boolean + - `isFeatured`: boolean + - `featuredAt`: number + - `featuredBy`: string - `groups`: `{ id, name, itemIds[] }[]` - `pool`: `{ id, src, label, origin }[]` - `createdAt`: number @@ -147,6 +150,7 @@ - `GET /api/topics/:topicId` - 티어표 - `GET /api/tierlists/public` + - `featuredTierLists`와 일반 공개 `tierLists`를 분리해서 반환한다. - `topicId` 없이 `q`만 전달하면 전체 공개 티어표 검색에 사용한다. - `GET /api/tierlists/me` - `GET /api/tierlists/favorites/me` @@ -165,6 +169,8 @@ - 여러 이미지를 한 번에 최대 `100개`까지 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다. - `PATCH /api/admin/templates/:templateId/items/:itemId` - `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` @@ -194,6 +200,7 @@ - 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다. - 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다. - 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 내부에서 `템플릿 요청 관리 / 전체 티어표 관리`를 분리해 볼 수 있고, 확인용 완성본은 탐색 UI 없는 preview 전용 모달로 연다. +- `전체 티어표 관리`에서는 공개 티어표를 `추천 지정 / 추천 해제`할 수 있고, 추천 지정된 티어표는 주제별 공개 목록 상단의 `추천 티어표` 섹션에 먼저 노출된다. 비공개 티어표는 추천 지정할 수 없고, 추천글을 비공개로 바꾸면 추천 상태도 함께 해제된다. - `티어표 관리` 탭의 추가 아이템은 작은 그리드 카드로 표시하고, 클릭 시 `기존 템플릿에 추가 / 새 템플릿 만들기` 모달을 통해 목적지를 선택한다. - `티어표 관리` 탭에서는 티어표 안의 커스텀 아이템을 개별 또는 일괄로 기존 게임 템플릿에 복제할 수 있다. - `freeform` 티어표는 관리자 화면에서 새 게임 ID/이름을 입력해 새로운 게임 템플릿으로 복제 생성할 수 있다. @@ -217,6 +224,7 @@ - 공개 티어표는 목록과 상세 화면에서 즐겨찾기 토글 및 개수를 함께 표시한다. - 카드형 목록에서는 즐겨찾기 수/상태만 표시하고, 실제 토글은 상세 화면에서 처리한다. - 공개 티어표 목록은 현재 게임 기준으로 제목/작성자 검색을 지원한다. +- 주제별 공개 티어표 화면은 관리자 추천글을 상단 `추천 티어표` 섹션으로 먼저 보여주고, 일반 공개 목록은 아래 `전체 공개 티어표` 섹션으로 분리해 중복 없이 렌더링한다. 추천 섹션은 최대 16개까지 표시한다. - `내 즐겨찾기` 화면에서는 즐겨찾기한 순, 최신 업데이트순, 인기순 정렬을 제공한다. - 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다. - 사용자가 직접 추가한 커스텀 아이템 이름은 편집 화면 우측 목록에서 정리할 수 있고, 저장 시 원본 커스텀 아이템 라벨과 함께 동기화된다. diff --git a/docs/todo.md b/docs/todo.md index 8d8e48d..9e52fac 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,6 +1,9 @@ # 할 일 및 이슈 ## 단기 확인 +- `v1.4.51`에서 주제별 공개 목록을 `추천 티어표 / 전체 공개 티어표`로 분리했으므로, 추천 지정된 티어표가 상단 강조 섹션에만 나오고 아래 일반 목록에는 중복되지 않는지, 추천 해제 즉시 아래 일반 목록으로 내려가는지 확인한다. +- 관리자 `전체 티어표 관리`에서 공개 글은 `추천 지정 / 추천 해제`가 정상 동작하고, 비공개 글은 추천 지정 버튼이 비활성화되며, 추천글을 비공개로 바꾸면 추천 상태가 자동 해제되는지 QA한다. +- 추천 섹션은 최대 16개까지만 보여주도록 잘라두었으므로, 17개 이상 추천 지정 시 최근 지정순과 좋아요 수 보조 정렬이 기대대로 적용되는지 한 번 더 확인한다. - `v1.4.50`에서 설정 화면을 좌우 2열 카드형으로 나눴으므로, 데스크톱 폭에서는 프로필 정보가 왼쪽, 비밀번호 변경이 오른쪽에 나란히 보이고, 모바일/좁은 폭에서는 두 카드가 자연스럽게 위아래로 쌓이는지 확인한다. - `v1.4.49`에서 설정 화면에 비밀번호 변경 섹션을 추가했으므로, 현재 비밀번호가 틀린 경우 `현재 비밀번호가 일치하지 않아요.`, 새 비밀번호 확인이 다른 경우 `비밀번호 확인이 일치하지 않아요.`, 성공 시 `비밀번호를 변경했어요.` 토스트가 각각 정확히 뜨는지 확인한다. - 설정 화면 닉네임 저장도 중복/예약어 에러를 구체적으로 보여주도록 바꿨으므로, 이미 사용 중인 닉네임과 예약어 닉네임을 각각 넣었을 때 서버 문제처럼 보이지 않고 원인 문구가 정확히 뜨는지 QA한다. @@ -132,6 +135,8 @@ - 아이템 관리의 같은 이미지 참조 요약과 상세 모달은 붙였으므로, 실제 운영 데이터에서 카드 수치와 모달 목록이 기대한 참조 관계와 맞는지 한 번 더 QA한다. ## 중기 개선 +- 특정 작성자 팔로우, 작성자 프로필 페이지, 팔로우한 작성자 티어표만 모아보는 피드 화면을 추가한다. +- 추천 티어표는 이번에 관리자 수동 지정부터 붙였으므로, 다음 단계에서는 최근 N일 좋아요 수 기준 추천 후보 필터와 추천 섹션 노출 개수 설정을 관리자 화면에 추가할지 검토한다. - 이메일 인증/비밀번호 재설정 1차 구현이 들어갔으므로, 다음 단계에서는 Gmail 발신 기반이 실제 운영에서 스팸함으로 얼마나 가는지 보고 필요하면 Cloudflare DNS의 SPF/DKIM/DMARC와 도메인 발신 주소 전환을 정리한다. - 구글 계정 로그인은 아직 붙이지 않았으므로, 이메일 인증 안정화 후 Google OAuth 클라이언트를 만들고 일반 이메일 계정과 같은 이메일의 구글 계정을 자동 연결할지 정책을 먼저 결정한다. - 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다. diff --git a/docs/update.md b/docs/update.md index b11d655..a6347f2 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 로그 +## 2026-04-03 v1.4.51 +- 주제별 공개 티어표 목록을 `추천 티어표`와 `전체 공개 티어표`로 분리해, 관리자가 추천 지정한 글은 상단 강조 섹션에 먼저 보여주고 아래 일반 목록에서는 중복 노출되지 않도록 정리했다. +- 관리자 `전체 티어표 관리` 카드에 `추천 지정 / 추천 해제` 버튼과 `추천 노출중` 배지를 추가하고, 상단 통계에도 추천 개수를 함께 표시하도록 보강했다. +- 백엔드 `tierlists`에 `is_featured`, `featured_at`, `featured_by`를 추가하고, 공개 목록 API가 추천 티어표 최대 16개와 일반 공개 티어표 목록을 분리해서 내려주도록 확장했다. +- 비공개 티어표를 추천으로 지정하려는 경우는 서버에서 `public_tierlist_required`로 차단하고, 이미 추천된 글을 비공개로 전환하면 추천 상태도 자동 해제되도록 맞췄다. + ## 2026-04-03 v1.4.50 - 설정 화면 메인 영역이 `max-width: 620px` 단일 컬럼으로 고정되어 넓은 화면에서 오른쪽 공간이 많이 비어 보였으므로, 프로필 정보 카드와 비밀번호 변경 카드를 좌우 2열 그리드로 나누고 좁은 화면에서만 1열로 내려가도록 레이아웃을 재정리했다. - 왼쪽 카드는 아바타/닉네임/이메일/로그아웃/프로필 저장을, 오른쪽 카드는 현재 비밀번호 확인과 새 비밀번호 저장을 담당하게 분리해, 설정 화면의 정보 묶음이 더 명확하게 읽히도록 맞췄다. diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 3169156..737011e 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -87,6 +87,8 @@ export const api = { request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId)}`), updateAdminTierList: (tierListId, payload) => request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'PATCH', body: payload }), + updateAdminTierListFeatured: (tierListId, payload) => + request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/featured`, { method: 'PATCH', body: payload }), deleteAdminTierList: (tierListId) => request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'DELETE' }), listAdminTemplateRequests: () => request('/api/admin/template-requests'), diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 8d7e520..b712405 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -54,7 +54,7 @@ const adminTierListTopicId = ref('') const adminTierListPage = ref(1) const adminTierListLimit = ref(50) const adminTierListTotal = ref(0) -const adminTierListStats = ref({ total: 0, publicCount: 0, privateCount: 0 }) +const adminTierListStats = ref({ total: 0, publicCount: 0, privateCount: 0, featuredCount: 0 }) const selectedTemplateTierListStats = ref({ total: 0, publicCount: 0, privateCount: 0 }) const templateRequests = ref([]) const importModalOpen = ref(false) @@ -277,6 +277,7 @@ const adminOverviewStats = computed(() => { ] : [ { label: '검색 결과', value: `${adminTierListStats.value.total || 0}` }, + { label: '추천', value: `${adminTierListStats.value.featuredCount || 0}` }, { label: '공개', value: `${adminTierListStats.value.publicCount || 0}` }, { label: '비공개', value: `${adminTierListStats.value.privateCount || 0}` }, { label: '현재 페이지', value: `${adminTierListPage.value}/${adminTierListPageCount.value}` }, @@ -844,9 +845,10 @@ async function refreshAdminTierListStats() { total: data.total || 0, publicCount: data.publicCount || 0, privateCount: data.privateCount || 0, + featuredCount: data.featuredCount || 0, } } catch (e) { - adminTierListStats.value = { total: 0, publicCount: 0, privateCount: 0 } + adminTierListStats.value = { total: 0, publicCount: 0, privateCount: 0, featuredCount: 0 } } } @@ -1472,6 +1474,27 @@ async function deleteAdminTierListEntry() { } } +async function toggleAdminTierListFeatured(tierList) { + if (!tierList?.id) return + const nextFeatured = !tierList.isFeatured + resetMessages() + + try { + const data = await api.updateAdminTierListFeatured(tierList.id, { isFeatured: nextFeatured }) + const updated = data.tierList || {} + adminTierLists.value = adminTierLists.value.map((entry) => (entry.id === tierList.id ? { ...entry, ...updated } : entry)) + if (previewTierList.value?.id === tierList.id) previewTierList.value = { ...previewTierList.value, ...updated } + if (modalTargetAdminTierList.value?.id === tierList.id) { + modalTargetAdminTierList.value = { ...modalTargetAdminTierList.value, ...updated } + } + await refreshAdminTierListStats() + success.value = nextFeatured ? '추천 티어표로 지정했어요.' : '추천 지정을 해제했어요.' + } catch (e) { + error.value = + e?.data?.error === 'public_tierlist_required' ? '공개 티어표만 추천으로 지정할 수 있어요.' : '추천 상태 변경에 실패했어요.' + } +} + function openAdminTierList(tierList) { previewTierList.value = tierList previewModalOpen.value = true @@ -1782,6 +1805,7 @@ function userAvatarFallback(user) { :admin-tier-list-total="adminTierListTotal" :admin-tier-list-stats="adminTierListStats" :open-admin-tier-list-manage-modal="openAdminTierListManageModal" + :toggle-admin-tier-list-featured="toggleAdminTierListFeatured" :move-admin-tier-list-page="moveAdminTierListPage" /> diff --git a/frontend/src/views/SearchResultsView.vue b/frontend/src/views/SearchResultsView.vue index 1f23a05..d0d73b8 100644 --- a/frontend/src/views/SearchResultsView.vue +++ b/frontend/src/views/SearchResultsView.vue @@ -46,7 +46,14 @@ async function loadResults() { error.value = '' try { const data = await api.searchAllPublicTierLists(query.value) - tierLists.value = data.tierLists || [] + const featuredItems = Array.isArray(data.featuredTierLists) ? data.featuredTierLists : [] + const publicItems = Array.isArray(data.tierLists) ? data.tierLists : [] + const seen = new Set() + tierLists.value = [...featuredItems, ...publicItems].filter((tierList) => { + if (!tierList?.id || seen.has(tierList.id)) return false + seen.add(tierList.id) + return true + }) } catch (e) { error.value = '검색 결과를 불러오지 못했어요.' } finally { diff --git a/frontend/src/views/TopicHubView.vue b/frontend/src/views/TopicHubView.vue index 0ad6648..0083a81 100644 --- a/frontend/src/views/TopicHubView.vue +++ b/frontend/src/views/TopicHubView.vue @@ -12,6 +12,7 @@ const auth = useAuthStore() const topicId = computed(() => route.params.topicId) const topicName = ref('') +const featuredTierLists = ref([]) const tierLists = ref([]) const error = ref('') const query = ref('') @@ -19,6 +20,7 @@ const brokenThumbnailIds = ref({}) const isTopicLoading = ref(false) const isListView = computed(() => route.query.view === 'list') const topicTitle = computed(() => topicName.value || (isTopicLoading.value ? '주제 불러오는 중...' : '')) +const publicTierLists = computed(() => tierLists.value.filter((tierList) => !tierList.isFeatured)) function fmt(ts) { return new Date(ts).toLocaleDateString(undefined, { @@ -59,6 +61,7 @@ async function loadTierLists() { ]) topicName.value = topicRes.topic?.name || '' brokenThumbnailIds.value = {} + featuredTierLists.value = listRes.featuredTierLists || [] tierLists.value = listRes.tierLists || [] } catch (e) { error.value = '주제 정보를 불러오지 못했어요.' @@ -110,10 +113,65 @@ watch(