Compare commits

...

5 Commits

25 changed files with 1760 additions and 76 deletions

View File

@@ -12,6 +12,7 @@ const { ensureData } = require('./src/db')
const authRoutes = require('./src/routes/auth')
const topicsRoutes = require('./src/routes/topics')
const tierListsRoutes = require('./src/routes/tierlists')
const usersRoutes = require('./src/routes/users')
const adminRoutes = require('./src/routes/admin')
const app = express()
@@ -85,6 +86,7 @@ app.use(async (req, res, next) => {
app.use('/api/auth', authRoutes)
app.use('/api/topics', topicsRoutes)
app.use('/api/tierlists', tierListsRoutes)
app.use('/api/users', usersRoutes)
app.use('/api/admin', adminRoutes)
app.listen(PORT, () => {

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
@@ -419,6 +426,18 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await query(`
CREATE TABLE IF NOT EXISTS user_follows (
follower_id VARCHAR(64) NOT NULL,
following_id VARCHAR(64) NOT NULL,
created_at BIGINT NOT NULL,
PRIMARY KEY (follower_id, following_id),
INDEX idx_user_follows_following (following_id, created_at),
CONSTRAINT fk_user_follows_follower FOREIGN KEY (follower_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_follows_following FOREIGN KEY (following_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await query(`
CREATE TABLE IF NOT EXISTS image_assets (
id VARCHAR(64) PRIMARY KEY,
@@ -525,6 +544,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 +2028,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 +2040,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 +2051,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 +2065,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 +2113,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,
@@ -2140,6 +2194,198 @@ async function listUserTierLists(userId) {
return applyFavoriteMetaToTierLists(tierLists, favoriteStats)
}
async function findUserProfileById(userId, currentUserId = '') {
const rows = await query(
`
SELECT
u.id,
u.email,
u.nickname,
u.avatar_src,
u.created_at,
(
SELECT COUNT(*)
FROM tierlists t
WHERE t.author_id = u.id AND t.is_public = 1
) AS public_tierlist_count,
(
SELECT COUNT(*)
FROM user_follows uf
WHERE uf.following_id = u.id
) AS follower_count,
(
SELECT COUNT(*)
FROM user_follows uf
WHERE uf.follower_id = u.id
) AS following_count,
${
currentUserId
? `EXISTS(
SELECT 1
FROM user_follows uf
WHERE uf.follower_id = ? AND uf.following_id = u.id
)`
: '0'
} AS is_following
FROM users u
WHERE u.id = ?
LIMIT 1
`,
currentUserId ? [currentUserId, userId] : [userId]
)
const row = rows[0]
if (!row) return null
return {
id: row.id,
nickname: row.nickname || '',
accountName: getUserAccountName(row),
avatarSrc: row.avatar_src || '',
createdAt: Number(row.created_at || 0),
publicTierListCount: Number(row.public_tierlist_count || 0),
followerCount: Number(row.follower_count || 0),
followingCount: Number(row.following_count || 0),
isFollowing: !!row.is_following,
isSelf: !!currentUserId && currentUserId === row.id,
}
}
async function followUser({ followerId, followingId }) {
await query('INSERT IGNORE INTO user_follows (follower_id, following_id, created_at) VALUES (?, ?, ?)', [
followerId,
followingId,
now(),
])
}
async function unfollowUser({ followerId, followingId }) {
await query('DELETE FROM user_follows WHERE follower_id = ? AND following_id = ?', [followerId, followingId])
}
async function listPublicTierListsByAuthor(authorId, currentUserId = '', queryText = '') {
const params = [authorId]
let whereClause = 'WHERE t.author_id = ? AND t.is_public = 1'
if ((queryText || '').trim()) {
const search = `%${queryText.trim()}%`
whereClause += ' AND (t.title LIKE ? OR tp.name LIKE ?)'
params.push(search, search)
}
const rows = await query(
`
SELECT
t.id,
t.topic_id,
tp.name AS topic_name,
t.title,
t.thumbnail_src,
t.is_public,
t.is_featured,
t.featured_at,
t.featured_by,
t.created_at,
t.updated_at,
t.author_id,
u.nickname,
u.email,
u.avatar_src
FROM tierlists t
INNER JOIN users u ON u.id = t.author_id
INNER JOIN topics tp ON tp.id = t.topic_id
${whereClause}
ORDER BY t.is_featured DESC, t.featured_at DESC, t.updated_at DESC
LIMIT 200
`,
params
)
const tierLists = rows.map((row) => ({
id: row.id,
topicId: row.topic_id,
topicName: row.topic_name || '',
title: row.title,
thumbnailSrc: row.thumbnail_src || '',
isPublic: !!row.is_public,
isFeatured: !!row.is_featured,
featuredAt: Number(row.featured_at || 0),
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
authorId: row.author_id,
authorName: getUserDisplayName(row),
authorAccountName: getUserAccountName(row),
authorAvatarSrc: row.avatar_src || '',
}))
const favoriteStats = await getFavoriteStatsForTierListIds(
tierLists.map((tierList) => tierList.id),
currentUserId
)
return applyFavoriteMetaToTierLists(tierLists, favoriteStats)
}
async function listFollowingTierLists(userId, queryText = '') {
const params = [userId]
let whereClause = 'WHERE uf.follower_id = ? AND t.is_public = 1'
if ((queryText || '').trim()) {
const search = `%${queryText.trim()}%`
whereClause += ' AND (t.title LIKE ? OR tp.name LIKE ? OR u.nickname LIKE ? OR u.email LIKE ?)'
params.push(search, search, search, search)
}
const rows = await query(
`
SELECT
t.id,
t.topic_id,
tp.name AS topic_name,
t.title,
t.thumbnail_src,
t.is_public,
t.is_featured,
t.featured_at,
t.featured_by,
t.created_at,
t.updated_at,
t.author_id,
uf.created_at AS followed_at,
u.nickname,
u.email,
u.avatar_src
FROM user_follows uf
INNER JOIN tierlists t ON t.author_id = uf.following_id
INNER JOIN users u ON u.id = t.author_id
INNER JOIN topics tp ON tp.id = t.topic_id
${whereClause}
ORDER BY t.updated_at DESC, uf.created_at DESC
LIMIT 200
`,
params
)
const tierLists = rows.map((row) => ({
id: row.id,
topicId: row.topic_id,
topicName: row.topic_name || '',
title: row.title,
thumbnailSrc: row.thumbnail_src || '',
isPublic: !!row.is_public,
isFeatured: !!row.is_featured,
featuredAt: Number(row.featured_at || 0),
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
authorId: row.author_id,
authorName: getUserDisplayName(row),
authorAccountName: getUserAccountName(row),
authorAvatarSrc: row.avatar_src || '',
isFavorited: false,
}))
const favoriteStats = await getFavoriteStatsForTierListIds(
tierLists.map((tierList) => tierList.id),
userId
)
return applyFavoriteMetaToTierLists(tierLists, favoriteStats)
}
function uniqueTierListItems(poolItems) {
const map = new Map()
;(poolItems || []).forEach((item) => {
@@ -2207,6 +2453,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 +2531,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 +2542,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 +2563,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 +2774,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)
}
@@ -2651,6 +2930,7 @@ module.exports = {
findUserByEmail,
findUserByNickname,
findUserById,
findUserProfileById,
createUser,
updateUserPassword,
verifyUserEmail,
@@ -2704,14 +2984,19 @@ module.exports = {
listCustomItems,
findUnusedCustomItems,
listPublicTierLists,
listPublicTierListsByAuthor,
listFollowingTierLists,
listFavoriteTierLists,
listUserTierLists,
listAdminTierLists,
summarizeAdminTierLists,
findTierListById,
updateAdminTierListMeta,
updateTierListFeaturedStatus,
favoriteTopic,
unfavoriteTopic,
followUser,
unfollowUser,
favoriteTierList,
unfavoriteTierList,
deleteTierList,

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

@@ -53,6 +53,11 @@ const confirmPasswordResetSchema = z.object({
password: z.string().min(6),
})
const changePasswordSchema = z.object({
currentPassword: z.string().min(6),
nextPassword: z.string().min(6),
})
const profileSchema = z.object({
nickname: z.string().trim().min(1).max(40),
removeAvatar: z.union([z.string(), z.undefined()]).optional(),
@@ -322,6 +327,24 @@ router.post('/password-reset/confirm', async (req, res) => {
}
})
router.post('/password', requireAuth, async (req, res) => {
const parsed = changePasswordSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const user = await findUserById(req.session.userId)
if (!user) return res.status(404).json({ error: 'not_found' })
const authUser = await findUserByEmail(user.email)
if (!authUser) return res.status(404).json({ error: 'not_found' })
const passwordMatched = await bcrypt.compare(parsed.data.currentPassword, authUser.passwordHash)
if (!passwordMatched) return res.status(401).json({ error: 'invalid_current_password' })
const passwordHash = await bcrypt.hash(parsed.data.nextPassword, 10)
const updated = await updateUserPassword({ id: authUser.id, passwordHash })
res.json({ user: await serializeUser(updated) })
})
const upload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 })
router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) => {

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

@@ -0,0 +1,77 @@
const express = require('express')
const { z } = require('zod')
const {
findUserProfileById,
followUser,
unfollowUser,
listPublicTierListsByAuthor,
listFollowingTierLists,
} = require('../db')
const { requireAuth } = require('../middleware/auth')
const router = express.Router()
router.get('/following-feed', requireAuth, async (req, res) => {
const schema = z.object({
q: z.string().trim().max(120).optional().default(''),
})
const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const tierLists = await listFollowingTierLists(req.session.userId, parsed.data.q)
res.json({ tierLists })
})
router.get('/:userId', async (req, res) => {
const user = await findUserProfileById(req.params.userId, req.session?.userId || '')
if (!user) return res.status(404).json({ error: 'not_found' })
res.json({ user })
})
router.get('/:userId/tierlists', async (req, res) => {
const schema = z.object({
q: z.string().trim().max(120).optional().default(''),
})
const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const user = await findUserProfileById(req.params.userId, req.session?.userId || '')
if (!user) return res.status(404).json({ error: 'not_found' })
const tierLists = await listPublicTierListsByAuthor(
req.params.userId,
req.session?.userId || '',
parsed.data.q
)
res.json({ tierLists })
})
router.post('/:userId/follow', requireAuth, async (req, res) => {
const targetUserId = req.params.userId || ''
if (!targetUserId || targetUserId === req.session.userId) {
return res.status(400).json({ error: 'self_follow_not_allowed' })
}
const user = await findUserProfileById(targetUserId, req.session.userId)
if (!user) return res.status(404).json({ error: 'not_found' })
await followUser({ followerId: req.session.userId, followingId: targetUserId })
const updated = await findUserProfileById(targetUserId, req.session.userId)
res.json({ user: updated })
})
router.delete('/:userId/follow', requireAuth, async (req, res) => {
const targetUserId = req.params.userId || ''
if (!targetUserId || targetUserId === req.session.userId) {
return res.status(400).json({ error: 'self_follow_not_allowed' })
}
const user = await findUserProfileById(targetUserId, req.session.userId)
if (!user) return res.status(404).json({ error: 'not_found' })
await unfollowUser({ followerId: req.session.userId, followingId: targetUserId })
const updated = await findUserProfileById(targetUserId, req.session.userId)
res.json({ user: updated })
})
module.exports = router

View File

@@ -1,5 +1,21 @@
# 의사결정 이력
## 2026-04-03 v1.4.53
- 본인 티어표 복사 기능이 타인 티어표 전용 조건으로만 남아 있었지만, 실제 사용에서는 자기 작업본을 변형용 복사본으로 다시 만들고 싶은 경우도 많으므로 저장된 본인 티어표에도 복사 버튼을 여는 편이 맞다고 판단했다.
- 편집 중 저장하지 않은 변경이 있는 상태에서 복사본을 만들 때는 마지막 저장본이 아니라 현재 화면 상태가 복사되기를 기대하기 쉬우므로, 본인 편집본 복사는 복사 직전에 현재 원본을 한 번 저장한 뒤 새 복사본을 만드는 쪽으로 정리했다.
- 팔로우 기능은 처음부터 추천 알고리즘까지 섞기보다, 작성자 프로필과 팔로우 피드라는 명확한 사용자 경로를 먼저 열어두는 편이 제품 구조상 자연스럽다고 보고 `user_follows` 기반 1차 구현을 먼저 붙였다.
- 작성자 프로필 진입점은 목록 카드 내부 작성자 클릭을 바로 분리하면 기존 카드 전체 클릭 문법과 충돌할 수 있으므로, 이번 단계에서는 티어표 편집/뷰어 우측 패널의 `작성자 프로필 보기`를 우선 진입점으로 두고 카드 내부 세부 클릭 분리는 후속 UX로 미루는 편이 안전하다고 판단했다.
## 2026-04-03 v1.4.51
- 불특정 다수가 만드는 공개 티어표를 전부 같은 선상에 두면 첫 화면 품질 편차가 너무 커질 수 있으므로, 주제별 목록 상단에 관리자 큐레이션 `추천 티어표` 섹션을 두고 아래 일반 공개 목록과 분리하는 편이 맞다고 판단했다.
- 추천 선정은 처음부터 완전 자동화보다 운영자가 직접 지정/해제할 수 있는 수동 큐레이션을 먼저 넣는 편이 안전하므로, 좋아요 수 기반 자동 후보 필터와 팔로우 피드는 후속 작업으로 미루고 이번 릴리스에서는 추천 표시 구조와 관리자 토글만 먼저 완성했다.
- 비공개 글이 추천 섹션에 올라가면 접근 정책이 꼬이므로, 추천 지정은 공개 글만 허용하고 공개글을 비공개로 바꾸면 추천 상태도 함께 해제하는 쪽으로 정리했다.
## 2026-04-03 v1.4.49
- 프로필 저장 실패를 하나의 일반 실패 메시지로만 보여주면 사용자가 “서버가 고장났나?”라고 오해하기 쉬우므로, 중복 닉네임/예약어 닉네임처럼 사용자가 직접 고칠 수 있는 입력 오류는 원인별 안내를 분리하는 편이 맞다고 판단했다.
- 비밀번호를 잊은 사용자뿐 아니라 로그인 중인 사용자도 보안상 주기적으로 비밀번호를 직접 바꿀 수 있어야 하므로, 설정 화면에 현재 비밀번호 확인 기반 변경 폼을 추가하는 쪽으로 정리했다.
- 비밀번호 재설정 링크는 로그인 세션이 남아 있어도 링크 토큰 자체의 목적이 우선이므로, `/login?resetToken=...` 진입 시에는 기존 자동 리다이렉트보다 재설정 폼 렌더링을 우선하는 편이 맞다고 판단했다.
## 2026-04-03 v1.4.45
- 실제 서비스에서는 남의 이메일 주소로 가입만 먼저 해두는 문제가 생길 수 있으므로, 일반 회원은 가입 직후 인증 메일을 거쳐야 로그인할 수 있게 하고 비밀번호 분실도 메일 토큰 기반으로 복구하는 구조가 필요하다고 판단했다.
- 다만 초기 운영자가 바로 서비스를 띄울 수 있어야 하므로, 첫 번째 가입 계정만은 기존처럼 이메일 인증 없이 바로 최고 관리자 계정으로 활성화하는 예외를 유지하는 편이 맞다고 정리했다.

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`
@@ -30,6 +30,16 @@
- 역할: 즐겨찾기한 티어표 목록 조회, 검색/정렬, 라이브러리 카드형 표시, 편집 화면 이동, 즐겨찾기 상태 확인
- 연동 API: `GET /api/tierlists/favorites/me`, `DELETE /api/tierlists/:id/favorite`
## `/following`
- 화면 파일: `frontend/src/views/FollowingFeedView.vue`
- 역할: 팔로우한 작성자의 공개 티어표를 최신 업데이트순 카드 목록으로 모아보기, 제목/주제/작성자 검색, 티어표 상세 이동, 작성자 프로필 이동
- 연동 API: `GET /api/users/following-feed`
## `/users/:userId`
- 화면 파일: `frontend/src/views/UserProfileView.vue`
- 역할: 작성자 공개 프로필, 팔로워/팔로잉/공개 티어표 수 표시, 로그인 사용자의 팔로우/언팔로우 전환, 해당 작성자의 공개 티어표 목록 조회와 상세 이동
- 연동 API: `GET /api/users/:userId`, `GET /api/users/:userId/tierlists`, `POST /api/users/:userId/follow`, `DELETE /api/users/:userId/follow`
## `/search`
- 화면 파일: `frontend/src/views/SearchResultsView.vue`
- 역할: 좌측 전역 검색 입력에서 넘긴 키워드로 공개 티어표 전체를 검색하고, 자체 검색 툴바 없이 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 목록으로 표시
@@ -37,13 +47,13 @@
## `/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`
- 역할: 프로필 표시, 작성자 닉네임 수정, 아바타 미리보기 후 저장, 설정 화면 하단 로그아웃 처리
- 연동 API: `GET /api/auth/me`, `POST /api/auth/profile`
- 역할: 넓은 화면에서는 왼쪽 프로필 정보 카드와 오른쪽 비밀번호 변경 카드로 나뉘는 설정 화면, 프로필 표시, 작성자 닉네임 수정, 아바타 미리보기 후 저장, 중복/예약어 닉네임 오류 안내, 현재 비밀번호 확인 기반 비밀번호 변경, 설정 화면 로그아웃 처리
- 연동 API: `GET /api/auth/me`, `POST /api/auth/profile`, `POST /api/auth/password`
## 공통 레이아웃
- 앱 셸 파일: `frontend/src/App.vue`
@@ -59,4 +69,5 @@
- 메일 발송 유틸: `backend/src/lib/mailer.js`
- 주제 라우트: `backend/src/routes/topics.js`
- 티어표 라우트: `backend/src/routes/tierlists.js`
- 사용자/팔로우 라우트: `backend/src/routes/users.js`
- 관리자 라우트: `backend/src/routes/admin.js`

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
@@ -117,6 +120,10 @@
- `userId`: string
- `tierListId`: string
- `createdAt`: number
- `userFollows`
- `followerId`: string
- `followingId`: string
- `createdAt`: number
- `gameSuggestions`
- `id`: string
- `name`: string
@@ -132,6 +139,8 @@
- `GET /api/auth/me`
- `GET /api/auth/meta`
- `POST /api/auth/profile`
- `POST /api/auth/password`
- 로그인한 사용자가 현재 비밀번호를 확인한 뒤 새 비밀번호로 직접 변경한다.
- `POST /api/auth/email/verify`
- `login?verifyToken=...` 링크에서 받은 토큰으로 이메일 인증을 완료하고 바로 로그인 세션을 만든다.
- `POST /api/auth/email/resend`
@@ -145,6 +154,7 @@
- `GET /api/topics/:topicId`
- 티어표
- `GET /api/tierlists/public`
- `featuredTierLists`와 일반 공개 `tierLists`를 분리해서 반환한다.
- `topicId` 없이 `q`만 전달하면 전체 공개 티어표 검색에 사용한다.
- `GET /api/tierlists/me`
- `GET /api/tierlists/favorites/me`
@@ -156,6 +166,15 @@
- `POST /api/tierlists/thumbnail`
- `POST /api/tierlists/custom-items`
- `POST /api/tierlists`
- 사용자/팔로우
- `GET /api/users/following-feed`
- 로그인한 사용자가 팔로우한 작성자의 공개 티어표를 최신 업데이트순으로 조회한다.
- `GET /api/users/:userId`
- 작성자 공개 프로필, 공개 티어표 수, 팔로워/팔로잉 수, 현재 로그인 사용자의 팔로우 여부를 반환한다.
- `GET /api/users/:userId/tierlists`
- 해당 작성자의 공개 티어표 목록을 반환한다.
- `POST /api/users/:userId/follow`
- `DELETE /api/users/:userId/follow`
- 관리자
- `POST /api/admin/templates`
- `POST /api/admin/templates/:templateId/thumbnail`
@@ -163,6 +182,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`
@@ -192,6 +213,7 @@
- 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다.
- 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다.
- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 내부에서 `템플릿 요청 관리 / 전체 티어표 관리`를 분리해 볼 수 있고, 확인용 완성본은 탐색 UI 없는 preview 전용 모달로 연다.
- `전체 티어표 관리`에서는 공개 티어표를 `추천 지정 / 추천 해제`할 수 있고, 추천 지정된 티어표는 주제별 공개 목록 상단의 `추천 티어표` 섹션에 먼저 노출된다. 비공개 티어표는 추천 지정할 수 없고, 추천글을 비공개로 바꾸면 추천 상태도 함께 해제된다.
- `티어표 관리` 탭의 추가 아이템은 작은 그리드 카드로 표시하고, 클릭 시 `기존 템플릿에 추가 / 새 템플릿 만들기` 모달을 통해 목적지를 선택한다.
- `티어표 관리` 탭에서는 티어표 안의 커스텀 아이템을 개별 또는 일괄로 기존 게임 템플릿에 복제할 수 있다.
- `freeform` 티어표는 관리자 화면에서 새 게임 ID/이름을 입력해 새로운 게임 템플릿으로 복제 생성할 수 있다.
@@ -207,14 +229,17 @@
- 공유 링크로 여는 `preview=1` 화면은 `뷰어 모드`로 취급하며, 드래그/행열 편집/저장 같은 편집 UI 없이 완성본만 렌더링한다.
- 비로그인 사용자나 작성자 본인이 아닌 로그인 사용자는 저장된 티어표를 기본적으로 뷰어 모드로 열람하며, 일반 편집 URL로 직접 진입해도 소유자가 아니면 `preview=1` 주소로 자동 전환된다.
- 비로그인 사용자도 뷰어 모드 우측 레일의 `공유하기` 버튼으로 현재 공유 링크를 복사할 수 있다.
- 로그인한 타인 티어표 열람자는 뷰어 모드 우측 레일에서 `내 티어표로 복사`를 사용할 수 있고, 작성자 본인은 `수정 모드로 전환` 사용할 수 있다.
- 로그인한 사용자는 뷰어 모드 우측 레일에서 저장된 티어표를 복사할 수 있고, 타인 티어표면 `내 티어표로 복사`, 본인 티어표면 `복사본 만들기` 문구를 사용한다. 작성자 본인은 `수정 모드로 전환` 사용할 수 있다.
- 작성자 본인이 일반 편집 화면에서 저장된 본인 티어표를 보고 있을 때는 우측 패널의 `뷰어 모드로 보기`로 공유 화면 형태를 바로 확인할 수 있다.
- 편집/뷰어 우측 패널의 `작성자 프로필 보기`로 해당 작성자의 공개 프로필과 공개 티어표 목록을 열 수 있고, 로그인 상태에서는 작성자 프로필에서 팔로우/언팔로우를 전환할 수 있다.
- 같은 `TierEditorView` 안에서 `topicId / tierListId / preview` 라우트 값만 바뀌어도 화면이 이전 티어표 데이터에 남지 않도록, 라우트 변경마다 에디터 상태를 다시 로드한다.
- 복사한 티어표 상단의 `원본` 링크를 누르면 원본 티어표로 이동하며, 편집 모드에서 저장하지 않은 변경이 있으면 이동 전에 `저장 없이 이동` 확인 모달을 먼저 띄운다.
- 본인 티어표도 저장된 상태라면 편집/뷰어 우측 패널에서 복사본을 만들 수 있고, 편집 중 저장하지 않은 수정이 남아 있으면 복사 직전에 현재 수정본을 먼저 저장해 최신 상태 기준 복사본을 만든다.
- 비공개 티어표라도 관리자는 편집 화면에서 완성본을 열람할 수 있다.
- 공개 티어표는 목록과 상세 화면에서 즐겨찾기 토글 및 개수를 함께 표시한다.
- 카드형 목록에서는 즐겨찾기 수/상태만 표시하고, 실제 토글은 상세 화면에서 처리한다.
- 공개 티어표 목록은 현재 게임 기준으로 제목/작성자 검색을 지원한다.
- 주제별 공개 티어표 화면은 관리자 추천글을 상단 `추천 티어표` 섹션으로 먼저 보여주고, 일반 공개 목록은 아래 `전체 공개 티어표` 섹션으로 분리해 중복 없이 렌더링한다. 추천 섹션은 최대 16개까지 표시한다.
- `내 즐겨찾기` 화면에서는 즐겨찾기한 순, 최신 업데이트순, 인기순 정렬을 제공한다.
- 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다.
- 사용자가 직접 추가한 커스텀 아이템 이름은 편집 화면 우측 목록에서 정리할 수 있고, 저장 시 원본 커스텀 아이템 라벨과 함께 동기화된다.
@@ -284,6 +309,8 @@
- 인증 메일/비밀번호 재설정 메일 토큰은 원문을 DB에 저장하지 않고 SHA-256 해시만 저장하며, 새 토큰을 발급할 때는 같은 사용자의 이전 미사용 토큰을 먼저 만료 처리한다.
- 이메일 인증 토큰은 24시간, 비밀번호 재설정 토큰은 1시간 유효 기간을 사용한다.
- 비밀번호 재설정 링크로 새 비밀번호를 저장한 사용자는 같은 메일 주소를 확인한 것으로 보고, 기존에 미인증 상태였더라도 저장과 함께 이메일 인증을 완료 처리한다.
- 로그인한 상태로도 `login?resetToken=...` 재설정 링크를 열 수 있으며, 이때는 기존 로그인 세션이 있어도 자동으로 내 티어표 화면으로 보내지 않고 새 비밀번호 입력 화면을 먼저 보여준다.
- 설정 화면의 직접 비밀번호 변경은 현재 비밀번호가 맞는지 먼저 확인하고, 맞지 않으면 `invalid_current_password`로 차단한다.
## 운영 배포 메모
- 프로덕션 컴포즈 파일은 [docker-compose.prod.yml](/Users/bicute/Desktop/zenn.dev/tier-cursor/docker-compose.prod.yml)이다.

View File

@@ -1,6 +1,18 @@
# 할 일 및 이슈
## 단기 확인
- `v1.4.53`에서 본인 티어표 복사 버튼을 다시 열었으므로, 작성자 본인 편집 모드와 뷰어 모드 모두에서 `복사본 만들기`가 보이고, 복사 후 새 복사본 화면으로 실제 이동하는지 확인한다.
- 본인 티어표를 수정한 뒤 저장하지 않은 상태로 `복사본 만들기`를 누르면 복사 직전에 원본이 먼저 저장되고, 새 복사본이 방금 수정한 최신 내용 기준으로 생성되는지 QA한다.
- `/users/:userId` 작성자 프로필에서 비로그인 사용자는 팔로우 버튼이 안 보이고, 로그인 사용자는 타인 프로필에서 `팔로우 / 팔로잉` 전환과 팔로워 수 갱신이 정상이며, 자기 프로필에서는 팔로우 버튼이 숨겨지는지 확인한다.
- `/following` 팔로우 피드는 팔로우한 작성자의 공개 티어표만 최신 업데이트순으로 보이고, 비로그인 진입 시 `/login?redirect=/following`으로 이동하며, 검색어로 제목/주제/작성자를 필터링할 수 있는지 확인한다.
- 티어표 편집/뷰어 우측 패널의 `작성자 프로필 보기`가 현재 티어표 작성자 프로필로 정확히 이동하고, 복사본에서는 복사본 작성자 자신 프로필로, 원본 링크는 기존처럼 원본 티어표로 이동하는지 함께 QA한다.
- `v1.4.51`에서 주제별 공개 목록을 `추천 티어표 / 전체 공개 티어표`로 분리했으므로, 추천 지정된 티어표가 상단 강조 섹션에만 나오고 아래 일반 목록에는 중복되지 않는지, 추천 해제 즉시 아래 일반 목록으로 내려가는지 확인한다.
- 관리자 `전체 티어표 관리`에서 공개 글은 `추천 지정 / 추천 해제`가 정상 동작하고, 비공개 글은 추천 지정 버튼이 비활성화되며, 추천글을 비공개로 바꾸면 추천 상태가 자동 해제되는지 QA한다.
- 추천 섹션은 최대 16개까지만 보여주도록 잘라두었으므로, 17개 이상 추천 지정 시 최근 지정순과 좋아요 수 보조 정렬이 기대대로 적용되는지 한 번 더 확인한다.
- `v1.4.50`에서 설정 화면을 좌우 2열 카드형으로 나눴으므로, 데스크톱 폭에서는 프로필 정보가 왼쪽, 비밀번호 변경이 오른쪽에 나란히 보이고, 모바일/좁은 폭에서는 두 카드가 자연스럽게 위아래로 쌓이는지 확인한다.
- `v1.4.49`에서 설정 화면에 비밀번호 변경 섹션을 추가했으므로, 현재 비밀번호가 틀린 경우 `현재 비밀번호가 일치하지 않아요.`, 새 비밀번호 확인이 다른 경우 `비밀번호 확인이 일치하지 않아요.`, 성공 시 `비밀번호를 변경했어요.` 토스트가 각각 정확히 뜨는지 확인한다.
- 설정 화면 닉네임 저장도 중복/예약어 에러를 구체적으로 보여주도록 바꿨으므로, 이미 사용 중인 닉네임과 예약어 닉네임을 각각 넣었을 때 서버 문제처럼 보이지 않고 원인 문구가 정확히 뜨는지 QA한다.
- 로그인한 상태로 비밀번호 재설정 메일의 `login?resetToken=...` 링크를 눌렀을 때도 바로 내 티어표 화면으로 튕기지 않고 `새 비밀번호 설정` 화면이 먼저 뜨는지 확인한다.
- `v1.4.48`에서 로컬 `APP_ORIGIN``localhost:5173`으로 먼저 주입하도록 바꿨으므로, 백엔드를 다시 띄운 뒤 새 회원가입 인증 메일과 비밀번호 재설정 메일 링크가 운영 도메인이 아니라 로컬 주소로 열리는지 확인한다.
- `v1.4.47`에서 로컬 백엔드가 루트 `.env.production`을 읽도록 바꿨으므로, `SMTP_PASS` 교체 후 백엔드를 다시 띄우고 로컬 회원가입이 더 이상 `mail_not_configured` 503으로 떨어지지 않는지 확인한다.
- `.env.production``SMTP_PASS=여기에_Gmail_앱_비밀번호_입력` placeholder를 실제 Gmail 앱 비밀번호로 교체한 뒤, 운영 컨테이너를 재기동해서 회원가입 인증 메일과 비밀번호 재설정 메일이 실제로 발송되는지 확인한다.
@@ -128,6 +140,8 @@
- 아이템 관리의 같은 이미지 참조 요약과 상세 모달은 붙였으므로, 실제 운영 데이터에서 카드 수치와 모달 목록이 기대한 참조 관계와 맞는지 한 번 더 QA한다.
## 중기 개선
- 목록 카드의 작성자 메타를 카드 전체 열기 버튼과 충돌 없이 직접 프로필 링크로 분리하는 후속 UX를 검토한다.
- 추천 티어표는 이번에 관리자 수동 지정부터 붙였으므로, 다음 단계에서는 최근 N일 좋아요 수 기준 추천 후보 필터와 추천 섹션 노출 개수 설정을 관리자 화면에 추가할지 검토한다.
- 이메일 인증/비밀번호 재설정 1차 구현이 들어갔으므로, 다음 단계에서는 Gmail 발신 기반이 실제 운영에서 스팸함으로 얼마나 가는지 보고 필요하면 Cloudflare DNS의 SPF/DKIM/DMARC와 도메인 발신 주소 전환을 정리한다.
- 구글 계정 로그인은 아직 붙이지 않았으므로, 이메일 인증 안정화 후 Google OAuth 클라이언트를 만들고 일반 이메일 계정과 같은 이메일의 구글 계정을 자동 연결할지 정책을 먼저 결정한다.
- 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다.

View File

@@ -1,5 +1,30 @@
# 업데이트 로그
## 2026-04-03 v1.4.53
- 티어표 복사 버튼이 타인 티어표에서만 보이도록 묶여 있어 본인 티어표에서는 숨겨지던 문제를 고쳐, 저장된 본인 티어표도 `복사본 만들기`로 새 복사본을 만들 수 있게 복구했다.
- 본인 티어표를 편집 중 저장하지 않은 변경이 있는 상태로 복사본을 만들면 화면에 보이는 최신 수정 내용이 빠질 수 있었으므로, 복사 실행 직전에 현재 수정본을 먼저 저장한 뒤 복사본을 생성하도록 보정했다.
- 작성자 프로필 화면(`/users/:userId`)과 팔로우 피드 화면(`/following`)을 추가하고, 백엔드에 `user_follows` 테이블과 팔로우/언팔로우/작성자 공개 티어표/팔로잉 피드 API를 붙였다.
- 티어표 편집/뷰어 우측 패널에 `작성자 프로필 보기` 진입점을 추가하고, 왼쪽 내비게이션에도 `팔로우 피드` 메뉴를 노출해 팔로우한 작성자의 공개 티어표를 따로 모아 볼 수 있게 했다.
- 프런트 HTML 메타 제목/설명에서도 `게임 템플릿` 표현을 `템플릿` 기준 문구로 맞춰, 실제 서비스가 특정 게임만 다루는 것처럼 보이지 않도록 한 번 더 정리했다.
## 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열로 내려가도록 레이아웃을 재정리했다.
- 왼쪽 카드는 아바타/닉네임/이메일/로그아웃/프로필 저장을, 오른쪽 카드는 현재 비밀번호 확인과 새 비밀번호 저장을 담당하게 분리해, 설정 화면의 정보 묶음이 더 명확하게 읽히도록 맞췄다.
## 2026-04-03 v1.4.49
- 설정 화면에 현재 비밀번호 확인 후 새 비밀번호를 직접 저장하는 `비밀번호 변경` 섹션을 추가하고, 백엔드에는 로그인 사용자용 `POST /api/auth/password` API를 붙였다.
- 프로필 닉네임 저장 실패가 모두 `프로필 저장에 실패했어요.`로 뭉뚱그려 보이던 부분을 고쳐, 중복 닉네임은 `닉네임이 이미 사용 중이에요.`, 예약어 닉네임은 `사용할 수 없는 닉네임이에요.`처럼 회원가입 화면과 같은 맥락의 원인 안내로 분리했다.
- 로그인한 상태로 `login?resetToken=...` 비밀번호 재설정 링크를 열면 기존 로그인 감시가 바로 내 티어표 화면으로 보내버릴 수 있었으므로, 인증/재설정 토큰이 있는 동안에는 자동 리다이렉트를 멈추고 재설정 입력 화면을 우선 보여주도록 보정했다.
## 2026-04-03 v1.4.48
- 로컬 백엔드도 `.env.production`을 읽는 구조가 되면서 이메일 인증/비밀번호 재설정 링크의 `APP_ORIGIN`이 운영 도메인으로 잡히던 문제를 막기 위해, `backend``dev/start` 스크립트에서 로컬 실행 시 `APP_ORIGIN=http://localhost:5173`을 먼저 주입하도록 분리했다.
- 이로써 로컬 개발에서는 인증 메일 링크가 `localhost:5173`으로 열리고, 상용 Docker 배포에서는 `docker-compose.prod.yml``APP_ORIGIN=https://tmaker.sori.studio`를 그대로 사용하도록 환경이 구분된다.

View File

@@ -3,10 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tier Maker | 게임 템플릿으로 만드는 티어표</title>
<title>Tier Maker | 템플릿으로 쉽게 만드는 티어표</title>
<meta
name="description"
content="게임 템플릿과 커스텀 이미지로 티어표를 만들고 저장하고 공유하세요. 관리자 요청과 템플릿 업데이트 흐름까지 한 번에 관리할 수 있습니다."
content="템플릿과 커스텀 이미지로 티어표를 만들고 저장하고 공유하세요. 관리자 요청과 템플릿 업데이트 흐름까지 한 번에 관리할 수 있습니다."
/>
<meta name="theme-color" content="#090d16" />
<meta name="application-name" content="Tier Maker" />
@@ -20,10 +20,10 @@
<meta property="og:locale" content="ko_KR" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://tmaker.sori.studio/" />
<meta property="og:title" content="Tier Maker | 게임 템플릿으로 만드는 티어표" />
<meta property="og:title" content="Tier Maker | 템플릿으로 쉽게 만드는 티어표" />
<meta
property="og:description"
content="게임 템플릿과 커스텀 이미지로 티어표를 만들고 저장하고 공유하세요. 관리자 요청과 템플릿 업데이트 흐름까지 한 번에 관리할 수 있습니다."
content="템플릿과 커스텀 이미지로 티어표를 만들고 저장하고 공유하세요. 관리자 요청과 템플릿 업데이트 흐름까지 한 번에 관리할 수 있습니다."
/>
<meta property="og:image" content="https://tmaker.sori.studio/og-card.png" />
<meta property="og:image:alt" content="Tier Maker 공유 썸네일" />
@@ -31,10 +31,10 @@
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Tier Maker | 게임 템플릿으로 만드는 티어표" />
<meta name="twitter:title" content="Tier Maker | 템플릿으로 쉽게 만드는 티어표" />
<meta
name="twitter:description"
content="게임 템플릿과 커스텀 이미지로 티어표를 만들고 저장하고 공유하세요."
content="템플릿과 커스텀 이미지로 티어표를 만들고 저장하고 공유하세요."
/>
<meta name="twitter:image" content="https://tmaker.sori.studio/og-card.png" />
</head>

View File

@@ -2,7 +2,7 @@
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from './stores/auth'
import { editorNewPath, favoritesPath, homePath, loginPath, mePath } from './lib/paths'
import { editorNewPath, favoritesPath, followingFeedPath, homePath, loginPath, mePath } from './lib/paths'
import { toApiUrl } from './lib/runtime'
import { useToast } from './composables/useToast'
import iconDockToLeft from './assets/icons/dock_to_left.svg'
@@ -14,6 +14,7 @@ import iconAddNotes from './assets/icons/add_notes.svg'
import iconDashboardCustomize from './assets/icons/dashboard_customize.svg'
import iconSearch from './assets/icons/search.svg'
import iconSettings from './assets/icons/settings.svg'
import iconKidStar from './assets/icons/kid_star.svg'
import iconMenuBook from './assets/icons/menu_book.svg'
import RightRailAd from './components/RightRailAd.vue'
import SvgIcon from './components/SvgIcon.vue'
@@ -69,6 +70,7 @@ const leftNavItems = computed(() => {
{ key: 'home', label: '주제 선택', path: '/', iconSrc: iconGridView },
{ key: 'me', label: '나의 티어표', path: '/me', iconSrc: iconLists, requiresAuth: true },
{ key: 'favorites', label: '즐겨찾기', path: '/favorites', iconSrc: iconFavorite, requiresAuth: true },
{ key: 'followingFeed', label: '팔로우 피드', path: '/following', iconSrc: iconKidStar, requiresAuth: true },
{ key: 'profile', label: '설정', path: '/profile', iconSrc: iconSettings, requiresAuth: true },
]
return items.filter((item) => !item.requiresAuth || (authReady.value && auth.user))
@@ -221,6 +223,26 @@ const routeMeta = computed(() => {
action: () => router.push(mePath()),
}
}
if (route.name === 'followingFeed') {
return {
title: '팔로우 피드',
subtitle: '팔로우한 작성자의 새 티어표',
contextTitle: '구독 목록',
contextText: '작성자 프로필에서 팔로우한 사람의 공개 티어표를 한곳에서 볼 수 있어요.',
actionLabel: '즐겨찾기 보기',
action: () => router.push(favoritesPath()),
}
}
if (route.name === 'userProfile') {
return {
title: '작성자 프로필',
subtitle: '공개 티어표와 팔로우',
contextTitle: '작성자 탐색',
contextText: auth.user ? '마음에 드는 작성자를 팔로우하고 새 공개 티어표를 피드에서 이어서 볼 수 있어요.' : '로그인하면 작성자를 팔로우할 수 있어요.',
actionLabel: auth.user ? '팔로우 피드 보기' : '로그인하러 가기',
action: () => router.push(auth.user ? followingFeedPath() : loginPath(route.fullPath)),
}
}
if (route.name === 'profile') {
return {
title: '설정',

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

@@ -62,6 +62,8 @@ export const api = {
requestPasswordReset: ({ email }) => request('/api/auth/password-reset/request', { method: 'POST', body: { email } }),
confirmPasswordReset: ({ token, password }) =>
request('/api/auth/password-reset/confirm', { method: 'POST', body: { token, password } }),
changePassword: ({ currentPassword, nextPassword }) =>
request('/api/auth/password', { method: 'POST', body: { currentPassword, nextPassword } }),
logout: () => request('/api/auth/logout', { method: 'POST' }),
listTopics: () => request('/api/topics'),
@@ -85,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'),
@@ -151,6 +155,13 @@ export const api = {
listMyTierLists: () => request('/api/tierlists/me'),
listMyFavoriteTierLists: ({ q = '', sort = 'favorited' } = {}) =>
request(`/api/tierlists/favorites/me?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}`),
getUserProfile: (userId) => request(`/api/users/${encodeURIComponent(userId)}`),
listUserPublicTierLists: (userId, { q = '' } = {}) =>
request(`/api/users/${encodeURIComponent(userId)}/tierlists?q=${encodeURIComponent(q || '')}`),
listFollowingFeed: ({ q = '' } = {}) =>
request(`/api/users/following-feed?q=${encodeURIComponent(q || '')}`),
followUser: (userId) => request(`/api/users/${encodeURIComponent(userId)}/follow`, { method: 'POST', body: {} }),
unfollowUser: (userId) => request(`/api/users/${encodeURIComponent(userId)}/follow`, { method: 'DELETE' }),
getTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`),
favoriteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/favorite`, { method: 'POST' }),
unfavoriteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/favorite`, { method: 'DELETE' }),

View File

@@ -33,6 +33,14 @@ export function favoritesPath() {
return '/favorites'
}
export function followingFeedPath() {
return '/following'
}
export function profilePath() {
return '/profile'
}
export function userProfilePath(userId) {
return `/users/${encodeSegment(userId)}`
}

View File

@@ -6,6 +6,8 @@ import TierEditorView from '../views/TierEditorView.vue'
import LoginView from '../views/LoginView.vue'
import MyTierListsView from '../views/MyTierListsView.vue'
import FavoriteTierListsView from '../views/FavoriteTierListsView.vue'
import FollowingFeedView from '../views/FollowingFeedView.vue'
import UserProfileView from '../views/UserProfileView.vue'
import AdminView from '../views/AdminView.vue'
import ProfileView from '../views/ProfileView.vue'
import SearchResultsView from '../views/SearchResultsView.vue'
@@ -22,6 +24,7 @@ export function createRouter() {
{ path: '/login', name: 'login', component: LoginView },
{ path: '/me', name: 'me', component: MyTierListsView },
{ path: '/favorites', name: 'favorites', component: FavoriteTierListsView },
{ path: '/following', name: 'followingFeed', component: FollowingFeedView },
{ path: '/search', name: 'search', component: SearchResultsView },
{ path: '/admin', redirect: '/admin/featured' },
{ path: '/admin/featured', name: 'adminFeatured', component: AdminView },
@@ -30,6 +33,7 @@ export function createRouter() {
{ path: '/admin/tierlists', name: 'adminTierlists', component: AdminView },
{ path: '/admin/users', name: 'adminUsers', component: AdminView },
{ path: '/profile', name: 'profile', component: ProfileView },
{ path: '/users/:userId', name: 'userProfile', component: UserProfileView },
],
})

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

@@ -0,0 +1,328 @@
<script setup>
import { onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../lib/api'
import { editorPath, loginPath, userProfilePath } from '../lib/paths'
import { toApiUrl } from '../lib/runtime'
import { useToast } from '../composables/useToast'
const router = useRouter()
const toast = useToast()
const tierLists = ref([])
const query = ref('')
const isLoading = ref(false)
const error = ref('')
const brokenThumbnailIds = ref({})
watch(error, (message) => {
if (!message) return
toast.error(message)
error.value = ''
})
function fmt(ts) {
return new Date(ts).toLocaleDateString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
function displayNameOf(tierList) {
return tierList.authorName || '알 수 없음'
}
function avatarSrcOf(tierList) {
return tierList.authorAvatarSrc ? toApiUrl(tierList.authorAvatarSrc) : ''
}
function avatarFallbackOf(tierList) {
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
}
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 loadFollowingFeed() {
isLoading.value = true
try {
const data = await api.listFollowingFeed({ q: query.value })
brokenThumbnailIds.value = {}
tierLists.value = data.tierLists || []
} catch (e) {
toast.error('로그인이 필요해요.')
router.push(loginPath('/following'))
} finally {
isLoading.value = false
}
}
function openTierList(tierList) {
router.push(editorPath(tierList.topicId, tierList.id))
}
function openAuthorProfile(tierList) {
if (!tierList?.authorId) return
router.push(userProfilePath(tierList.authorId))
}
onMounted(loadFollowingFeed)
</script>
<template>
<section class="pageWrap">
<section class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Following</div>
<h2 class="pageHead__title">팔로우 피드</h2>
<div class="pageHead__desc">팔로우한 작성자가 공개한 티어표를 최신 업데이트순으로 모아봅니다.</div>
</div>
<div class="pageHead__aside toolbar">
<input v-model="query" class="input" placeholder="제목, 주제, 작성자 검색" @keydown.enter.prevent="loadFollowingFeed" />
<button class="btn" :disabled="isLoading" @click="loadFollowingFeed">{{ isLoading ? '검색중...' : '검색' }}</button>
</div>
</section>
<section class="panel">
<div v-if="isLoading" class="empty">팔로우 피드를 불러오고 있어요.</div>
<div v-else-if="tierLists.length === 0" class="empty">아직 팔로우한 작성자의 공개 티어표가 없어요.</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.topicId }}</div>
</div>
</button>
<button class="authorLink" type="button" @click="openAuthorProfile(tierList)">
<div class="authorLink__main">
<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="authorLink__name">{{ displayNameOf(tierList) }}</span>
</div>
<span class="authorLink__date">{{ fmt(tierList.updatedAt) }}</span>
</button>
</article>
</div>
</section>
</section>
</template>
<style scoped>
.panel {
background: transparent;
border-radius: 0;
padding: 0;
}
.toolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.input {
min-width: 240px;
padding: 11px 13px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
color: var(--theme-text);
}
.btn {
padding: 11px 13px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft-2);
color: var(--theme-text);
font-weight: 800;
cursor: pointer;
}
.empty {
opacity: 0.75;
}
.list {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.boardCard {
min-width: 0;
border-radius: 22px;
border: 1px solid var(--theme-card-border);
background: var(--theme-card-bg);
color: var(--theme-text);
overflow: hidden;
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
transition:
transform 0.16s ease,
background 0.16s ease;
}
.boardCard:hover {
background: var(--theme-card-bg-hover);
transform: translateY(-2px);
}
.boardCard__body {
width: 100%;
min-width: 0;
text-align: left;
cursor: pointer;
border: 0;
background: transparent;
color: inherit;
padding: 0;
display: grid;
}
.boardCard__thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
padding: 14px 14px 0;
box-sizing: border-box;
}
.boardCard__thumb,
.boardCard__thumbPlaceholder {
width: 100%;
height: 100%;
border-radius: 18px;
display: block;
}
.boardCard__thumb {
object-fit: cover;
}
.boardCard__thumbPlaceholder {
display: grid;
place-items: center;
background: var(--theme-thumb-fallback-bg);
color: var(--theme-text-faint);
font-size: 13px;
font-weight: 700;
}
.boardCard__head {
min-width: 0;
padding: 16px 18px 0;
display: grid;
gap: 6px;
}
.boardCard__titleRow {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: flex-start;
}
.boardCard__title {
min-width: 0;
font-weight: 800;
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;
}
.boardCard__topic,
.favoriteStat {
font-size: 13px;
color: var(--theme-text-faint);
}
.favoriteStat {
white-space: nowrap;
}
.authorLink {
width: calc(100% - 28px);
margin: 14px;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
color: inherit;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.authorLink__main {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.authorLink__name {
min-width: 0;
font-size: 13px;
font-weight: 800;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.authorLink__date {
flex: 0 0 auto;
font-size: 10px;
color: var(--theme-text-faint);
}
.boardCard__avatar {
width: 22px;
height: 22px;
border-radius: 9999px;
object-fit: cover;
border: 1px solid var(--theme-avatar-border);
background: var(--theme-border);
flex: 0 0 auto;
}
.boardCard__avatar--fallback {
display: grid;
place-items: center;
font-size: 11px;
font-weight: 900;
}
@media (max-width: 1400px) {
.list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1200px) {
.list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.list {
grid-template-columns: 1fr;
}
.input {
min-width: 0;
width: 100%;
}
}
</style>

View File

@@ -115,6 +115,7 @@ watch(
() => [auth.hydrated, auth.user],
([hydrated, user]) => {
if (!hydrated || !user) return
if (verifyToken.value || resetToken.value) return
router.replace(redirectPath.value)
},
{ immediate: true }

View File

@@ -2,6 +2,7 @@
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { api } from '../lib/api'
import { homePath, loginPath } from '../lib/paths'
import { toApiUrl } from '../lib/runtime'
import { useToast } from '../composables/useToast'
@@ -12,11 +13,18 @@ const toast = useToast()
const error = ref('')
const saving = ref(false)
const passwordSaving = ref(false)
const nickname = ref('')
const nicknameError = ref('')
const previewUrl = ref('')
const avatarFile = ref(null)
const removeAvatar = ref(false)
const fileInput = ref(null)
const currentPassword = ref('')
const nextPassword = ref('')
const nextPasswordConfirm = ref('')
const currentPasswordError = ref('')
const nextPasswordError = ref('')
watch(error, (message) => {
if (!message) return
@@ -67,6 +75,15 @@ function onAvatarChange(e) {
previewUrl.value = URL.createObjectURL(file)
}
function clearProfileFieldErrors() {
nicknameError.value = ''
}
function clearPasswordFieldErrors() {
currentPasswordError.value = ''
nextPasswordError.value = ''
}
function clearAvatar() {
error.value = ''
avatarFile.value = null
@@ -80,6 +97,14 @@ function clearAvatar() {
async function saveProfile() {
error.value = ''
clearProfileFieldErrors()
if (nickname.value.trim().length < 2) {
nicknameError.value = '닉네임은 2자 이상 입력해주세요.'
error.value = '닉네임을 확인해주세요.'
return
}
saving.value = true
try {
const fd = new FormData()
@@ -92,8 +117,13 @@ async function saveProfile() {
credentials: 'include',
body: fd,
})
if (!res.ok) throw new Error('upload_failed')
const data = await res.json()
if (!res.ok) {
const requestError = new Error('profile_update_failed')
requestError.data = data
requestError.status = res.status
throw requestError
}
auth.user = data.user
avatarFile.value = null
removeAvatar.value = false
@@ -104,12 +134,60 @@ async function saveProfile() {
if (fileInput.value) fileInput.value.value = ''
toast.success('프로필을 저장했어요.')
} catch (e2) {
error.value = '프로필 저장에 실패했어요.'
const code = e2?.data?.error
if (code === 'nickname_taken') {
nicknameError.value = '이미 사용 중인 닉네임입니다.'
error.value = '닉네임이 이미 사용 중이에요.'
} else if (code === 'nickname_reserved') {
nicknameError.value = '운영자/관리자처럼 혼동될 수 있는 닉네임은 사용할 수 없어요.'
error.value = '사용할 수 없는 닉네임이에요.'
} else {
error.value = '프로필 저장에 실패했어요.'
}
} finally {
saving.value = false
}
}
async function savePassword() {
error.value = ''
clearPasswordFieldErrors()
if (nextPassword.value.length < 6) {
nextPasswordError.value = '새 비밀번호는 6자 이상 입력해주세요.'
error.value = '새 비밀번호를 확인해주세요.'
return
}
if (nextPassword.value !== nextPasswordConfirm.value) {
nextPasswordError.value = '비밀번호 확인이 일치하지 않아요.'
error.value = '비밀번호 확인이 일치하지 않아요.'
return
}
passwordSaving.value = true
try {
const data = await api.changePassword({
currentPassword: currentPassword.value,
nextPassword: nextPassword.value,
})
auth.user = data.user
currentPassword.value = ''
nextPassword.value = ''
nextPasswordConfirm.value = ''
toast.success('비밀번호를 변경했어요.')
} catch (e2) {
if (e2?.data?.error === 'invalid_current_password') {
currentPasswordError.value = '현재 비밀번호가 일치하지 않아요.'
error.value = '현재 비밀번호가 일치하지 않아요.'
} else {
error.value = '비밀번호 변경에 실패했어요.'
}
} finally {
passwordSaving.value = false
}
}
async function logout() {
await auth.logout()
toast.success('로그아웃했어요.')
@@ -132,56 +210,114 @@ async function logout() {
</section>
<section v-else-if="auth.user" class="settingsScreen">
<div class="settingsIdentity">
<div class="avatarButtonWrap">
<button class="avatarButton" type="button" @click="openAvatarPicker">
<img v-if="avatarUrl" :src="avatarUrl" class="avatarButton__image" alt="avatar" draggable="false" />
<div v-else class="avatarButton__fallback">{{ displayInitial }}</div>
<div class="avatarButton__overlay">
<span>{{ avatarUrl ? '이미지 변경' : '이미지 추가' }}</span>
<div class="settingsGrid">
<article class="settingsPanel">
<div class="settingsIdentity">
<div class="avatarButtonWrap">
<button class="avatarButton" type="button" @click="openAvatarPicker">
<img v-if="avatarUrl" :src="avatarUrl" class="avatarButton__image" alt="avatar" draggable="false" />
<div v-else class="avatarButton__fallback">{{ displayInitial }}</div>
<div class="avatarButton__overlay">
<span>{{ avatarUrl ? '이미지 변경' : '이미지 추가' }}</span>
</div>
</button>
<button
v-if="avatarUrl || previewUrl"
class="avatarButton__remove"
type="button"
aria-label="프로필 이미지 삭제"
@click="clearAvatar"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
</button>
<button
v-if="avatarUrl || previewUrl"
class="avatarButton__remove"
type="button"
aria-label="프로필 이미지 삭제"
@click="clearAvatar"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<div class="identityMeta">
<div class="identityMeta__eyebrow">Profile Photo</div>
<div class="identityMeta__title">프로필 이미지</div>
<div class="identityMeta__desc">아바타 클릭해서 이미지를 추가하거나 교체 습니다.</div>
</div>
<div class="identityMeta">
<div class="identityMeta__eyebrow">Profile Photo</div>
<div class="identityMeta__title">프로필 정보</div>
<div class="identityMeta__desc">아바타 닉네임을 정리하고, 현재 계정 이메일을 확인 어요.</div>
</div>
<input ref="fileInput" class="hiddenInput" type="file" accept="image/*" :disabled="saving" @change="onAvatarChange" />
</div>
<input ref="fileInput" class="hiddenInput" type="file" accept="image/*" :disabled="saving" @change="onAvatarChange" />
</div>
<div class="settingsFields">
<label class="field">
<span class="field__label">닉네임</span>
<input v-model="nickname" class="field__input" maxlength="40" placeholder="작성자 닉네임" />
<span class="field__hint">티어표 작성자 이름으로 표시됩니다. {{ nickname.length }}/40</span>
</label>
<div class="settingsFields">
<label class="field">
<span class="field__label">닉네임</span>
<input v-model="nickname" class="field__input" maxlength="40" placeholder="작성자 닉네임" />
<span v-if="nicknameError" class="field__error">{{ nicknameError }}</span>
<span class="field__hint">티어표 작성자 이름으로 표시됩니다. {{ nickname.length }}/40</span>
</label>
<label class="field">
<span class="field__label">이메일</span>
<input :value="auth.user.email" class="field__input field__input--readonly" readonly />
<span class="field__hint">현재 로그인에 사용 중인 계정입니다.</span>
</label>
<label class="field">
<span class="field__label">이메일</span>
<input :value="auth.user.email" class="field__input field__input--readonly" readonly />
<span class="field__hint">현재 로그인에 사용 중인 계정입니다.</span>
</label>
<div v-if="auth.user.isAdmin" class="roleBadge">Administrator</div>
</div>
<div v-if="auth.user.isAdmin" class="roleBadge">Administrator</div>
</div>
<div class="settingsActions">
<button class="primaryAction" :disabled="saving" @click="saveProfile">{{ saving ? '저장 중...' : '변경사항 저장' }}</button>
<button class="secondaryAction" type="button" @click="logout">로그아웃</button>
<div class="settingsActions">
<button class="primaryAction" :disabled="saving" @click="saveProfile">{{ saving ? '저장 중...' : '변경사항 저장' }}</button>
<button class="secondaryAction" type="button" @click="logout">로그아웃</button>
</div>
</article>
<article class="settingsPanel">
<div class="identityMeta__eyebrow">Password</div>
<div class="identityMeta__title">비밀번호 변경</div>
<div class="identityMeta__desc">현재 비밀번호를 확인한 비밀번호로 바꿀 있어요.</div>
<div class="settingsFields settingsFields--password">
<label class="field">
<span class="field__label">현재 비밀번호</span>
<input
v-model="currentPassword"
class="field__input"
type="password"
autocomplete="current-password"
maxlength="120"
placeholder="현재 비밀번호"
/>
<span v-if="currentPasswordError" class="field__error">{{ currentPasswordError }}</span>
</label>
<label class="field">
<span class="field__label"> 비밀번호</span>
<input
v-model="nextPassword"
class="field__input"
type="password"
autocomplete="new-password"
maxlength="120"
placeholder="새 비밀번호"
/>
<span v-if="nextPasswordError" class="field__error">{{ nextPasswordError }}</span>
<span class="field__hint">6~120 입력 가능 · {{ nextPassword.length }}/120</span>
</label>
<label class="field">
<span class="field__label"> 비밀번호 확인</span>
<input
v-model="nextPasswordConfirm"
class="field__input"
type="password"
autocomplete="new-password"
maxlength="120"
placeholder="새 비밀번호 확인"
/>
</label>
</div>
<div class="settingsActions">
<button class="primaryAction" type="button" :disabled="passwordSaving" @click="savePassword">
{{ passwordSaving ? '변경 중...' : '비밀번호 변경' }}
</button>
</div>
</article>
</div>
</section>
</section>
@@ -190,8 +326,7 @@ async function logout() {
<style scoped>
.settingsScreen {
display: grid;
gap: 32px;
max-width: 620px;
gap: 24px;
padding-top: 4px;
}
@@ -212,6 +347,22 @@ async function logout() {
align-items: center;
}
.settingsGrid {
display: grid;
grid-template-columns: minmax(360px, 1fr) minmax(360px, 1fr);
gap: 24px;
align-items: start;
}
.settingsPanel {
min-width: 0;
padding: 28px;
border: 1px solid var(--theme-border);
border-radius: 28px;
background: var(--theme-surface);
box-shadow: var(--theme-card-shadow);
}
.avatarButtonWrap {
position: relative;
width: 120px;
@@ -355,6 +506,12 @@ async function logout() {
color: var(--theme-text-soft);
}
.field__error {
font-size: 12px;
color: #ff7b7b;
font-weight: 700;
}
.roleBadge {
width: fit-content;
padding: 6px 10px;
@@ -373,6 +530,10 @@ async function logout() {
padding-top: 8px;
}
.settingsFields--password {
padding-top: 24px;
}
.primaryAction,
.secondaryAction {
padding: 12px 18px;
@@ -394,6 +555,15 @@ async function logout() {
}
@media (max-width: 720px) {
.settingsGrid {
grid-template-columns: minmax(0, 1fr);
}
.settingsPanel {
padding: 22px;
border-radius: 24px;
}
.settingsIdentity {
grid-template-columns: 1fr;
}

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

@@ -10,7 +10,7 @@ import addPhotoAlternateIcon from '../assets/icons/add_photo_alternate.svg'
import shareIcon from '../assets/icons/share.svg'
import RightRailAd from '../components/RightRailAd.vue'
import { api } from '../lib/api'
import { editorNewPath, editorPath, loginPath, mePath, topicPath } from '../lib/paths'
import { editorNewPath, editorPath, loginPath, mePath, topicPath, userProfilePath } from '../lib/paths'
import { toApiUrl } from '../lib/runtime'
import { useAuthStore } from '../stores/auth'
import { useToast } from '../composables/useToast'
@@ -122,9 +122,11 @@ const untitledWarning = computed(
'제목 없이 저장된 티어표는 무분별한 도배 방지를 위해 관리자에 의해 임의 삭제될 수 있어요.'
)
const canFavorite = computed(() => !!auth.user && !isNewTierList.value && !canEdit.value)
const canDuplicate = computed(() => !!auth.user && hasSavedTierList.value && !isOwnTierList.value)
const canDuplicate = computed(() => !!auth.user && hasSavedTierList.value)
const canSwitchToViewerMode = computed(() => isOwnTierList.value && hasSavedTierList.value && !previewMode.value)
const canSwitchToEditMode = computed(() => isOwnTierList.value && hasSavedTierList.value && previewMode.value)
const duplicateActionLabel = computed(() => (isOwnTierList.value ? '복사본 만들기' : '내 티어표로 복사'))
const canOpenAuthorProfile = computed(() => !!ownerId.value && hasSavedTierList.value)
const copiedFromLabel = computed(() => {
if (!sourceTierListId.value) return ''
const parts = []
@@ -940,6 +942,11 @@ function openSourceTierList() {
requestEditorNavigation(editorPath(templateId.value, sourceTierListId.value))
}
function openAuthorProfile() {
if (!canOpenAuthorProfile.value) return
router.push(userProfilePath(ownerId.value))
}
function closeSaveModal() {
isSaveModalOpen.value = false
}
@@ -998,6 +1005,9 @@ async function confirmDeleteTierList() {
async function duplicateCurrentTierList() {
if (!canDuplicate.value) return
try {
if (canEdit.value && hasUnsavedChanges.value) {
await persistTierList({ showModal: false })
}
const data = await api.duplicateTierList(tierListId.value)
const duplicatedId = data.tierList?.id
if (!duplicatedId) throw new Error('duplicate_failed')
@@ -1297,11 +1307,14 @@ onUnmounted(() => {
공유하기
</button>
<button v-if="canDuplicate" class="btn btn--save viewerSidebar__button" type="button" @click="duplicateCurrentTierList">
티어표로 복사
{{ duplicateActionLabel }}
</button>
<button v-if="canSwitchToEditMode" class="btn btn--save viewerSidebar__button" type="button" @click="openEditMode">
수정 모드로 전환
</button>
<button v-if="canOpenAuthorProfile" class="btn btn--ghost viewerSidebar__button" type="button" @click="openAuthorProfile">
작성자 프로필 보기
</button>
</div>
</div>
</template>
@@ -1730,8 +1743,9 @@ onUnmounted(() => {
<SvgIcon :src="shareIcon" :size="16" />
<span>공유하기</span>
</button>
<button v-if="canOpenAuthorProfile" class="editorSidebar__utilityLink" @click="openAuthorProfile">작성자 프로필 보기</button>
<button v-if="canEdit && hasSavedTierList" class="editorSidebar__utilityLink editorSidebar__utilityLink--danger" @click="openDeleteModal">삭제하기</button>
<button v-if="canDuplicate" class="editorSidebar__utilityLink" @click="duplicateCurrentTierList">복사해서 티어표로 가져오기</button>
<button v-if="canDuplicate" class="editorSidebar__utilityLink" @click="duplicateCurrentTierList">{{ duplicateActionLabel }}</button>
<button
v-if="canRequestTemplateCreate"
class="editorSidebar__utilityLink"

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;
}

View File

@@ -0,0 +1,471 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api'
import { editorPath, followingFeedPath, loginPath } from '../lib/paths'
import { toApiUrl } from '../lib/runtime'
import { useAuthStore } from '../stores/auth'
import { useToast } from '../composables/useToast'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const toast = useToast()
const userId = computed(() => route.params.userId || '')
const profile = ref(null)
const tierLists = ref([])
const query = ref('')
const isLoading = ref(false)
const isFollowBusy = ref(false)
const error = ref('')
const brokenThumbnailIds = ref({})
const profileAvatarUrl = computed(() => (profile.value?.avatarSrc ? toApiUrl(profile.value.avatarSrc) : ''))
const profileDisplayName = computed(() => profile.value?.nickname || profile.value?.accountName || '알 수 없음')
const profileFallback = computed(() => (profile.value?.accountName || 'u').trim().charAt(0).toUpperCase() || '?')
const canFollow = computed(() => !!auth.user && !!profile.value && !profile.value.isSelf)
watch(error, (message) => {
if (!message) return
toast.error(message)
error.value = ''
})
function fmt(ts) {
return new Date(ts).toLocaleDateString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
function displayNameOf(tierList) {
return tierList.authorName || profileDisplayName.value
}
function avatarSrcOf(tierList) {
return tierList.authorAvatarSrc ? toApiUrl(tierList.authorAvatarSrc) : profileAvatarUrl.value
}
function avatarFallbackOf(tierList) {
return (tierList.authorAccountName || profile.value?.accountName || 'u').trim().charAt(0).toUpperCase() || '?'
}
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 loadProfile() {
isLoading.value = true
try {
if (!auth.hydrated) await auth.refresh()
const [profileRes, tierListsRes] = await Promise.all([
api.getUserProfile(userId.value),
api.listUserPublicTierLists(userId.value, { q: query.value }),
])
profile.value = profileRes.user || null
tierLists.value = tierListsRes.tierLists || []
brokenThumbnailIds.value = {}
} catch (e) {
error.value = '작성자 프로필을 불러오지 못했어요.'
profile.value = null
tierLists.value = []
} finally {
isLoading.value = false
}
}
async function toggleFollow() {
if (!canFollow.value || !profile.value?.id || isFollowBusy.value) return
try {
isFollowBusy.value = true
const data = profile.value.isFollowing
? await api.unfollowUser(profile.value.id)
: await api.followUser(profile.value.id)
profile.value = data.user || profile.value
toast.success(profile.value.isFollowing ? '팔로우했어요.' : '팔로우를 해제했어요.')
} catch (e) {
if (e?.status === 401) {
router.push(loginPath(route.fullPath))
return
}
error.value = '팔로우 상태를 변경하지 못했어요.'
} finally {
isFollowBusy.value = false
}
}
function openTierList(tierList) {
router.push(editorPath(tierList.topicId, tierList.id))
}
watch(userId, loadProfile, { immediate: true })
</script>
<template>
<section class="pageWrap">
<section class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Author</div>
<h2 class="pageHead__title">{{ profileDisplayName }}</h2>
<div class="pageHead__desc">
{{ profile?.accountName ? `@${profile.accountName}` : '작성자 프로필' }}
</div>
</div>
<div class="pageHead__aside profileActions">
<button v-if="canFollow" class="btn btn--primary" :disabled="isFollowBusy" type="button" @click="toggleFollow">
{{ profile?.isFollowing ? '팔로잉' : '팔로우' }}
</button>
<button v-if="auth.user" class="btn" type="button" @click="router.push(followingFeedPath())">팔로우 피드</button>
</div>
</section>
<section class="profileHero">
<div class="profileCard">
<img v-if="profileAvatarUrl" class="profileAvatar" :src="profileAvatarUrl" :alt="profileDisplayName" draggable="false" />
<div v-else class="profileAvatar profileAvatar--fallback">{{ profileFallback }}</div>
<div class="profileMeta">
<div class="profileMeta__name">{{ profileDisplayName }}</div>
<div class="profileMeta__handle">{{ profile?.accountName ? `@${profile.accountName}` : '작성자 프로필' }}</div>
</div>
</div>
<div class="profileStats">
<article class="profileStat">
<span class="profileStat__label">공개 티어표</span>
<strong class="profileStat__value">{{ profile?.publicTierListCount || 0 }}</strong>
</article>
<article class="profileStat">
<span class="profileStat__label">팔로워</span>
<strong class="profileStat__value">{{ profile?.followerCount || 0 }}</strong>
</article>
<article class="profileStat">
<span class="profileStat__label">팔로잉</span>
<strong class="profileStat__value">{{ profile?.followingCount || 0 }}</strong>
</article>
</div>
</section>
<section class="listToolbar">
<input v-model="query" class="input" placeholder="이 작성자의 공개 티어표 검색" @keydown.enter.prevent="loadProfile" />
<button class="btn" :disabled="isLoading" type="button" @click="loadProfile">{{ isLoading ? '검색중...' : '검색' }}</button>
</section>
<div v-if="isLoading" class="empty">작성자 티어표를 불러오고 있어요.</div>
<div v-else-if="!tierLists.length" class="empty">아직 공개 티어표가 없어요.</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__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>
</template>
<style scoped>
.profileActions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn {
padding: 11px 13px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft-2);
color: var(--theme-text);
font-weight: 800;
cursor: pointer;
}
.btn--primary {
border: 0;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: #fff;
}
.profileHero {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 420px);
gap: 18px;
margin-bottom: 22px;
}
.profileCard,
.profileStat {
border-radius: 24px;
border: 1px solid var(--theme-card-border);
background: var(--theme-card-bg);
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
}
.profileCard {
padding: 24px;
display: flex;
align-items: center;
gap: 18px;
min-width: 0;
}
.profileAvatar {
width: 82px;
height: 82px;
border-radius: 9999px;
object-fit: cover;
border: 1px solid var(--theme-avatar-border);
background: var(--theme-border);
flex: 0 0 auto;
}
.profileAvatar--fallback {
display: grid;
place-items: center;
font-size: 28px;
font-weight: 900;
}
.profileMeta {
min-width: 0;
display: grid;
gap: 6px;
}
.profileMeta__name {
font-size: 24px;
font-weight: 900;
color: var(--theme-text);
word-break: break-word;
}
.profileMeta__handle {
font-size: 14px;
color: var(--theme-text-faint);
}
.profileStats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.profileStat {
padding: 18px;
display: grid;
gap: 8px;
align-content: center;
}
.profileStat__label {
font-size: 12px;
font-weight: 800;
color: var(--theme-text-faint);
}
.profileStat__value {
font-size: 26px;
font-weight: 900;
color: var(--theme-text);
}
.listToolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 18px;
}
.input {
min-width: 260px;
padding: 11px 13px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
color: var(--theme-text);
}
.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: 1px solid var(--theme-card-border);
background: var(--theme-card-bg);
color: var(--theme-text);
overflow: hidden;
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
transition:
transform 0.16s ease,
background 0.16s ease;
}
.boardCard:hover {
background: var(--theme-card-bg-hover);
transform: translateY(-2px);
}
.boardCard__body {
width: 100%;
min-width: 0;
text-align: left;
cursor: pointer;
border: 0;
background: transparent;
color: inherit;
padding: 0;
display: grid;
}
.boardCard__thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
padding: 14px 14px 0;
box-sizing: border-box;
}
.boardCard__thumb,
.boardCard__thumbPlaceholder {
width: 100%;
height: 100%;
border-radius: 18px;
display: block;
}
.boardCard__thumb {
object-fit: cover;
}
.boardCard__thumbPlaceholder {
display: grid;
place-items: center;
background: var(--theme-thumb-fallback-bg);
color: var(--theme-text-faint);
font-size: 13px;
font-weight: 700;
}
.boardCard__head {
min-width: 0;
padding: 16px 18px 18px;
display: grid;
gap: 8px;
}
.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-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;
}
.boardCard__author {
min-width: 0;
max-width: 100%;
display: inline-flex;
gap: 7px;
align-items: center;
font-size: 13px;
opacity: 0.86;
overflow: hidden;
}
.boardCard__authorName {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.boardCard__avatar {
width: 22px;
height: 22px;
border-radius: 9999px;
object-fit: cover;
border: 1px solid var(--theme-avatar-border);
background: var(--theme-border);
flex: 0 0 auto;
}
.boardCard__avatar--fallback {
display: grid;
place-items: center;
font-size: 11px;
font-weight: 900;
}
.boardCard__date,
.favoriteStat {
flex: 0 0 auto;
min-width: 0;
max-width: 100%;
font-size: 13px;
color: var(--theme-text-faint);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.boardCard__date {
font-size: 10px;
}
@media (max-width: 1400px) {
.list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1200px) {
.profileHero {
grid-template-columns: 1fr;
}
.list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.profileCard {
padding: 18px;
}
.profileStats,
.list {
grid-template-columns: 1fr;
}
.input {
min-width: 0;
width: 100%;
}
}
</style>