Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dddb57333c | |||
| b758823537 | |||
| 66408aaa1b | |||
| 426e7de177 | |||
| 953837137a | |||
| f1756a4ff1 | |||
| f9767624d1 | |||
| 9847b4dd8f | |||
| 8a43a2dd2c |
@@ -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, () => {
|
||||
|
||||
@@ -64,6 +64,9 @@ function mapUserRow(row) {
|
||||
avatarSrc: row.avatar_src || '',
|
||||
createdAt: Number(row.created_at),
|
||||
tierListCount: Number(row.tierlist_count || 0),
|
||||
followerCount: Number(row.follower_count || 0),
|
||||
receivedFavoriteCount: Number(row.received_favorite_count || 0),
|
||||
lastLoginAt: Number(row.last_login_at || 0),
|
||||
recentActivityAt: Number(row.recent_activity_at || row.created_at || 0),
|
||||
}
|
||||
}
|
||||
@@ -140,6 +143,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 || '',
|
||||
@@ -195,6 +201,21 @@ function getUserAccountName(row) {
|
||||
return email.split('@')[0] || email
|
||||
}
|
||||
|
||||
function getAssetLibrarySourceLabel(src) {
|
||||
const normalizedSrc = String(src || '').trim()
|
||||
if (normalizedSrc.includes('/uploads/assets/avatars/')) return '프로필 아바타'
|
||||
if (normalizedSrc.includes('/uploads/assets/tierlists/')) return '티어표 썸네일'
|
||||
if (normalizedSrc.includes('/uploads/assets/topics/')) return '썸네일 이미지'
|
||||
return '보관 자산'
|
||||
}
|
||||
|
||||
function getAssetLibraryKind(src) {
|
||||
const normalizedSrc = String(src || '').trim()
|
||||
if (normalizedSrc.includes('/uploads/assets/avatars/')) return 'avatar'
|
||||
if (normalizedSrc.includes('/uploads/assets/tierlists/') || normalizedSrc.includes('/uploads/assets/topics/')) return 'thumbnail'
|
||||
return 'asset'
|
||||
}
|
||||
|
||||
async function createPool() {
|
||||
const rootConnection = await mysql.createConnection({
|
||||
host: DB_HOST,
|
||||
@@ -279,6 +300,7 @@ async function ensureSchema() {
|
||||
email_verified TINYINT(1) NOT NULL DEFAULT 1,
|
||||
is_admin TINYINT(1) NOT NULL DEFAULT 0,
|
||||
avatar_src VARCHAR(255) NOT NULL DEFAULT '',
|
||||
last_login_at BIGINT NOT NULL DEFAULT 0,
|
||||
created_at BIGINT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
@@ -289,6 +311,12 @@ async function ensureSchema() {
|
||||
await query('UPDATE users SET email_verified = 1 WHERE email_verified IS NULL')
|
||||
}
|
||||
|
||||
const userLastLoginColumns = await query("SHOW COLUMNS FROM users LIKE 'last_login_at'")
|
||||
if (!userLastLoginColumns.length) {
|
||||
await query('ALTER TABLE users ADD COLUMN last_login_at BIGINT NOT NULL DEFAULT 0 AFTER avatar_src')
|
||||
await query('UPDATE users SET last_login_at = created_at WHERE last_login_at = 0')
|
||||
}
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS email_verification_tokens (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
@@ -378,6 +406,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 +421,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 +451,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 +569,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")
|
||||
@@ -603,7 +659,7 @@ async function countUsers() {
|
||||
|
||||
async function findUserByEmail(email) {
|
||||
const rows = await query(
|
||||
'SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at FROM users WHERE email = ? LIMIT 1',
|
||||
'SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE email = ? LIMIT 1',
|
||||
[email]
|
||||
)
|
||||
const row = rows[0]
|
||||
@@ -617,7 +673,7 @@ async function findUserByNickname(nickname, excludeUserId = '') {
|
||||
const rows = excludeUserId
|
||||
? await query(
|
||||
`
|
||||
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at
|
||||
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at
|
||||
FROM users
|
||||
WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?)) AND id <> ?
|
||||
LIMIT 1
|
||||
@@ -626,7 +682,7 @@ async function findUserByNickname(nickname, excludeUserId = '') {
|
||||
)
|
||||
: await query(
|
||||
`
|
||||
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at
|
||||
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at
|
||||
FROM users
|
||||
WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?))
|
||||
LIMIT 1
|
||||
@@ -640,7 +696,7 @@ async function findUserByNickname(nickname, excludeUserId = '') {
|
||||
|
||||
async function findUserById(id) {
|
||||
const rows = await query(
|
||||
'SELECT id, email, nickname, email_verified, is_admin, avatar_src, created_at FROM users WHERE id = ? LIMIT 1',
|
||||
'SELECT id, email, nickname, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE id = ? LIMIT 1',
|
||||
[id]
|
||||
)
|
||||
return mapUserRow(rows[0])
|
||||
@@ -650,14 +706,24 @@ async function createUser({ id, email, nickname, passwordHash, emailVerified = t
|
||||
const createdAt = now()
|
||||
await query(
|
||||
`
|
||||
INSERT INTO users (id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO users (id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[id, email, nickname || '', passwordHash, emailVerified ? 1 : 0, isAdmin ? 1 : 0, '', createdAt]
|
||||
[id, email, nickname || '', passwordHash, emailVerified ? 1 : 0, isAdmin ? 1 : 0, '', isAdmin ? createdAt : 0, createdAt]
|
||||
)
|
||||
return findUserById(id)
|
||||
}
|
||||
|
||||
async function touchUserLastLoginAt(userId, timestamp = now()) {
|
||||
const lastLoginAt = Number(timestamp) || now()
|
||||
const staleBefore = lastLoginAt - 10 * 60 * 1000
|
||||
await query(
|
||||
'UPDATE users SET last_login_at = ? WHERE id = ? AND last_login_at < ?',
|
||||
[lastLoginAt, userId, staleBefore]
|
||||
)
|
||||
return findUserById(userId)
|
||||
}
|
||||
|
||||
async function updateUserPassword({ id, passwordHash }) {
|
||||
await query('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, id])
|
||||
return findUserById(id)
|
||||
@@ -783,7 +849,7 @@ async function updateUserProfile({ id, nickname, avatarSrc }) {
|
||||
|
||||
async function findPrimaryAdminUser() {
|
||||
const rows = await query(
|
||||
'SELECT id, email, nickname, email_verified, is_admin, avatar_src, created_at FROM users WHERE is_admin = 1 ORDER BY created_at ASC, email ASC LIMIT 1'
|
||||
'SELECT id, email, nickname, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE is_admin = 1 ORDER BY created_at ASC, email ASC LIMIT 1'
|
||||
)
|
||||
return mapUserRow(rows[0])
|
||||
}
|
||||
@@ -808,6 +874,18 @@ async function listUsers({ queryText = '', sort = 'recent', direction = 'desc' }
|
||||
? isAsc
|
||||
? 'tierlist_count ASC, recent_activity_at ASC, u.email ASC'
|
||||
: 'tierlist_count DESC, recent_activity_at DESC, u.email ASC'
|
||||
: sort === 'followers'
|
||||
? isAsc
|
||||
? 'follower_count ASC, recent_activity_at ASC, u.email ASC'
|
||||
: 'follower_count DESC, recent_activity_at DESC, u.email ASC'
|
||||
: sort === 'favorites'
|
||||
? isAsc
|
||||
? 'received_favorite_count ASC, recent_activity_at ASC, u.email ASC'
|
||||
: 'received_favorite_count DESC, recent_activity_at DESC, u.email ASC'
|
||||
: sort === 'lastLogin'
|
||||
? isAsc
|
||||
? 'u.last_login_at ASC, recent_activity_at ASC, u.email ASC'
|
||||
: 'u.last_login_at DESC, recent_activity_at DESC, u.email ASC'
|
||||
: isAsc
|
||||
? 'recent_activity_at ASC, u.created_at ASC, u.email ASC'
|
||||
: 'recent_activity_at DESC, u.created_at ASC, u.email ASC'
|
||||
@@ -821,16 +899,21 @@ async function listUsers({ queryText = '', sort = 'recent', direction = 'desc' }
|
||||
u.email_verified,
|
||||
u.is_admin,
|
||||
u.avatar_src,
|
||||
u.last_login_at,
|
||||
u.created_at,
|
||||
COUNT(t.id) AS tierlist_count,
|
||||
COUNT(DISTINCT t.id) AS tierlist_count,
|
||||
COUNT(DISTINCT uf.follower_id) AS follower_count,
|
||||
COUNT(DISTINCT ft.user_id, ft.tierlist_id) AS received_favorite_count,
|
||||
GREATEST(
|
||||
u.created_at,
|
||||
COALESCE(MAX(t.updated_at), 0)
|
||||
) AS recent_activity_at
|
||||
FROM users u
|
||||
LEFT JOIN tierlists t ON t.author_id = u.id
|
||||
LEFT JOIN user_follows uf ON uf.following_id = u.id
|
||||
LEFT JOIN favorite_tierlists ft ON ft.tierlist_id = t.id
|
||||
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||
GROUP BY u.id, u.email, u.nickname, u.email_verified, u.is_admin, u.avatar_src, u.created_at
|
||||
GROUP BY u.id, u.email, u.nickname, u.email_verified, u.is_admin, u.avatar_src, u.last_login_at, u.created_at
|
||||
ORDER BY ${orderBy}
|
||||
`,
|
||||
params
|
||||
@@ -1746,6 +1829,32 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
getCustomItemUsageMeta(),
|
||||
])
|
||||
|
||||
const [userAvatarRows, topicThumbnailRows, tierListThumbnailRows, templateRequestThumbnailRows] = await Promise.all([
|
||||
query("SELECT avatar_src AS src FROM users WHERE avatar_src <> ''"),
|
||||
query("SELECT thumbnail_src AS src FROM topics WHERE thumbnail_src <> ''"),
|
||||
query("SELECT thumbnail_src AS src FROM tierlists WHERE thumbnail_src <> ''"),
|
||||
query("SELECT thumbnail_src_snapshot AS src FROM template_requests WHERE thumbnail_src_snapshot <> ''"),
|
||||
])
|
||||
|
||||
const avatarSrcSet = new Set(userAvatarRows.map((row) => row.src).filter(Boolean))
|
||||
const thumbnailSrcSet = new Set([
|
||||
...topicThumbnailRows.map((row) => row.src).filter(Boolean),
|
||||
...tierListThumbnailRows.map((row) => row.src).filter(Boolean),
|
||||
...templateRequestThumbnailRows.map((row) => row.src).filter(Boolean),
|
||||
])
|
||||
|
||||
const resolveLibraryAssetKind = (src) => {
|
||||
if (avatarSrcSet.has(src)) return 'avatar'
|
||||
if (thumbnailSrcSet.has(src)) return 'thumbnail'
|
||||
return getAssetLibraryKind(src)
|
||||
}
|
||||
|
||||
const resolveLibraryAssetLabel = (src) => {
|
||||
if (avatarSrcSet.has(src)) return '프로필 아바타'
|
||||
if (thumbnailSrcSet.has(src)) return '썸네일 이미지'
|
||||
return getAssetLibrarySourceLabel(src)
|
||||
}
|
||||
|
||||
const templateLinkedBySrc = new Map()
|
||||
topicItemRows.forEach((row) => {
|
||||
if (!row?.src) return
|
||||
@@ -1768,8 +1877,9 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
ownerEmail: row.email,
|
||||
usageCount: usageMeta.usageMap.get(row.id) || 0,
|
||||
linkedTemplates,
|
||||
assetKind: resolveLibraryAssetKind(row.src),
|
||||
sourceType: 'user',
|
||||
sourceLabel: '사용자 업로드',
|
||||
sourceLabel: '사용자 아이템',
|
||||
canDelete: true,
|
||||
}
|
||||
})
|
||||
@@ -1789,8 +1899,9 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
ownerEmail: '',
|
||||
usageCount: 0,
|
||||
linkedTemplates: [],
|
||||
sourceType: 'template',
|
||||
sourceLabel: '관리자 템플릿',
|
||||
sourceType: 'asset',
|
||||
sourceLabel: resolveLibraryAssetLabel(row.src),
|
||||
assetKind: resolveLibraryAssetKind(row.src),
|
||||
canDelete: true,
|
||||
sourceTopicId: '',
|
||||
sourceTopicName: '',
|
||||
@@ -1807,8 +1918,9 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
ownerEmail: '',
|
||||
usageCount: (templateLinkedBySrc.get(row.src) || new Map()).size,
|
||||
linkedTemplates: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()),
|
||||
assetKind: resolveLibraryAssetKind(row.src),
|
||||
sourceType: 'template',
|
||||
sourceLabel: '관리자 템플릿',
|
||||
sourceLabel: '템플릿 아이템',
|
||||
canDelete: true,
|
||||
sourceTopicId: row.topic_id,
|
||||
sourceTopicName: row.topic_name || row.topic_id,
|
||||
@@ -1832,7 +1944,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
|
||||
siblings.forEach((entry) => {
|
||||
if (entry.sourceType === 'user') userReferenceCount += 1
|
||||
else if (entry.isAssetLibraryItem) assetReferenceCount += 1
|
||||
else if (entry.sourceType === 'asset' || entry.isAssetLibraryItem) assetReferenceCount += 1
|
||||
else templateReferenceCount += 1
|
||||
;(entry.linkedTemplates || []).forEach((template) => {
|
||||
if (template?.id) linkedTemplates.set(template.id, template)
|
||||
@@ -1854,6 +1966,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
label: entry.label,
|
||||
sourceLabel: entry.sourceLabel,
|
||||
sourceType: entry.sourceType,
|
||||
assetKind: entry.assetKind || '',
|
||||
ownerName: entry.ownerName,
|
||||
createdAt: entry.createdAt,
|
||||
sourceTopicId: entry.sourceTopicId || '',
|
||||
@@ -1871,11 +1984,17 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
case 'template':
|
||||
return item.sourceType === 'template' && !item.isAssetLibraryItem
|
||||
case 'asset':
|
||||
return !!item.isAssetLibraryItem
|
||||
return item.sourceType === 'asset' || !!item.isAssetLibraryItem
|
||||
case 'thumbnail':
|
||||
return item.assetKind === 'thumbnail'
|
||||
case 'avatar':
|
||||
return item.assetKind === 'avatar'
|
||||
case 'library':
|
||||
return item.sourceType === 'user' || (item.sourceType === 'template' && !item.isAssetLibraryItem)
|
||||
case 'unused-user':
|
||||
return item.sourceType === 'user' && item.usageCount === 0 && item.linkedTemplates.length === 0
|
||||
case 'unused-admin':
|
||||
return !!item.isAssetLibraryItem
|
||||
return item.sourceType === 'asset' || !!item.isAssetLibraryItem
|
||||
default:
|
||||
return true
|
||||
}
|
||||
@@ -1997,6 +2116,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 +2128,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 +2139,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 +2153,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 +2201,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 +2282,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) => {
|
||||
@@ -2168,9 +2502,18 @@ function getAutoThumbnailSrc(groups = [], pool = []) {
|
||||
return fallbackItem?.src || ''
|
||||
}
|
||||
|
||||
async function listAdminTierLists({ queryText = '', topicId = '', page = 1, limit = 50, currentUserId = '' } = {}) {
|
||||
async function listAdminTierLists({
|
||||
queryText = '',
|
||||
topicId = '',
|
||||
page = 1,
|
||||
limit = 50,
|
||||
sort = 'recent',
|
||||
minFavorites = 0,
|
||||
currentUserId = '',
|
||||
} = {}) {
|
||||
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
|
||||
const normalizedPage = Math.max(Number(page) || 1, 1)
|
||||
const normalizedMinFavorites = Math.max(Number(minFavorites) || 0, 0)
|
||||
const hasQuery = !!(queryText || '').trim()
|
||||
const resolvedTopicId = (topicId || '').trim()
|
||||
const hasTopicId = !!resolvedTopicId
|
||||
@@ -2207,6 +2550,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,
|
||||
@@ -2228,7 +2574,7 @@ async function listAdminTierLists({ queryText = '', topicId = '', page = 1, limi
|
||||
params
|
||||
)
|
||||
|
||||
const allItems = rows.map((row) => {
|
||||
const baseItems = rows.map((row) => {
|
||||
const tierList = mapTierListRow(row)
|
||||
const poolItems = uniqueTierListItems(tierList.pool)
|
||||
const extraItems = poolItems.filter((item) => item.origin === 'custom')
|
||||
@@ -2239,23 +2585,47 @@ async function listAdminTierLists({ queryText = '', topicId = '', page = 1, limi
|
||||
extraItems,
|
||||
}
|
||||
})
|
||||
|
||||
const total = allItems.length
|
||||
const offset = (normalizedPage - 1) * normalizedLimit
|
||||
const pagedTierLists = allItems.slice(offset, offset + normalizedLimit)
|
||||
const favoriteStats = await getFavoriteStatsForTierListIds(
|
||||
pagedTierLists.map((tierList) => tierList.id),
|
||||
baseItems.map((tierList) => tierList.id),
|
||||
currentUserId
|
||||
)
|
||||
const filteredItems = applyFavoriteMetaToTierLists(baseItems, favoriteStats)
|
||||
.filter((tierList) => Number(tierList.favoriteCount || 0) >= normalizedMinFavorites)
|
||||
.sort((a, b) => {
|
||||
if (sort === 'favorites') {
|
||||
return (
|
||||
Number(b.favoriteCount || 0) - Number(a.favoriteCount || 0) ||
|
||||
Number(b.updatedAt || 0) - Number(a.updatedAt || 0) ||
|
||||
Number(b.createdAt || 0) - Number(a.createdAt || 0)
|
||||
)
|
||||
}
|
||||
if (sort === 'created') {
|
||||
return (
|
||||
Number(b.createdAt || 0) - Number(a.createdAt || 0) ||
|
||||
Number(b.updatedAt || 0) - Number(a.updatedAt || 0) ||
|
||||
String(a.title || '').localeCompare(String(b.title || ''))
|
||||
)
|
||||
}
|
||||
return (
|
||||
Number(b.updatedAt || 0) - Number(a.updatedAt || 0) ||
|
||||
Number(b.createdAt || 0) - Number(a.createdAt || 0) ||
|
||||
String(a.title || '').localeCompare(String(b.title || ''))
|
||||
)
|
||||
})
|
||||
|
||||
const total = filteredItems.length
|
||||
const offset = (normalizedPage - 1) * normalizedLimit
|
||||
const pagedTierLists = filteredItems.slice(offset, offset + normalizedLimit)
|
||||
return {
|
||||
tierLists: applyFavoriteMetaToTierLists(pagedTierLists, favoriteStats),
|
||||
tierLists: pagedTierLists,
|
||||
total,
|
||||
page: normalizedPage,
|
||||
limit: normalizedLimit,
|
||||
}
|
||||
}
|
||||
|
||||
async function summarizeAdminTierLists({ queryText = '', topicId = '' } = {}) {
|
||||
async function summarizeAdminTierLists({ queryText = '', topicId = '', minFavorites = 0 } = {}) {
|
||||
const normalizedMinFavorites = Math.max(Number(minFavorites) || 0, 0)
|
||||
const hasQuery = !!(queryText || '').trim()
|
||||
const resolvedTopicId = (topicId || '').trim()
|
||||
const hasTopicId = !!resolvedTopicId
|
||||
@@ -2282,7 +2652,8 @@ 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
|
||||
, t.id
|
||||
FROM tierlists t
|
||||
INNER JOIN users u ON u.id = t.author_id
|
||||
INNER JOIN topics tp ON tp.id = t.topic_id
|
||||
@@ -2291,12 +2662,21 @@ async function summarizeAdminTierLists({ queryText = '', topicId = '' } = {}) {
|
||||
params
|
||||
)
|
||||
|
||||
const total = rows.length
|
||||
const publicCount = rows.filter((row) => Number(row.is_public) === 1).length
|
||||
const favoriteStats = normalizedMinFavorites > 0
|
||||
? await getFavoriteStatsForTierListIds(rows.map((row) => row.id), '')
|
||||
: { countMap: new Map(), favoritedSet: new Set() }
|
||||
const scopedRows = normalizedMinFavorites > 0
|
||||
? rows.filter((row) => Number(favoriteStats.countMap.get(row.id) || 0) >= normalizedMinFavorites)
|
||||
: rows
|
||||
|
||||
const total = scopedRows.length
|
||||
const publicCount = scopedRows.filter((row) => Number(row.is_public) === 1).length
|
||||
const featuredCount = scopedRows.filter((row) => Number(row.is_featured) === 1).length
|
||||
return {
|
||||
total,
|
||||
publicCount,
|
||||
privateCount: Math.max(0, total - publicCount),
|
||||
featuredCount,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2312,6 +2692,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 +2903,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,7 +3059,9 @@ module.exports = {
|
||||
findUserByEmail,
|
||||
findUserByNickname,
|
||||
findUserById,
|
||||
findUserProfileById,
|
||||
createUser,
|
||||
touchUserLastLoginAt,
|
||||
updateUserPassword,
|
||||
verifyUserEmail,
|
||||
createEmailVerificationToken,
|
||||
@@ -2704,14 +3114,19 @@ module.exports = {
|
||||
listCustomItems,
|
||||
findUnusedCustomItems,
|
||||
listPublicTierLists,
|
||||
listPublicTierListsByAuthor,
|
||||
listFollowingTierLists,
|
||||
listFavoriteTierLists,
|
||||
listUserTierLists,
|
||||
listAdminTierLists,
|
||||
summarizeAdminTierLists,
|
||||
findTierListById,
|
||||
updateAdminTierListMeta,
|
||||
updateTierListFeaturedStatus,
|
||||
favoriteTopic,
|
||||
unfavoriteTopic,
|
||||
followUser,
|
||||
unfollowUser,
|
||||
favoriteTierList,
|
||||
unfavoriteTierList,
|
||||
deleteTierList,
|
||||
|
||||
@@ -75,10 +75,13 @@ async function optimizeAndPersist({ file, width, height, fit, quality }) {
|
||||
}
|
||||
}
|
||||
|
||||
const filename = nanoid() + '.webp'
|
||||
const absoluteDir = path.join(UPLOAD_ROOT, OPTIMIZED_DIR)
|
||||
const basename = nanoid()
|
||||
const shardDirectory = basename.slice(0, 2)
|
||||
const filename = basename + '.webp'
|
||||
const relativeDir = path.join(OPTIMIZED_DIR, shardDirectory)
|
||||
const absoluteDir = path.join(UPLOAD_ROOT, relativeDir)
|
||||
const absolutePath = path.join(absoluteDir, filename)
|
||||
const src = '/uploads/' + OPTIMIZED_DIR + '/' + filename
|
||||
const src = '/uploads/' + relativeDir.split(path.sep).join('/') + '/' + filename
|
||||
|
||||
await fs.mkdir(absoluteDir, { recursive: true })
|
||||
await fs.writeFile(absolutePath, data)
|
||||
|
||||
@@ -38,6 +38,7 @@ const {
|
||||
summarizeAdminTierLists,
|
||||
findTierListById,
|
||||
updateAdminTierListMeta,
|
||||
updateTierListFeaturedStatus,
|
||||
listAdminTemplateRequests,
|
||||
findTemplateRequestById,
|
||||
updateTemplateRequestStatus,
|
||||
@@ -303,7 +304,7 @@ router.delete('/templates/:templateId', requireAdmin, async (req, res) => {
|
||||
router.patch('/custom-items/:itemId/label', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
label: z.string().trim().min(1).max(60),
|
||||
sourceType: z.enum(['template', 'user']).optional().default('user'),
|
||||
sourceType: z.enum(['template', 'user', 'asset']).optional().default('user'),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
@@ -331,7 +332,10 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
page: z.coerce.number().int().min(1).optional().default(1),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
|
||||
filter: z.enum(['all', 'user', 'template', 'asset', 'unused-user', 'unused-admin']).optional().default('all'),
|
||||
filter: z
|
||||
.enum(['library', 'all', 'user', 'template', 'asset', 'thumbnail', 'avatar', 'unused-user', 'unused-admin'])
|
||||
.optional()
|
||||
.default('library'),
|
||||
})
|
||||
const parsed = schema.safeParse(req.query)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
@@ -349,6 +353,8 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
topicId: z.string().trim().max(120).optional().default(''),
|
||||
sort: z.enum(['recent', 'created', 'favorites']).optional().default('recent'),
|
||||
minFavorites: z.coerce.number().int().min(0).max(1000000).optional().default(0),
|
||||
page: z.coerce.number().int().min(1).optional().default(1),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
|
||||
})
|
||||
@@ -358,6 +364,8 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
|
||||
const result = await listAdminTierLists({
|
||||
queryText: parsed.data.q,
|
||||
topicId: parsed.data.topicId,
|
||||
sort: parsed.data.sort,
|
||||
minFavorites: parsed.data.minFavorites,
|
||||
page: parsed.data.page,
|
||||
limit: parsed.data.limit,
|
||||
currentUserId: req.session?.userId || '',
|
||||
@@ -369,6 +377,7 @@ router.get('/tierlists/stats', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
topicId: z.string().trim().max(120).optional().default(''),
|
||||
minFavorites: z.coerce.number().int().min(0).max(1000000).optional().default(0),
|
||||
})
|
||||
const parsed = schema.safeParse(req.query)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
@@ -376,10 +385,30 @@ router.get('/tierlists/stats', requireAdmin, async (req, res) => {
|
||||
const result = await summarizeAdminTierLists({
|
||||
queryText: parsed.data.q,
|
||||
topicId: parsed.data.topicId,
|
||||
minFavorites: parsed.data.minFavorites,
|
||||
})
|
||||
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 })
|
||||
@@ -646,6 +675,15 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
||||
const result = await listCustomItems({ page: 1, limit: 10000, filterMode: 'all' })
|
||||
const target = result.items.find((item) => item.id === req.params.itemId)
|
||||
if (!target) return res.status(404).json({ error: 'not_found' })
|
||||
if (target.sourceType === 'asset' || String(target.id || '').startsWith('asset:')) {
|
||||
const assetId = String(target.id).slice('asset:'.length)
|
||||
const asset = await findImageAssetById(assetId)
|
||||
if (!asset) return res.status(404).json({ error: 'not_found' })
|
||||
await deleteImageAssets([assetId])
|
||||
await removeUploadFiles([asset.src])
|
||||
return res.json({ ok: true, sourceType: 'asset' })
|
||||
}
|
||||
|
||||
if (target.sourceType === 'template') {
|
||||
if (String(target.id || '').startsWith('asset:')) {
|
||||
const assetId = String(target.id).slice('asset:'.length)
|
||||
@@ -950,7 +988,7 @@ router.delete('/custom-items', requireAdmin, async (req, res) => {
|
||||
router.get('/users', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
sort: z.enum(['recent', 'created', 'tierlists']).optional().default('recent'),
|
||||
sort: z.enum(['recent', 'lastLogin', 'created', 'tierlists', 'followers', 'favorites']).optional().default('recent'),
|
||||
direction: z.enum(['asc', 'desc']).optional().default('desc'),
|
||||
})
|
||||
const parsed = schema.safeParse(req.query)
|
||||
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
findUserByNickname,
|
||||
findUserById,
|
||||
createUser,
|
||||
touchUserLastLoginAt,
|
||||
updateUserPassword,
|
||||
verifyUserEmail,
|
||||
createEmailVerificationToken,
|
||||
@@ -204,7 +205,8 @@ router.post('/signup', async (req, res) => {
|
||||
|
||||
try {
|
||||
await establishSession(req, user)
|
||||
res.json({ user: await serializeUser(user), verificationRequired: false })
|
||||
const touchedUser = await touchUserLastLoginAt(user.id)
|
||||
res.json({ user: await serializeUser(touchedUser || user), verificationRequired: false })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: 'session_save_failed' })
|
||||
}
|
||||
@@ -227,7 +229,8 @@ router.post('/login', async (req, res) => {
|
||||
|
||||
try {
|
||||
await establishSession(req, user)
|
||||
res.json({ user: await serializeUser(user) })
|
||||
const touchedUser = await touchUserLastLoginAt(user.id)
|
||||
res.json({ user: await serializeUser(touchedUser || user) })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: 'session_save_failed' })
|
||||
}
|
||||
@@ -240,7 +243,7 @@ router.post('/logout', async (req, res) => {
|
||||
|
||||
router.get('/me', async (req, res) => {
|
||||
if (!req.session || !req.session.userId) return res.json({ user: null })
|
||||
const user = await findUserById(req.session.userId)
|
||||
const user = await touchUserLastLoginAt(req.session.userId)
|
||||
if (!user) return res.json({ user: null })
|
||||
res.json({ user: await serializeUser(user) })
|
||||
})
|
||||
@@ -265,7 +268,8 @@ router.post('/email/verify', async (req, res) => {
|
||||
|
||||
try {
|
||||
await establishSession(req, user)
|
||||
res.json({ user: await serializeUser(user) })
|
||||
const touchedUser = await touchUserLastLoginAt(user.id)
|
||||
res.json({ user: await serializeUser(touchedUser || user) })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: 'session_save_failed' })
|
||||
}
|
||||
@@ -321,7 +325,8 @@ router.post('/password-reset/confirm', async (req, res) => {
|
||||
|
||||
try {
|
||||
await establishSession(req, verifiedUser)
|
||||
res.json({ user: await serializeUser(verifiedUser) })
|
||||
const touchedUser = await touchUserLastLoginAt(verifiedUser.id)
|
||||
res.json({ user: await serializeUser(touchedUser || verifiedUser) })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: 'session_save_failed' })
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
77
backend/src/routes/users.js
Normal file
77
backend/src/routes/users.js
Normal 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
|
||||
@@ -1,5 +1,42 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-04-03 v1.4.59
|
||||
- 최근 최적화 이미지가 `assets` 바로 아래 평면 파일로 저장되면서 경로만으로 프로필/썸네일 역할을 구분할 수 없게 되었으므로, 관리자 아이템 분류는 폴더명 규칙 하나에만 기대지 말고 실제 DB 참조 컬럼을 역추적해 판별하는 편이 더 안전하다고 판단했다.
|
||||
- 이미지가 장기적으로 많이 쌓일 수 있는 서비스라면 한 폴더에 모든 파일을 계속 몰아넣기보다 적당한 수준의 하위 폴더 분산이 낫다고 보고, 신규 파일만 ID 앞 2글자로 1단계 샤딩 저장하되 기존 평면 경로는 그대로 유지하는 점진 방식으로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.58
|
||||
- 작성자 프로필 화면 상단에서 닉네임과 `@accountName`을 다시 보여주면 바로 아래 프로필 카드의 동일 정보와 역할이 겹치므로, 상단은 페이지 성격을 설명하는 공통 제목으로 두고 실제 사용자 식별 정보는 프로필 카드 한 곳에만 모으는 편이 낫다고 판단했다.
|
||||
- `@accountName`은 사용자가 직접 만든 핸들이 아니라 이메일 앞부분 기반 표시라서 계정명이 따로 존재하는 것처럼 오해를 만들 수 있으므로, 별도 사용자명 정책을 도입하기 전까지는 공개 프로필 UI에서 숨기는 쪽으로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.57
|
||||
- 아이템 관리 필터는 전체 데이터 탐색과 실제 반복 아이템 검수를 같은 셀렉트에서 오가야 하므로, `전체 이미지`를 맨 위에 두되 기본값은 여전히 `아이템(템플릿 + 사용자)`로 유지해 운영자가 처음부터 프로필/썸네일 자산에 묻히지 않게 하는 편이 맞다고 판단했다.
|
||||
- `미사용 사용자 업로드`라는 표현은 계정 탈퇴 잔여물처럼 오해될 수 있으므로, 실제 의미가 “사용자 아이템 레코드는 남아 있지만 현재 저장 티어표/템플릿 참조가 없는 항목”이라는 점에 맞춰 `미사용 아이템`으로 줄이고, 계정 삭제 시 외래키로 같이 삭제되는 항목은 이 범주로 남지 않는다고 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.56
|
||||
- 아이템 관리의 원래 목적은 “반복 사용 가능한 티어표 아이템”과 “사용자가 올린 커스텀 아이템”을 운영자가 구분해 검수하는 것이었는데, 프로필 아바타나 티어표 썸네일까지 `관리자 템플릿`으로 보이면 의미가 흐려지므로 보관 이미지 자산은 별도 출처와 배지로 분리하는 편이 맞다고 판단했다.
|
||||
- 평소 운영자가 가장 먼저 봐야 하는 대상도 1회성 썸네일이 아니라 실제 아이템이므로, 아이템 관리 기본 필터는 `전체 이미지`가 아니라 `아이템만 (템플릿+사용자)`로 두고 썸네일/프로필 이미지는 필요할 때만 따로 보게 하는 쪽으로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.55
|
||||
- 기존 `최근 활동`은 실제 의미가 “작성한 티어표의 마지막 수정일”에 가까웠는데, 이를 마지막 접속일처럼 읽을 수 있으면 장기 미접속 계정 정리 판단이 틀어질 수 있으므로 `최근 콘텐츠 활동`과 `마지막 접속일`을 아예 분리하는 편이 맞다고 판단했다.
|
||||
- 마지막 접속일은 로그인 성공 순간만 찍으면 장기 세션 사용자를 놓칠 수 있으므로, 세션이 살아 있는 `/api/auth/me` 확인에서도 일정 간격으로 갱신해 실제 접속 흔적에 더 가깝게 유지하는 쪽으로 정리했다.
|
||||
- 관리자 회원 카드에서 팔로워/즐겨찾기 수치를 보더라도 실제 공개 프로필과 작성 글 구성을 바로 확인할 진입점이 없으면 운영 판단이 끊기므로, `회원 정보 수정` 옆에 `프로필 보기` 버튼을 같이 두는 편이 자연스럽다고 판단했다.
|
||||
|
||||
## 2026-04-03 v1.4.54
|
||||
- 추천 티어표를 수동으로만 지정할 수 있어도, 운영자가 후보 자체를 찾지 못하면 실무상 큐레이션이 막히므로, 관리자 전체 티어표 목록에 받은 즐겨찾기 수를 직접 보여주고 즐겨찾기 많은 순/최소 즐겨찾기 필터를 먼저 붙이는 편이 맞다고 판단했다.
|
||||
- 누가 핵심 작성자인지 보는 기준도 작성 티어표 수 하나만으로는 부족하므로, 팔로워 수와 받은 즐겨찾기 수를 회원 관리 카드에 같이 노출하고 이 지표로 정렬할 수 있게 두는 쪽으로 정리했다.
|
||||
- 이메일 인증/재설정 메일이 들어간 뒤에는 운영자가 평소 화면에서 회원 비밀번호를 직접 덮어쓰는 버튼을 계속 보는 것이 과한 권한처럼 느껴질 수 있으므로, 서버 API는 최후 보루로 남기되 관리자 회원 카드의 비밀번호 초기화 UI는 숨기는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-03 v1.4.53
|
||||
- 본인 티어표 복사 기능이 타인 티어표 전용 조건으로만 남아 있었지만, 실제 사용에서는 자기 작업본을 변형용 복사본으로 다시 만들고 싶은 경우도 많으므로 저장된 본인 티어표에도 복사 버튼을 여는 편이 맞다고 판단했다.
|
||||
- 편집 중 저장하지 않은 변경이 있는 상태에서 복사본을 만들 때는 마지막 저장본이 아니라 현재 화면 상태가 복사되기를 기대하기 쉬우므로, 본인 편집본 복사는 복사 직전에 현재 원본을 한 번 저장한 뒤 새 복사본을 만드는 쪽으로 정리했다.
|
||||
- 팔로우 기능은 처음부터 추천 알고리즘까지 섞기보다, 작성자 프로필과 팔로우 피드라는 명확한 사용자 경로를 먼저 열어두는 편이 제품 구조상 자연스럽다고 보고 `user_follows` 기반 1차 구현을 먼저 붙였다.
|
||||
- 작성자 프로필 진입점은 목록 카드 내부 작성자 클릭을 바로 분리하면 기존 카드 전체 클릭 문법과 충돌할 수 있으므로, 이번 단계에서는 티어표 편집/뷰어 우측 패널의 `작성자 프로필 보기`를 우선 진입점으로 두고 카드 내부 세부 클릭 분리는 후속 UX로 미루는 편이 안전하다고 판단했다.
|
||||
|
||||
## 2026-04-03 v1.4.51
|
||||
- 불특정 다수가 만드는 공개 티어표를 전부 같은 선상에 두면 첫 화면 품질 편차가 너무 커질 수 있으므로, 주제별 목록 상단에 관리자 큐레이션 `추천 티어표` 섹션을 두고 아래 일반 공개 목록과 분리하는 편이 맞다고 판단했다.
|
||||
- 추천 선정은 처음부터 완전 자동화보다 운영자가 직접 지정/해제할 수 있는 수동 큐레이션을 먼저 넣는 편이 안전하므로, 좋아요 수 기반 자동 후보 필터와 팔로우 피드는 후속 작업으로 미루고 이번 릴리스에서는 추천 표시 구조와 관리자 토글만 먼저 완성했다.
|
||||
- 비공개 글이 추천 섹션에 올라가면 접근 정책이 꼬이므로, 추천 지정은 공개 글만 허용하고 공개글을 비공개로 바꾸면 추천 상태도 함께 해제하는 쪽으로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.49
|
||||
- 프로필 저장 실패를 하나의 일반 실패 메시지로만 보여주면 사용자가 “서버가 고장났나?”라고 오해하기 쉬우므로, 중복 닉네임/예약어 닉네임처럼 사용자가 직접 고칠 수 있는 입력 오류는 원인별 안내를 분리하는 편이 맞다고 판단했다.
|
||||
- 비밀번호를 잊은 사용자뿐 아니라 로그인 중인 사용자도 보안상 주기적으로 비밀번호를 직접 바꿀 수 있어야 하므로, 설정 화면에 현재 비밀번호 확인 기반 변경 폼을 추가하는 쪽으로 정리했다.
|
||||
|
||||
17
docs/map.md
17
docs/map.md
@@ -7,7 +7,7 @@
|
||||
|
||||
## `/topics/:topicId`
|
||||
- 화면 파일: `frontend/src/views/TopicHubView.vue`
|
||||
- 역할: 선택한 주제 정보 표시, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
|
||||
- 역할: 선택한 주제 정보 표시, 관리자 추천 티어표 상단 강조 섹션과 일반 공개 티어표 목록 분리 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
|
||||
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`
|
||||
|
||||
## `/editor/:topicId/new`, `/editor/:topicId/:tierListId`
|
||||
@@ -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,8 +47,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`
|
||||
@@ -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`
|
||||
|
||||
50
docs/spec.md
50
docs/spec.md
@@ -24,6 +24,7 @@
|
||||
- 아바타: `backend/uploads/avatars/`
|
||||
- 커스텀 아이템: `backend/uploads/custom/`
|
||||
- 시드 이미지: `backend/uploads/seeds/`
|
||||
- 최적화 이미지 자산: 신규 업로드는 `backend/uploads/assets/<앞2글자>/<파일명>.webp` 형태로 1단계 샤딩 저장하고, 기존 `backend/uploads/assets/<파일명>.webp` 평면 경로도 계속 읽는다.
|
||||
|
||||
## 화면 구조
|
||||
- 좌측 패널
|
||||
@@ -67,7 +68,9 @@
|
||||
- `emailVerified`: boolean
|
||||
- `isAdmin`: boolean
|
||||
- `avatarSrc`: string
|
||||
- `lastLoginAt`: number
|
||||
- `createdAt`: number
|
||||
- 관리자 목록 집계 응답에서는 `tierListCount`, `followerCount`, `receivedFavoriteCount`, `lastLoginAt`, `recentActivityAt`도 함께 내려준다.
|
||||
- `emailVerificationTokens`
|
||||
- `id`: string
|
||||
- `userId`: string
|
||||
@@ -109,6 +112,9 @@
|
||||
- 사용자가 직접 지정하지 않으면 저장 시 티어표 대표 아이템 이미지로 자동 채운다.
|
||||
- `description`: string
|
||||
- `isPublic`: boolean
|
||||
- `isFeatured`: boolean
|
||||
- `featuredAt`: number
|
||||
- `featuredBy`: string
|
||||
- `groups`: `{ id, name, itemIds[] }[]`
|
||||
- `pool`: `{ id, src, label, origin }[]`
|
||||
- `createdAt`: number
|
||||
@@ -117,6 +123,10 @@
|
||||
- `userId`: string
|
||||
- `tierListId`: string
|
||||
- `createdAt`: number
|
||||
- `userFollows`
|
||||
- `followerId`: string
|
||||
- `followingId`: string
|
||||
- `createdAt`: number
|
||||
- `gameSuggestions`
|
||||
- `id`: string
|
||||
- `name`: string
|
||||
@@ -130,23 +140,27 @@
|
||||
- 이메일 인증이 끝나지 않은 계정은 `email_unverified`로 차단한다.
|
||||
- `POST /api/auth/logout`
|
||||
- `GET /api/auth/me`
|
||||
- 로그인 세션이 살아 있는 사용자의 `last_login_at`을 주기적으로 갱신해, 회원 관리에서 `마지막 접속일`을 따로 볼 수 있게 한다.
|
||||
- `GET /api/auth/meta`
|
||||
- `POST /api/auth/profile`
|
||||
- `POST /api/auth/password`
|
||||
- 로그인한 사용자가 현재 비밀번호를 확인한 뒤 새 비밀번호로 직접 변경한다.
|
||||
- `POST /api/auth/email/verify`
|
||||
- `login?verifyToken=...` 링크에서 받은 토큰으로 이메일 인증을 완료하고 바로 로그인 세션을 만든다.
|
||||
- 인증 완료 직후 로그인 세션이 열리면서 `last_login_at`도 함께 갱신한다.
|
||||
- `POST /api/auth/email/resend`
|
||||
- 미인증 계정의 인증 메일을 다시 발송한다.
|
||||
- `POST /api/auth/password-reset/request`
|
||||
- 입력한 이메일로 비밀번호 재설정 링크를 발송한다.
|
||||
- `POST /api/auth/password-reset/confirm`
|
||||
- `login?resetToken=...` 링크의 토큰과 새 비밀번호로 비밀번호를 재설정하고 바로 로그인 세션을 만든다.
|
||||
- 재설정 완료 직후 로그인 세션이 열리면서 `last_login_at`도 함께 갱신한다.
|
||||
- 주제
|
||||
- `GET /api/topics`
|
||||
- `GET /api/topics/:topicId`
|
||||
- 티어표
|
||||
- `GET /api/tierlists/public`
|
||||
- `featuredTierLists`와 일반 공개 `tierLists`를 분리해서 반환한다.
|
||||
- `topicId` 없이 `q`만 전달하면 전체 공개 티어표 검색에 사용한다.
|
||||
- `GET /api/tierlists/me`
|
||||
- `GET /api/tierlists/favorites/me`
|
||||
@@ -158,6 +172,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`
|
||||
@@ -165,6 +188,10 @@
|
||||
- 여러 이미지를 한 번에 최대 `100개`까지 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다.
|
||||
- `PATCH /api/admin/templates/:templateId/items/:itemId`
|
||||
- `GET /api/admin/tierlists`
|
||||
- `sort=recent|created|favorites`, `minFavorites`, `topicId`, `q`, `page`, `limit`으로 인기 티어표 후보를 정렬/필터링할 수 있다.
|
||||
- `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`
|
||||
@@ -172,10 +199,13 @@
|
||||
- `POST /api/admin/tierlists/:tierListId/promote-items`
|
||||
- `POST /api/admin/tierlists/:tierListId/create-template`
|
||||
- `GET /api/admin/custom-items`
|
||||
- `filter=library`를 기본값으로 사용해 반복 사용 가능한 `템플릿 아이템 + 사용자 아이템`만 먼저 보여주고, `filter=thumbnail` / `filter=avatar`로는 현재 참조 역할이 썸네일/프로필인 이미지를 따로 조회한다.
|
||||
- `filter=all|library|template|user|thumbnail|avatar|unused-user`를 사용하며, `filter=asset|unused-admin`은 과거 UI 호환용으로만 유지한다.
|
||||
- `POST /api/admin/custom-items/:itemId/promote`
|
||||
- `DELETE /api/admin/custom-items/:itemId`
|
||||
- `DELETE /api/admin/custom-items`
|
||||
- `GET /api/admin/users`
|
||||
- `sort=recent|lastLogin|created|tierlists|followers|favorites`, `direction=asc|desc`로 회원을 콘텐츠 활동/마지막 접속/작성량/팔로워/받은 즐겨찾기 기준으로 정렬한다.
|
||||
- `PATCH /api/admin/users/:userId`
|
||||
- `PATCH /api/admin/users/:userId/password`
|
||||
- `DELETE /api/admin/users/:userId`
|
||||
@@ -190,10 +220,14 @@
|
||||
- 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다.
|
||||
- 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다.
|
||||
- 게임 관리 탭에서는 홈 화면 상단에 먼저 노출할 게임을 최대 50개까지 지정하고, 드래그 또는 위/아래 버튼으로 순서를 저장할 수 있다.
|
||||
- 사용자 업로드 커스텀 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
|
||||
- 사용자 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
|
||||
- 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다.
|
||||
- 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다.
|
||||
- 사용자 아이템은 사용 횟수(`usageCount`)를 표시하며, `미사용 아이템` 필터에서 저장 티어표/템플릿 참조가 더 이상 없는 항목만 개별/일괄 삭제할 수 있다. 사용자가 계정 탈퇴 등으로 삭제된 경우는 `custom_items.owner_id` 외래키로 레코드가 같이 사라지므로 보통 `미사용 아이템`으로 남지 않는다.
|
||||
- 아이템 관리 기본 필터는 `아이템(템플릿 + 사용자)`이며, 우측 필터 순서는 `전체 이미지 → 아이템(템플릿 + 사용자) → 템플릿 아이템 → 사용자 아이템 → 썸네일 이미지 → 프로필 이미지 → 미사용 아이템`을 사용한다.
|
||||
- `/uploads/assets/avatars/`는 `프로필 아바타`, `/uploads/assets/tierlists/`와 `/uploads/assets/topics/`는 `썸네일 이미지`, 그 외 보관 이미지는 `보관 자산` 배지로 표시한다. 최근처럼 `/uploads/assets/<파일명>.webp` 또는 `/uploads/assets/<앞2글자>/<파일명>.webp` 경로만 보고 종류를 알 수 없는 자산은 DB 참조(`avatar_src`, `thumbnail_src`, `thumbnail_src_snapshot`)를 역추적해 프로필/썸네일 여부를 분류한다. 실제 템플릿 기본 항목은 `템플릿 아이템`, 사용자 커스텀 항목은 `사용자 아이템` 배지를 사용한다.
|
||||
- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 내부에서 `템플릿 요청 관리 / 전체 티어표 관리`를 분리해 볼 수 있고, 확인용 완성본은 탐색 UI 없는 preview 전용 모달로 연다.
|
||||
- `전체 티어표 관리`에서는 공개 티어표를 `추천 지정 / 추천 해제`할 수 있고, 추천 지정된 티어표는 주제별 공개 목록 상단의 `추천 티어표` 섹션에 먼저 노출된다. 비공개 티어표는 추천 지정할 수 없고, 추천글을 비공개로 바꾸면 추천 상태도 함께 해제된다.
|
||||
- `전체 티어표 관리` 카드에는 받은 즐겨찾기 수를 함께 보여주며, 우측 운영 패널에서 즐겨찾기 많은 순 정렬과 최소 즐겨찾기 수 필터로 추천 후보를 좁힐 수 있다.
|
||||
- `티어표 관리` 탭의 추가 아이템은 작은 그리드 카드로 표시하고, 클릭 시 `기존 템플릿에 추가 / 새 템플릿 만들기` 모달을 통해 목적지를 선택한다.
|
||||
- `티어표 관리` 탭에서는 티어표 안의 커스텀 아이템을 개별 또는 일괄로 기존 게임 템플릿에 복제할 수 있다.
|
||||
- `freeform` 티어표는 관리자 화면에서 새 게임 ID/이름을 입력해 새로운 게임 템플릿으로 복제 생성할 수 있다.
|
||||
@@ -202,21 +236,29 @@
|
||||
- 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다.
|
||||
- 단, 일반 운영자는 최고 관리자 계정의 프로필 이미지/회원 정보/비밀번호/삭제 버튼을 사용할 수 없고, 최고 관리자만 다른 관리자 권한을 변경할 수 있다.
|
||||
- 관리자 회원 정보 수정은 운영상 필요한 경우 예약어 닉네임도 저장할 수 있지만, 일반 회원가입과 개인 프로필 수정에서는 운영자 사칭성 예약어 닉네임을 계속 차단한다.
|
||||
- 회원 관리 카드에는 아바타, 작성 티어표 수, 최근 활동 시각을 함께 표시한다.
|
||||
- 회원 관리 카드에는 아바타, 작성 티어표 수, 팔로워 수, 받은 즐겨찾기 수, 최근 콘텐츠 활동, 마지막 접속일을 함께 표시한다.
|
||||
- 운영자는 회원 목록을 작성 티어표 수뿐 아니라 팔로워 수와 받은 즐겨찾기 수 기준으로도 정렬할 수 있어, 핵심 작성자를 더 빠르게 찾을 수 있다.
|
||||
- 마지막 접속일은 로그인/세션 확인 기준, 최근 콘텐츠 활동은 작성한 티어표의 마지막 수정일 기준으로 분리해서 보여준다. 따라서 장기 미접속 계정 정리 판단은 마지막 접속일을 우선 사용하고, 콘텐츠 기여가 최근인지 볼 때는 최근 콘텐츠 활동을 사용한다.
|
||||
- 회원 카드의 `프로필 보기` 버튼은 해당 회원의 `/users/:userId` 공개 프로필 화면으로 이동해, 팔로워/공개 티어표 현황을 관리자 화면 밖에서도 바로 확인할 수 있게 한다.
|
||||
- 회원 비밀번호를 운영자가 임의로 덮어쓰는 기능은 비상 상황용 API로만 유지하고, 일반 회원 관리 카드에서는 비밀번호 초기화 버튼과 모달을 숨긴다. 평소 사용자 비밀번호 변경은 이메일 재설정 메일과 설정 화면 직접 변경을 우선 사용한다.
|
||||
|
||||
## 티어표 접근 메모
|
||||
- `new` 작성 경로는 로그인한 사용자만 진입할 수 있다.
|
||||
- 공유 링크로 여는 `preview=1` 화면은 `뷰어 모드`로 취급하며, 드래그/행열 편집/저장 같은 편집 UI 없이 완성본만 렌더링한다.
|
||||
- 비로그인 사용자나 작성자 본인이 아닌 로그인 사용자는 저장된 티어표를 기본적으로 뷰어 모드로 열람하며, 일반 편집 URL로 직접 진입해도 소유자가 아니면 `preview=1` 주소로 자동 전환된다.
|
||||
- 비로그인 사용자도 뷰어 모드 우측 레일의 `공유하기` 버튼으로 현재 공유 링크를 복사할 수 있다.
|
||||
- 로그인한 타인 티어표 열람자는 뷰어 모드 우측 레일에서 `내 티어표로 복사`를 사용할 수 있고, 작성자 본인은 `수정 모드로 전환`을 사용할 수 있다.
|
||||
- 로그인한 사용자는 뷰어 모드 우측 레일에서 저장된 티어표를 복사할 수 있고, 타인 티어표면 `내 티어표로 복사`, 본인 티어표면 `복사본 만들기` 문구를 사용한다. 작성자 본인은 `수정 모드로 전환`도 사용할 수 있다.
|
||||
- 작성자 본인이 일반 편집 화면에서 저장된 본인 티어표를 보고 있을 때는 우측 패널의 `뷰어 모드로 보기`로 공유 화면 형태를 바로 확인할 수 있다.
|
||||
- 편집/뷰어 우측 패널의 `작성자 프로필 보기`로 해당 작성자의 공개 프로필과 공개 티어표 목록을 열 수 있고, 로그인 상태에서는 작성자 프로필에서 팔로우/언팔로우를 전환할 수 있다.
|
||||
- `/users/:userId` 공개 프로필 화면 상단 헤더는 고정 제목 `사용자 프로필`과 안내 문구를 보여주고, 실제 닉네임/아바타는 본문 프로필 카드에서만 표시한다. 이메일 앞부분에서 파생된 `@accountName`은 사용자가 직접 설정한 핸들이 아니므로 프로필 UI에 노출하지 않는다.
|
||||
- 같은 `TierEditorView` 안에서 `topicId / tierListId / preview` 라우트 값만 바뀌어도 화면이 이전 티어표 데이터에 남지 않도록, 라우트 변경마다 에디터 상태를 다시 로드한다.
|
||||
- 복사한 티어표 상단의 `원본` 링크를 누르면 원본 티어표로 이동하며, 편집 모드에서 저장하지 않은 변경이 있으면 이동 전에 `저장 없이 이동` 확인 모달을 먼저 띄운다.
|
||||
- 본인 티어표도 저장된 상태라면 편집/뷰어 우측 패널에서 복사본을 만들 수 있고, 편집 중 저장하지 않은 수정이 남아 있으면 복사 직전에 현재 수정본을 먼저 저장해 최신 상태 기준 복사본을 만든다.
|
||||
- 비공개 티어표라도 관리자는 편집 화면에서 완성본을 열람할 수 있다.
|
||||
- 공개 티어표는 목록과 상세 화면에서 즐겨찾기 토글 및 개수를 함께 표시한다.
|
||||
- 카드형 목록에서는 즐겨찾기 수/상태만 표시하고, 실제 토글은 상세 화면에서 처리한다.
|
||||
- 공개 티어표 목록은 현재 게임 기준으로 제목/작성자 검색을 지원한다.
|
||||
- 주제별 공개 티어표 화면은 관리자 추천글을 상단 `추천 티어표` 섹션으로 먼저 보여주고, 일반 공개 목록은 아래 `전체 공개 티어표` 섹션으로 분리해 중복 없이 렌더링한다. 추천 섹션은 최대 16개까지 표시한다.
|
||||
- `내 즐겨찾기` 화면에서는 즐겨찾기한 순, 최신 업데이트순, 인기순 정렬을 제공한다.
|
||||
- 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다.
|
||||
- 사용자가 직접 추가한 커스텀 아이템 이름은 편집 화면 우측 목록에서 정리할 수 있고, 저장 시 원본 커스텀 아이템 라벨과 함께 동기화된다.
|
||||
|
||||
29
docs/todo.md
29
docs/todo.md
@@ -1,6 +1,31 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 단기 확인
|
||||
- `v1.4.59`에서 `thumbnail/avatar` 필터를 실제 DB 참조 역할 기준으로 다시 판별하도록 바꿨으므로, 최근 업로드처럼 `/uploads/assets/<파일명>.webp` 또는 `/uploads/assets/<앞2글자>/<파일명>.webp` 경로여도 썸네일 이미지/프로필 이미지 필터에서 빠지지 않는지 확인한다.
|
||||
- 신규 업로드 이미지는 `/uploads/assets/<앞2글자>/<파일명>.webp`로 저장되므로, 템플릿 썸네일/티어표 썸네일/프로필 아바타/아이템 업로드를 각각 새로 올린 뒤 실제 파일이 샤딩 폴더에 생성되고, 브라우저 표시·삭제·중복 재사용이 모두 기존처럼 동작하는지 QA한다.
|
||||
- 기존 `/uploads/assets/<파일명>.webp` 평면 경로는 그대로 유지되므로, 예전에 만든 티어표 썸네일과 아이템 이미지가 새 저장 구조 변경 후에도 깨지지 않는지 확인한다.
|
||||
- `v1.4.58`에서 작성자 프로필 상단 헤더를 `사용자 프로필` 공통 제목으로 바꾸고 `@accountName` 노출을 뺐으므로, `/users/:userId`에서 상단 문구와 본문 프로필 카드가 중복되지 않고 닉네임/아바타/팔로우 버튼만 자연스럽게 읽히는지 확인한다.
|
||||
- `v1.4.57`에서 관리자 아이템 필터 순서를 `전체 이미지 → 아이템(템플릿 + 사용자) → 템플릿 아이템 → 사용자 아이템 → 썸네일 이미지 → 프로필 이미지 → 미사용 아이템`으로 바꿨으므로, 우측 셀렉트 순서와 실제 필터링 결과가 같은 의미로 동작하는지 QA한다.
|
||||
- `썸네일 이미지` 필터에서는 `/uploads/assets/tierlists`, `/uploads/assets/topics`만 모이고, `프로필 이미지` 필터에서는 `/uploads/assets/avatars`만 모이며, 각 카드 배지가 `썸네일 이미지 / 프로필 아바타`로 구분되는지 확인한다.
|
||||
- `미사용 아이템` 필터는 사용자 아이템 중 저장 티어표 사용 횟수와 템플릿 연결이 모두 0인 항목만 보여주고, 계정 탈퇴로 이미 `custom_items` 레코드가 삭제된 항목이 따로 남지 않는지 확인한다.
|
||||
- `v1.4.56`에서 아이템 관리 기본 필터를 `아이템만 (템플릿+사용자)`로 바꿨으므로, 관리자 화면 첫 진입 시 프로필 아바타/티어표 썸네일 같은 1회성 자산이 기본 목록에서 빠지고 실제 템플릿/사용자 아이템만 보이는지 확인한다.
|
||||
- 보관 이미지 자산도 이름 변경, 템플릿에 추가, 개별 삭제가 기존처럼 동작하는지 확인한다.
|
||||
- 아이템 관리 상단 통계의 `미사용 아이템` 수치는 프로필/썸네일 자산을 포함하지 않고, 실제 사용자 아이템 중 사용 횟수와 템플릿 연결이 모두 0인 항목만 세는지 확인한다.
|
||||
- `v1.4.55`에서 회원 카드의 `최근 활동`을 `최근 콘텐츠 활동`으로 바꾸고 `마지막 접속일`을 따로 추가했으므로, 티어표를 수정하지 않고 로그인만 한 계정은 마지막 접속일만 갱신되고 최근 콘텐츠 활동은 유지되는지, 반대로 로그인 없이 과거 티어표만 있던 계정은 두 값이 다르게 보이는지 QA한다.
|
||||
- `/api/auth/me`에서도 `last_login_at`을 10분 단위 이상 간격으로만 갱신하도록 넣었으므로, 새로고침을 반복해도 과도한 DB 쓰기가 생기지 않으면서 실제 재접속 후에는 마지막 접속일이 자연스럽게 갱신되는지 확인한다.
|
||||
- 관리자 회원 목록의 `마지막 접속순` 정렬과 회원 카드의 `프로필 보기` 버튼이 정상 동작하고, 버튼 클릭 시 해당 회원의 `/users/:userId` 공개 프로필 화면으로 이동하는지 확인한다.
|
||||
- `v1.4.54`에서 관리자 전체 티어표 카드에 즐겨찾기 수와 인기순 정렬/최소 즐겨찾기 필터를 붙였으므로, 즐겨찾기 많은 순으로 바꿨을 때 실제 받은 즐겨찾기 수가 큰 글부터 보이고 최소값을 올리면 추천 후보만 남는지 확인한다.
|
||||
- 관리자 전체 티어표 통계 카드도 최소 즐겨찾기 필터가 적용된 범위 기준으로 `전체/추천/공개/비공개` 숫자가 바뀌는지 QA한다.
|
||||
- `v1.4.54`에서 회원 관리 카드에 팔로워 수와 받은 즐겨찾기 수를 추가했으므로, 팔로워 많은 순/받은 즐겨찾기 많은 순 정렬이 실제 운영 데이터 순서와 맞고 최고 관리자 보호 로직도 그대로 유지되는지 확인한다.
|
||||
- 관리자 회원 카드에서 `비밀번호 초기화` 버튼과 모달을 숨겼으므로, 일반 운영 동선에서는 비밀번호 직접 조작 UI가 보이지 않고 기존 회원 정보 수정/삭제/썸네일 변경은 그대로 동작하는지 확인한다.
|
||||
- `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한다.
|
||||
@@ -132,6 +157,8 @@
|
||||
- 아이템 관리의 같은 이미지 참조 요약과 상세 모달은 붙였으므로, 실제 운영 데이터에서 카드 수치와 모달 목록이 기대한 참조 관계와 맞는지 한 번 더 QA한다.
|
||||
|
||||
## 중기 개선
|
||||
- 목록 카드의 작성자 메타를 카드 전체 열기 버튼과 충돌 없이 직접 프로필 링크로 분리하는 후속 UX를 검토한다.
|
||||
- 추천 티어표는 전체 누적 즐겨찾기 기준 정렬/필터부터 붙였으므로, 다음 단계에서는 최근 N일 기준 급상승 추천 후보 필터와 추천 섹션 노출 개수 설정을 관리자 화면에 추가할지 검토한다.
|
||||
- 이메일 인증/비밀번호 재설정 1차 구현이 들어갔으므로, 다음 단계에서는 Gmail 발신 기반이 실제 운영에서 스팸함으로 얼마나 가는지 보고 필요하면 Cloudflare DNS의 SPF/DKIM/DMARC와 도메인 발신 주소 전환을 정리한다.
|
||||
- 구글 계정 로그인은 아직 붙이지 않았으므로, 이메일 인증 안정화 후 Google OAuth 클라이언트를 만들고 일반 이메일 계정과 같은 이메일의 구글 계정을 자동 연결할지 정책을 먼저 결정한다.
|
||||
- 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다.
|
||||
@@ -147,7 +174,7 @@
|
||||
- 라이트모드/다크모드 2차 보정까지 반영했으므로, 남은 작업은 전체 화면을 실제 사용 흐름으로 돌려 보며 대비·명도·아이콘 가독성을 미세하게 QA하는 최종 테마 점검 단계로 가져간다.
|
||||
- 라이트모드 공통 토큰 재정비와 카드/아바타/즐겨찾기 버튼 보정까지 반영했으므로, 다음 QA에서는 로그인/홈/주제 허브/에디터/관리자 순으로 실제 플로우를 돌리며 남은 하드코딩 색과 과한 대비가 없는지 확인한다.
|
||||
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
|
||||
- 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다.
|
||||
- 회원 일괄 작업(다중 선택, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다. 비밀번호는 평소 운영자가 직접 덮어쓰기보다 이메일 재설정 흐름을 우선하므로, 관리자 일괄 비밀번호 초기화는 별도 긴급 대응 정책이 생긴 뒤에만 다시 검토한다.
|
||||
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
|
||||
- 로그인/회원가입/관리자 비밀번호 초기화에 요청 횟수 제한을 추가한다.
|
||||
- 업로드 파일은 MIME 타입뿐 아니라 파일 시그니처 기반 검증까지 확장한다.
|
||||
|
||||
@@ -1,5 +1,53 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-03 v1.4.59
|
||||
- 최근 업로드된 최적화 이미지가 `/uploads/assets/<파일명>.webp`처럼 하위 폴더 없이 저장되면서, `썸네일 이미지 / 프로필 이미지` 필터가 경로 문자열만으로 자산 종류를 판별하지 못해 비어 보일 수 있던 문제를 고쳤다.
|
||||
- 관리자 아이템 목록 생성 시 `users.avatar_src`, `topics.thumbnail_src`, `tierlists.thumbnail_src`, `template_requests.thumbnail_src_snapshot`을 역으로 모아 해당 `src`가 프로필 이미지인지 썸네일 이미지인지 먼저 판별하고, `thumbnail/avatar` 필터는 `sourceType`이 아니라 이 실제 참조 역할(`assetKind`) 기준으로 걸리도록 보정했다.
|
||||
- 신규 최적화 이미지 저장은 한 폴더에 무한정 쌓이지 않도록 파일 ID 앞 2글자 기준으로 `/uploads/assets/ab/<파일명>.webp`처럼 1단계 샤딩 디렉터리를 사용하게 바꿨다. 기존에 이미 저장된 `/uploads/assets/<파일명>.webp` 평면 경로는 그대로 유지해 과거 이미지 링크가 깨지지 않게 했다.
|
||||
|
||||
## 2026-04-03 v1.4.58
|
||||
- 작성자 프로필 화면 상단 헤더가 `Author + 닉네임 + @accountName`을 다시 보여주면서, 바로 아래 프로필 카드의 아바타/닉네임 정보와 거의 같은 내용이 반복되던 구성을 정리했다.
|
||||
- 상단 헤더는 공통 제목 `사용자 프로필`과 안내 문구로 바꾸고, 실제 닉네임은 아래 프로필 카드에서만 보여주도록 나눠 화면의 정보 역할이 겹치지 않게 했다.
|
||||
- 이메일 앞부분에서 파생된 `@accountName`은 사용자가 직접 설정한 핸들이 아니라서 오히려 “내가 입력한 적 없는 계정명”처럼 느껴질 수 있으므로, 프로필 화면의 시각 노출에서는 제거했다.
|
||||
|
||||
## 2026-04-03 v1.4.57
|
||||
- 관리자 아이템 관리 필터를 `전체 이미지 → 아이템(템플릿 + 사용자) → 템플릿 아이템 → 사용자 아이템 → 썸네일 이미지 → 프로필 이미지 → 미사용 아이템` 순서로 다시 정리해, 전체 조회와 실제 아이템 검수 흐름이 더 직관적으로 이어지게 맞췄다.
|
||||
- 기본 필터는 계속 `아이템(템플릿 + 사용자)`를 유지하되, 썸네일과 프로필 이미지는 각각 `filter=thumbnail`, `filter=avatar`로 분리 조회할 수 있게 백엔드 필터 enum과 자산 분류 값을 확장했다.
|
||||
- 보관 자산 배지 문구도 `/uploads/assets/topics/` 경로는 `썸네일 이미지`, 사용자 업로드 항목은 `사용자 아이템`, 템플릿 기본 항목은 `템플릿 아이템`으로 맞춰 `관리자 템플릿`처럼 실제 의미와 어긋나는 표현이 남지 않도록 정리했다.
|
||||
- `미사용 아이템`은 계정 탈퇴로 같이 삭제된 항목이 아니라, 사용자 아이템 레코드는 남아 있지만 저장 티어표/템플릿에서 더 이상 참조하지 않는 항목이라는 의미가 드러나도록 통계 라벨과 일괄 삭제 버튼 문구를 다시 정돈했다.
|
||||
|
||||
## 2026-04-03 v1.4.56
|
||||
- 관리자 아이템 관리에서 `/uploads/assets/...` 아래의 보관 이미지가 템플릿 기본 아이템이 아닌데도 모두 `관리자 템플릿` 배지로 표시되던 분류를 정리했다.
|
||||
- 보관 이미지 자산은 이제 `asset` 출처로 분리하고, 경로에 따라 `프로필 아바타`, `티어표 썸네일`, `템플릿 썸네일`, `보관 자산` 배지가 붙도록 바꿔 반복 사용 아이템과 1회성/관리용 이미지를 구분해서 볼 수 있게 했다.
|
||||
- 아이템 관리 필터 기본값을 `아이템만 (템플릿+사용자)`로 바꾸고, `썸네일·프로필 이미지`, `미사용 썸네일·프로필 이미지` 필터를 따로 제공해 기본 화면에서는 실제 아이템만 먼저 검수할 수 있게 했다.
|
||||
- 아이템 관리 상단의 `미사용` 통계가 프로필/썸네일 같은 자산까지 `usageCount=0`으로 같이 세면 잘못된 숫자처럼 보일 수 있으므로, `미사용 사용자 아이템`이라는 라벨로 바꾸고 실제 사용자 업로드 아이템 중 템플릿 연결과 사용 횟수가 모두 없는 항목만 세도록 보정했다.
|
||||
|
||||
## 2026-04-03 v1.4.55
|
||||
- 관리자 회원 카드의 `최근 활동`이 실제로는 마지막 접속이 아니라 작성 티어표의 마지막 수정 시각 기준이었으므로, 라벨을 `최근 콘텐츠 활동`으로 분명하게 바꾸고 `마지막 접속일`을 별도 줄로 추가해 두 의미를 분리했다.
|
||||
- 백엔드 `users`에 `last_login_at`을 추가하고, 로그인/이메일 인증 완료/비밀번호 재설정 완료/세션 기반 `/api/auth/me` 확인 시 해당 시각을 갱신하도록 보강했다. 기존 계정은 마이그레이션 시 `created_at`으로 1차 채워 오래된 계정도 빈 값 없이 정렬할 수 있게 했다.
|
||||
- 관리자 회원 목록 정렬에 `마지막 접속순`을 추가하고, 회원 카드의 `회원 정보 수정` 옆에 `프로필 보기` 버튼을 붙여 해당 유저의 `/users/:userId` 공개 프로필 화면으로 바로 이동할 수 있게 했다.
|
||||
|
||||
## 2026-04-03 v1.4.54
|
||||
- 관리자 `전체 티어표 관리` 카드에 받은 즐겨찾기 수를 표시하고, 우측 운영 패널에 `최근 수정순 / 최근 생성순 / 즐겨찾기 많은 순` 정렬과 `최소 즐겨찾기 수` 필터를 추가해, 운영자가 추천 후보가 될 만한 인기 티어표를 더 빨리 찾을 수 있게 했다.
|
||||
- 관리자 회원 관리 카드에 `팔로워 수`와 `받은 즐겨찾기 수`를 추가하고, 정렬 기준에도 `팔로워 많은 순`, `받은 즐겨찾기 많은 순`을 붙여 어떤 작성자가 핵심 기여자인지 운영자가 더 쉽게 파악할 수 있게 했다.
|
||||
- 이메일 인증과 비밀번호 재설정 메일이 들어간 뒤에는 운영자가 회원 비밀번호를 직접 바꾸는 버튼이 평소 화면에 드러나 있을 필요가 작다고 보고, 회원 카드의 `비밀번호 초기화` 버튼과 해당 모달 UI를 숨겼다. 서버의 관리자 비밀번호 변경 API는 비상 상황용 최후 수단으로 남겨두되, 일반 운영 동선에서는 직접 조작처럼 보이지 않도록 정리했다.
|
||||
|
||||
## 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열로 내려가도록 레이아웃을 재정리했다.
|
||||
- 왼쪽 카드는 아바타/닉네임/이메일/로그아웃/프로필 저장을, 오른쪽 카드는 현재 비밀번호 확인과 새 비밀번호 저장을 담당하게 분리해, 설정 화면의 정보 묶음이 더 명확하게 읽히도록 맞췄다.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '설정',
|
||||
|
||||
@@ -16,7 +16,15 @@ const props = defineProps({
|
||||
<div v-if="!props.customItems.length" class="hint">조건에 맞는 관리 대상 아이템이 없어요.</div>
|
||||
<div v-else class="customItemGrid">
|
||||
<button v-for="item in props.customItems" :key="item.id" type="button" class="customItemCard" @click="props.openCustomItemModal(item)">
|
||||
<span class="customItemCard__badge" :class="{ 'customItemCard__badge--template': item.sourceType === 'template' }">{{ item.sourceLabel }}</span>
|
||||
<span
|
||||
class="customItemCard__badge"
|
||||
:class="{
|
||||
'customItemCard__badge--template': item.sourceType === 'template',
|
||||
'customItemCard__badge--asset': item.sourceType === 'asset',
|
||||
}"
|
||||
>
|
||||
{{ item.sourceLabel }}
|
||||
</span>
|
||||
<img class="customItemCard__image" :src="toApiUrl(item.src)" :alt="item.label" />
|
||||
<div class="customItemCard__title" :title="item.label">{{ item.label }}</div>
|
||||
</button>
|
||||
|
||||
@@ -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,8 @@ 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 pill--soft">즐겨찾기 {{ tierList.favoriteCount || 0 }}개</span>
|
||||
<span class="pill">전체 아이템 {{ tierList.itemCount }}개</span>
|
||||
<span class="pill" :class="{ 'pill--accent': tierList.extraItemCount > 0 }">추가 아이템 {{ tierList.extraItemCount }}개</span>
|
||||
</div>
|
||||
@@ -177,6 +181,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>
|
||||
|
||||
@@ -17,14 +17,12 @@ const props = defineProps({
|
||||
removeUserAvatar: { type: Function, required: true },
|
||||
canEditUserAvatar: { type: Function, required: true },
|
||||
canEditUserInfo: { type: Function, required: true },
|
||||
canResetUserPassword: { type: Function, required: true },
|
||||
canDeleteUser: { type: Function, required: true },
|
||||
roleLabelOf: { type: Function, required: true },
|
||||
fmt: { type: Function, required: true },
|
||||
openUserPasswordModal: { type: Function, required: true },
|
||||
openUserProfile: { type: Function, required: true },
|
||||
openUserDeleteModal: { type: Function, required: true },
|
||||
openUserEditModal: { type: Function, required: true },
|
||||
lockResetIcon: { type: String, required: true },
|
||||
deleteIcon: { type: String, required: true },
|
||||
})
|
||||
|
||||
@@ -49,16 +47,19 @@ const userSortDirectionModel = computed({
|
||||
<div class="sectionHeader">
|
||||
<div>
|
||||
<div class="panel__title">회원 관리</div>
|
||||
<div class="hint hint--tight">회원 프로필을 정리하고, 필요한 경우에만 권한 변경과 비밀번호 초기화를 진행할 수 있어요.</div>
|
||||
<div class="hint hint--tight">팔로워·즐겨찾기 지표로 핵심 작성자를 확인하고, 회원 정보와 권한만 최소한으로 관리해요.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar toolbar--secondary">
|
||||
<input v-model="userQueryModel" class="input toolbar__search" placeholder="이메일, 닉네임 검색" @keydown.enter.prevent="props.submitUserFilters" />
|
||||
<select v-model="userSortModel" class="select toolbar__select" @change="props.submitUserFilters">
|
||||
<option value="recent">최근 활동순</option>
|
||||
<option value="recent">최근 콘텐츠 활동순</option>
|
||||
<option value="lastLogin">마지막 접속순</option>
|
||||
<option value="created">가입순</option>
|
||||
<option value="tierlists">작성 티어표 많은 순</option>
|
||||
<option value="followers">팔로워 많은 순</option>
|
||||
<option value="favorites">받은 즐겨찾기 많은 순</option>
|
||||
</select>
|
||||
<select v-model="userSortDirectionModel" class="select toolbar__select" @change="props.submitUserFilters">
|
||||
<option value="desc">내림차순</option>
|
||||
@@ -113,22 +114,16 @@ const userSortDirectionModel = computed({
|
||||
<div class="userInfoList">
|
||||
<div class="userInfoLine"><span>가입일</span><strong>{{ props.fmt(user.createdAt) }}</strong></div>
|
||||
<div class="userInfoLine"><span>작성 티어표</span><strong>{{ user.tierListCount }}개</strong></div>
|
||||
<div class="userInfoLine"><span>최근 활동</span><strong>{{ props.fmt(user.recentActivityAt || user.createdAt) }}</strong></div>
|
||||
<div class="userInfoLine"><span>팔로워</span><strong>{{ user.followerCount || 0 }}명</strong></div>
|
||||
<div class="userInfoLine"><span>받은 즐겨찾기</span><strong>{{ user.receivedFavoriteCount || 0 }}개</strong></div>
|
||||
<div class="userInfoLine"><span>최근 콘텐츠 활동</span><strong>{{ props.fmt(user.recentActivityAt || user.createdAt) }}</strong></div>
|
||||
<div class="userInfoLine"><span>마지막 접속일</span><strong>{{ user.lastLoginAt ? props.fmt(user.lastLoginAt) : '기록 없음' }}</strong></div>
|
||||
<div class="userInfoLine"><span>계정명</span><strong>{{ user.email }}</strong></div>
|
||||
<div class="userInfoLine"><span>닉네임</span><strong>{{ user.nickname || '미설정' }}</strong></div>
|
||||
<div class="userInfoLine"><span>권한</span><strong>{{ props.roleLabelOf(user) }}</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="userCard__actions userCard__actions--compact">
|
||||
<button
|
||||
class="iconActionButton"
|
||||
type="button"
|
||||
title="비밀번호 초기화"
|
||||
:disabled="!props.canResetUserPassword(user)"
|
||||
@click="props.openUserPasswordModal(user)"
|
||||
>
|
||||
<SvgIcon class="iconActionButton__icon" :src="props.lockResetIcon" :size="18" />
|
||||
</button>
|
||||
<button
|
||||
class="iconActionButton iconActionButton--danger"
|
||||
type="button"
|
||||
@@ -146,6 +141,13 @@ const userSortDirectionModel = computed({
|
||||
>
|
||||
회원 정보 수정
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--ghost userSaveButton"
|
||||
type="button"
|
||||
@click="props.openUserProfile(user)"
|
||||
>
|
||||
프로필 보기
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -119,9 +119,19 @@ export function useAdminCustomItems({
|
||||
closeCustomItemDeleteModal()
|
||||
closeCustomItemModal()
|
||||
await refreshCustomItems()
|
||||
success.value = item.sourceType === 'template' ? '선택한 템플릿 아이템을 제거했어요.' : '사용자 업로드 이미지를 삭제했어요.'
|
||||
success.value =
|
||||
item.sourceType === 'template'
|
||||
? '선택한 템플릿 아이템을 제거했어요.'
|
||||
: item.sourceType === 'asset'
|
||||
? '선택한 이미지 자산을 삭제했어요.'
|
||||
: '사용자 업로드 이미지를 삭제했어요.'
|
||||
} catch (e) {
|
||||
error.value = item.sourceType === 'template' ? '템플릿 아이템 제거에 실패했어요.' : '사용자 업로드 이미지 삭제에 실패했어요.'
|
||||
error.value =
|
||||
item.sourceType === 'template'
|
||||
? '템플릿 아이템 제거에 실패했어요.'
|
||||
: item.sourceType === 'asset'
|
||||
? '이미지 자산 삭제에 실패했어요.'
|
||||
: '사용자 업로드 이미지 삭제에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,12 +81,14 @@ export const api = {
|
||||
request(
|
||||
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}`
|
||||
),
|
||||
listAdminTierLists: ({ q = '', topicId = '', page = 1, limit = 50 } = {}) =>
|
||||
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
|
||||
getAdminTierListStats: ({ q = '', topicId = '' } = {}) =>
|
||||
request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId)}`),
|
||||
listAdminTierLists: ({ q = '', topicId = '', sort = 'recent', minFavorites = 0, page = 1, limit = 50 } = {}) =>
|
||||
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId)}&sort=${encodeURIComponent(sort)}&minFavorites=${encodeURIComponent(minFavorites)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
|
||||
getAdminTierListStats: ({ q = '', topicId = '', minFavorites = 0 } = {}) =>
|
||||
request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId)}&minFavorites=${encodeURIComponent(minFavorites)}`),
|
||||
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'),
|
||||
@@ -153,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' }),
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
import { Teleport, computed, inject, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { editorPath } from '../lib/paths'
|
||||
import { editorPath, userProfilePath } from '../lib/paths'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import lockResetIcon from '../assets/icons/lock_reset.svg'
|
||||
import deleteIcon from '../assets/icons/delete.svg'
|
||||
import SvgIcon from '../components/SvgIcon.vue'
|
||||
import AdminFeaturedSection from '../components/admin/AdminFeaturedSection.vue'
|
||||
@@ -45,16 +44,18 @@ const customItemQuery = ref('')
|
||||
const customItemPage = ref(1)
|
||||
const customItemLimit = ref(50)
|
||||
const customItemTotal = ref(0)
|
||||
const customItemFilter = ref('all')
|
||||
const customItemFilter = ref('library')
|
||||
const customItemModalTargetTemplateId = ref('')
|
||||
|
||||
const adminTierLists = ref([])
|
||||
const adminTierListQuery = ref('')
|
||||
const adminTierListTopicId = ref('')
|
||||
const adminTierListSort = ref('recent')
|
||||
const adminTierListMinFavorites = ref(0)
|
||||
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)
|
||||
@@ -230,7 +231,7 @@ const activeTabDescription = computed(() => {
|
||||
return '템플릿 생성, 선택, 썸네일, 기본 아이템 관리를 전용 작업 화면으로 분리했습니다.'
|
||||
}
|
||||
if (activeTab.value === 'items') {
|
||||
return '사용자 업로드와 관리자 템플릿 이미지를 함께 검수하고, 필요한 템플릿에 직접 연결할 수 있어요.'
|
||||
return '사용자 아이템과 템플릿 아이템을 함께 검수하고, 필요한 템플릿에 직접 연결할 수 있어요.'
|
||||
}
|
||||
if (activeTab.value === 'tierlists') {
|
||||
return tierlistsMode.value === 'requests'
|
||||
@@ -241,7 +242,12 @@ const activeTabDescription = computed(() => {
|
||||
})
|
||||
const adminOverviewStats = computed(() => {
|
||||
const pendingRequests = templateRequests.value.length
|
||||
const orphanItems = customItems.value.filter((item) => item.usageCount === 0).length
|
||||
const orphanItems = customItems.value.filter(
|
||||
(item) =>
|
||||
item.sourceType === 'user' &&
|
||||
Number(item.usageCount || 0) === 0 &&
|
||||
!(Array.isArray(item.linkedTemplates) && item.linkedTemplates.length > 0)
|
||||
).length
|
||||
const adminCount = users.value.filter((user) => user.isAdmin).length
|
||||
|
||||
if (activeTab.value === 'featured') {
|
||||
@@ -263,8 +269,9 @@ const adminOverviewStats = computed(() => {
|
||||
if (activeTab.value === 'items') {
|
||||
return [
|
||||
{ label: '검색 결과', value: `${customItemTotal.value}` },
|
||||
{ label: '미사용', value: `${orphanItems}` },
|
||||
{ label: '미사용 아이템', value: `${orphanItems}` },
|
||||
{ label: '템플릿 아이템', value: `${customItems.value.filter((item) => item.sourceType === 'template').length}` },
|
||||
{ label: '이미지 자산', value: `${customItems.value.filter((item) => item.sourceType === 'asset').length}` },
|
||||
]
|
||||
}
|
||||
if (activeTab.value === 'tierlists') {
|
||||
@@ -277,6 +284,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}` },
|
||||
@@ -488,7 +496,7 @@ watch(
|
||||
|
||||
if (tab === 'items') {
|
||||
customItemQuery.value = ''
|
||||
customItemFilter.value = 'all'
|
||||
customItemFilter.value = 'library'
|
||||
customItemPage.value = 1
|
||||
await refreshCustomItems()
|
||||
return
|
||||
@@ -597,13 +605,14 @@ function formatImageJobStatus(status) {
|
||||
|
||||
function customItemDeleteImpactText(item) {
|
||||
if (!item) return ''
|
||||
if (item.sourceType === 'asset' || item.isAssetLibraryItem) {
|
||||
return `"${item.label}" ${item.sourceLabel || '이미지 자산'} 항목을 정리할까요? 라이브러리 항목만 제거되고, 같은 이미지를 쓰는 다른 참조는 그대로 유지됩니다.`
|
||||
}
|
||||
if (item.sourceType === 'template') {
|
||||
return item.isAssetLibraryItem
|
||||
? `"${item.label}" 보관 자산 항목을 정리할까요? 라이브러리 항목만 제거되고, 같은 이미지를 쓰는 다른 참조는 그대로 유지됩니다.`
|
||||
: `"${item.label}" 템플릿 항목을 정리할까요? 연결된 템플릿과 같은 주제의 저장된 티어표에서 이 항목이 함께 제거될 수 있어요.`
|
||||
return `"${item.label}" 템플릿 항목을 정리할까요? 연결된 템플릿과 같은 주제의 저장된 티어표에서 이 항목이 함께 제거될 수 있어요.`
|
||||
}
|
||||
|
||||
return `"${item.label}" 사용자 업로드 이미지를 삭제할까요? 현재 항목만 정리됩니다.`
|
||||
return `"${item.label}" 사용자 아이템을 삭제할까요? 현재 항목만 정리됩니다.`
|
||||
}
|
||||
|
||||
const imageDiagnosticsCards = computed(() => {
|
||||
@@ -745,7 +754,7 @@ function setTab(tab) {
|
||||
}
|
||||
if (tab === 'items') {
|
||||
customItemQuery.value = ''
|
||||
customItemFilter.value = 'all'
|
||||
customItemFilter.value = 'library'
|
||||
customItemPage.value = 1
|
||||
refreshCustomItems()
|
||||
}
|
||||
@@ -823,6 +832,8 @@ async function refreshAdminTierLists() {
|
||||
const data = await api.listAdminTierLists({
|
||||
q: adminTierListQuery.value,
|
||||
topicId: adminTierListTopicId.value,
|
||||
sort: adminTierListSort.value,
|
||||
minFavorites: adminTierListMinFavorites.value,
|
||||
page: adminTierListPage.value,
|
||||
limit: adminTierListLimit.value,
|
||||
})
|
||||
@@ -839,14 +850,19 @@ async function refreshAdminTierLists() {
|
||||
async function refreshAdminTierListStats() {
|
||||
if (!auth.user?.isAdmin) return
|
||||
try {
|
||||
const data = await api.getAdminTierListStats({ q: adminTierListQuery.value, topicId: adminTierListTopicId.value })
|
||||
const data = await api.getAdminTierListStats({
|
||||
q: adminTierListQuery.value,
|
||||
topicId: adminTierListTopicId.value,
|
||||
minFavorites: adminTierListMinFavorites.value,
|
||||
})
|
||||
adminTierListStats.value = {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1310,6 +1326,17 @@ function submitAdminTierListSearch() {
|
||||
refreshAdminTierLists()
|
||||
}
|
||||
|
||||
function changeAdminTierListSort() {
|
||||
adminTierListPage.value = 1
|
||||
refreshAdminTierLists()
|
||||
}
|
||||
|
||||
function changeAdminTierListMinFavorites() {
|
||||
adminTierListMinFavorites.value = Math.max(Number(adminTierListMinFavorites.value) || 0, 0)
|
||||
adminTierListPage.value = 1
|
||||
refreshAdminTierLists()
|
||||
}
|
||||
|
||||
function setAdminTierListTopicId(topicId) {
|
||||
adminTierListTopicId.value = topicId || ''
|
||||
adminTierListPage.value = 1
|
||||
@@ -1369,7 +1396,7 @@ function buildModalItemFromTierListItem(item, tierList) {
|
||||
id,
|
||||
label: item?.label || matchedItem?.label || '이름 없음',
|
||||
src: item?.src || matchedItem?.src || '',
|
||||
sourceType: matchedItem?.sourceType || (String(id).startsWith('asset:') ? 'template' : 'user'),
|
||||
sourceType: matchedItem?.sourceType || (String(id).startsWith('asset:') ? 'asset' : 'user'),
|
||||
sourceLabel: matchedItem?.sourceLabel || '티어표 추가 아이템',
|
||||
ownerName: matchedItem?.ownerName || tierListAuthorDisplayName(tierList),
|
||||
linkedTemplates: Array.isArray(matchedItem?.linkedTemplates) ? matchedItem.linkedTemplates : [],
|
||||
@@ -1472,6 +1499,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
|
||||
@@ -1667,6 +1715,11 @@ function userAvatarFallback(user) {
|
||||
return (user?.email?.trim()?.[0] || '?').toUpperCase()
|
||||
}
|
||||
|
||||
function openUserProfile(user) {
|
||||
if (!user?.id) return
|
||||
router.push(userProfilePath(user.id))
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -1782,6 +1835,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"
|
||||
/>
|
||||
|
||||
@@ -1801,14 +1855,12 @@ function userAvatarFallback(user) {
|
||||
:remove-user-avatar="removeUserAvatar"
|
||||
:can-edit-user-avatar="canEditUserAvatar"
|
||||
:can-edit-user-info="canEditUserInfo"
|
||||
:can-reset-user-password="canResetUserPassword"
|
||||
:can-delete-user="canDeleteUser"
|
||||
:role-label-of="roleLabelOf"
|
||||
:fmt="fmt"
|
||||
:open-user-password-modal="openUserPasswordModal"
|
||||
:open-user-profile="openUserProfile"
|
||||
:open-user-delete-modal="openUserDeleteModal"
|
||||
:open-user-edit-modal="openUserEditModal"
|
||||
:lock-reset-icon="lockResetIcon"
|
||||
:delete-icon="deleteIcon"
|
||||
@update:user-query="userQuery = $event"
|
||||
@update:user-sort="userSort = $event"
|
||||
@@ -1882,31 +1934,6 @@ function userAvatarFallback(user) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="userPasswordModalOpen" class="modalOverlay" @click.self="closeUserPasswordModal">
|
||||
<div class="modalCard" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__title">비밀번호 초기화</div>
|
||||
<div class="modalCard__desc">{{ modalTargetUser ? `${userDisplayName(modalTargetUser)} 계정에 설정할 새 비밀번호를 입력해주세요.` : '' }}</div>
|
||||
<div class="modalCard__form">
|
||||
<label class="field">
|
||||
<span class="field__label">새 비밀번호</span>
|
||||
<input
|
||||
v-model="modalPasswordDraft"
|
||||
class="field__input"
|
||||
type="password"
|
||||
maxlength="120"
|
||||
placeholder="초기화할 비밀번호 입력"
|
||||
@keydown.enter.prevent="confirmUserPasswordReset"
|
||||
/>
|
||||
<span class="field__hint">6~120자 권장 · {{ modalPasswordDraft.length }}/120자</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--ghost" @click="closeUserPasswordModal">취소</button>
|
||||
<button class="btn btn--primary" :disabled="!modalPasswordDraft.trim()" @click="confirmUserPasswordReset">초기화</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="userDeleteModalOpen" class="modalOverlay" @click.self="closeUserDeleteModal">
|
||||
<div class="modalCard" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__title">회원 삭제</div>
|
||||
@@ -2270,16 +2297,17 @@ function userAvatarFallback(user) {
|
||||
</select>
|
||||
<select :value="customItemFilter" class="select" @change="changeCustomItemFilter($event.target.value)">
|
||||
<option value="all">전체 이미지</option>
|
||||
<option value="user">사용자 업로드</option>
|
||||
<option value="template">템플릿 사용 이미지</option>
|
||||
<option value="asset">관리자 보관 자산</option>
|
||||
<option value="unused-user">미사용 사용자 업로드</option>
|
||||
<option value="unused-admin">미사용 관리자 자산</option>
|
||||
<option value="library">아이템(템플릿 + 사용자)</option>
|
||||
<option value="template">템플릿 아이템</option>
|
||||
<option value="user">사용자 아이템</option>
|
||||
<option value="thumbnail">썸네일 이미지</option>
|
||||
<option value="avatar">프로필 이미지</option>
|
||||
<option value="unused-user">미사용 아이템</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="adminSidebar__actions">
|
||||
<button class="btn btn--ghost" @click="refreshCustomItems">새로고침</button>
|
||||
<button class="btn btn--danger" :disabled="customItemFilter !== 'unused-user' || !customItems.length" @click="removeUnusedCustomItems">미사용 사용자 이미지 일괄 삭제</button>
|
||||
<button class="btn btn--danger" :disabled="customItemFilter !== 'unused-user' || !customItems.length" @click="removeUnusedCustomItems">미사용 아이템 일괄 삭제</button>
|
||||
</div>
|
||||
<div class="adminSidebar__stats">
|
||||
<div class="sidebarStat">
|
||||
@@ -2326,6 +2354,21 @@ function userAvatarFallback(user) {
|
||||
<option :value="50">50개씩 보기</option>
|
||||
<option :value="200">200개씩 보기</option>
|
||||
</select>
|
||||
<select v-model="adminTierListSort" class="select" @change="changeAdminTierListSort">
|
||||
<option value="recent">최근 수정순</option>
|
||||
<option value="created">최근 생성순</option>
|
||||
<option value="favorites">즐겨찾기 많은 순</option>
|
||||
</select>
|
||||
<input
|
||||
v-model.number="adminTierListMinFavorites"
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1000000"
|
||||
placeholder="최소 즐겨찾기 수"
|
||||
@change="changeAdminTierListMinFavorites"
|
||||
@keydown.enter.prevent="changeAdminTierListMinFavorites"
|
||||
/>
|
||||
</div>
|
||||
<div class="adminSidebar__actions">
|
||||
<button class="btn btn--ghost" @click="refreshAdminTierLists">새로고침</button>
|
||||
@@ -3445,6 +3488,9 @@ function userAvatarFallback(user) {
|
||||
.adminUiScope .customItemCard__badge--template {
|
||||
background: rgba(96, 165, 250, 0.18);
|
||||
}
|
||||
.adminUiScope .customItemCard__badge--asset {
|
||||
background: rgba(251, 191, 36, 0.18);
|
||||
}
|
||||
.adminUiScope .customItemCard:hover {
|
||||
border-color: rgba(126, 162, 255, 0.42);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
|
||||
328
frontend/src/views/FollowingFeedView.vue
Normal file
328
frontend/src/views/FollowingFeedView.vue
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
466
frontend/src/views/UserProfileView.vue
Normal file
466
frontend/src/views/UserProfileView.vue
Normal file
@@ -0,0 +1,466 @@
|
||||
<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">User Profile</div>
|
||||
<h2 class="pageHead__title">사용자 프로필</h2>
|
||||
<div class="pageHead__desc">
|
||||
이 사용자가 공개한 티어표를 모아볼 수 있어요.
|
||||
</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>
|
||||
</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;
|
||||
}
|
||||
.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>
|
||||
Reference in New Issue
Block a user