Compare commits

..

2 Commits

13 changed files with 295 additions and 15 deletions

View File

@@ -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,

View File

@@ -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 })

View File

@@ -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) => {

View File

@@ -1,5 +1,10 @@
# 의사결정 이력
## 2026-04-03 v1.4.51
- 불특정 다수가 만드는 공개 티어표를 전부 같은 선상에 두면 첫 화면 품질 편차가 너무 커질 수 있으므로, 주제별 목록 상단에 관리자 큐레이션 `추천 티어표` 섹션을 두고 아래 일반 공개 목록과 분리하는 편이 맞다고 판단했다.
- 추천 선정은 처음부터 완전 자동화보다 운영자가 직접 지정/해제할 수 있는 수동 큐레이션을 먼저 넣는 편이 안전하므로, 좋아요 수 기반 자동 후보 필터와 팔로우 피드는 후속 작업으로 미루고 이번 릴리스에서는 추천 표시 구조와 관리자 토글만 먼저 완성했다.
- 비공개 글이 추천 섹션에 올라가면 접근 정책이 꼬이므로, 추천 지정은 공개 글만 허용하고 공개글을 비공개로 바꾸면 추천 상태도 함께 해제하는 쪽으로 정리했다.
## 2026-04-03 v1.4.49
- 프로필 저장 실패를 하나의 일반 실패 메시지로만 보여주면 사용자가 “서버가 고장났나?”라고 오해하기 쉬우므로, 중복 닉네임/예약어 닉네임처럼 사용자가 직접 고칠 수 있는 입력 오류는 원인별 안내를 분리하는 편이 맞다고 판단했다.
- 비밀번호를 잊은 사용자뿐 아니라 로그인 중인 사용자도 보안상 주기적으로 비밀번호를 직접 바꿀 수 있어야 하므로, 설정 화면에 현재 비밀번호 확인 기반 변경 폼을 추가하는 쪽으로 정리했다.

View File

@@ -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`

View File

@@ -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개까지 표시한다.
- `내 즐겨찾기` 화면에서는 즐겨찾기한 순, 최신 업데이트순, 인기순 정렬을 제공한다.
- 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다.
- 사용자가 직접 추가한 커스텀 아이템 이름은 편집 화면 우측 목록에서 정리할 수 있고, 저장 시 원본 커스텀 아이템 라벨과 함께 동기화된다.

View File

@@ -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에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다.

View File

@@ -1,5 +1,14 @@
# 업데이트 로그
## 2026-04-03 v1.4.52
- 관리자 전체 티어표 카드 컴포넌트의 `추천 지정 / 추천 해제` 버튼과 `추천 노출중` 배지, 추천 개수 통계 표시가 실제 릴리스 커밋에 함께 포함되도록 누락 파일을 다시 묶었다.
## 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열로 내려가도록 레이아웃을 재정리했다.
- 왼쪽 카드는 아바타/닉네임/이메일/로그아웃/프로필 저장을, 오른쪽 카드는 현재 비밀번호 확인과 새 비밀번호 저장을 담당하게 분리해, 설정 화면의 정보 묶음이 더 명확하게 읽히도록 맞췄다.

View File

@@ -23,6 +23,7 @@ const props = defineProps({
adminTierListTotal: { type: Number, required: true },
adminTierListStats: { type: Object, required: true },
openAdminTierListManageModal: { type: Function, required: true },
toggleAdminTierListFeatured: { type: Function, required: true },
moveAdminTierListPage: { type: Function, required: true },
})
</script>
@@ -128,6 +129,7 @@ const props = defineProps({
<div class="panel__title">전체 티어표 관리</div>
<div class="tierAdminHeaderStats">
<span class="pill">전체 {{ props.adminTierListStats.total || 0 }}</span>
<span class="pill pill--accent">추천 {{ props.adminTierListStats.featuredCount || 0 }}</span>
<span class="pill pill--soft">공개 {{ props.adminTierListStats.publicCount || 0 }}</span>
<span class="pill pill--soft">비공개 {{ props.adminTierListStats.privateCount || 0 }}</span>
</div>
@@ -156,6 +158,7 @@ const props = defineProps({
<div class="tierAdminCard__stats">
<span class="pill" :class="tierList.isPublic ? 'pill--public' : 'pill--private'">{{ props.tierListVisibilityLabel(tierList) }}</span>
<span v-if="tierList.isFeatured" class="pill pill--accent">추천 노출중</span>
<span class="pill">전체 아이템 {{ tierList.itemCount }}</span>
<span class="pill" :class="{ 'pill--accent': tierList.extraItemCount > 0 }">추가 아이템 {{ tierList.extraItemCount }}</span>
</div>
@@ -177,6 +180,14 @@ const props = defineProps({
</div>
<div class="tierAdminSection__actions">
<button
class="btn btn--small"
:class="tierList.isFeatured ? 'btn--ghost' : 'btn--primary'"
:disabled="!tierList.isPublic && !tierList.isFeatured"
@click="props.toggleAdminTierListFeatured(tierList)"
>
{{ tierList.isFeatured ? '추천 해제' : '추천 지정' }}
</button>
<button class="btn btn--ghost btn--small" @click="props.openAdminTierListManageModal(tierList)">관리</button>
</div>
</div>

View File

@@ -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'),

View File

@@ -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"
/>

View File

@@ -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 {

View File

@@ -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(
</section>
<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 class="featuredHead__count">{{ featuredTierLists.length }}</div>
</div>
<div class="list featuredList" :class="{ 'list--table': isListView }">
<article
v-for="t in featuredTierLists"
:key="`featured-${t.id}`"
class="boardCard boardCard--featured"
:class="{ 'boardCard--list': isListView }"
>
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" @click="openTierList(t.id)">
<div class="boardCard__thumbWrap">
<img
v-if="tierListThumbnailUrl(t)"
class="boardCard__thumb"
:src="tierListThumbnailUrl(t)"
alt=""
draggable="false"
@error="handleThumbnailError(t.id)"
/>
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div>
<div class="boardCard__head">
<div class="boardCard__titleRow">
<div class="boardCard__title">{{ t.title }}</div>
<div class="favoriteStat" :title="t.isFavorited ? '이미 즐겨찾기한 티어표' : '즐겨찾기 수'">
{{ t.isFavorited ? '♥' : '♡' }} {{ t.favoriteCount || 0 }}
</div>
</div>
<div class="boardCard__metaRow">
<div class="boardCard__author">
<img
v-if="avatarSrcOf(t)"
class="boardCard__avatar"
:src="avatarSrcOf(t)"
:alt="displayNameOf(t)"
draggable="false"
/>
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
</div>
<div class="boardCard__date">{{ fmt(t.updatedAt) }}</div>
</div>
</div>
</button>
</article>
</div>
</section>
<section class="panel">
<div v-if="tierLists.length === 0" class="empty">아직 공개 티어표 없어요.</div>
<div class="sectionLabel">전체 공개 티어표</div>
<div v-if="publicTierLists.length === 0" class="empty">아직 일반 공개 티어표가 없어요.</div>
<div v-else class="list" :class="{ 'list--table': isListView }">
<article v-for="t in tierLists" :key="t.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
<article v-for="t in publicTierLists" :key="t.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" @click="openTierList(t.id)">
<div class="boardCard__thumbWrap">
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" alt="" draggable="false" @error="handleThumbnailError(t.id)" />
@@ -148,6 +206,44 @@ watch(
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);
}
.sectionLabel {
margin-bottom: 14px;
}
.toolbar {
display: flex;
gap: 10px;
@@ -206,6 +302,12 @@ watch(
background: var(--theme-card-bg-hover);
transform: translateY(-2px);
}
.boardCard--featured {
border-color: color-mix(in srgb, var(--theme-accent) 35%, var(--theme-card-border));
background:
linear-gradient(180deg, color-mix(in srgb, var(--theme-accent) 7%, transparent), transparent 55%),
var(--theme-card-bg);
}
.boardCard__body {
min-width: 0;
text-align: left;
@@ -361,6 +463,17 @@ watch(
}
@media (max-width: 720px) {
.featuredPanel {
padding: 18px;
border-radius: 22px;
}
.featuredHead {
align-items: flex-start;
flex-direction: column;
gap: 8px;
}
.list {
grid-template-columns: 1fr;
}