Compare commits

..

13 Commits

33 changed files with 3171 additions and 277 deletions

View File

@@ -3,3 +3,10 @@ MARIADB_DATABASE=tier_cursor
MARIADB_USER=tier_cursor
MARIADB_PASSWORD=change-this-db-password
SESSION_SECRET=change-this-session-secret
APP_ORIGIN=https://tmaker.sori.studio
SMTP_HOST=smtp.gmail.com
SMTP_PORT=465
SMTP_SECURE=true
SMTP_USER=your-gmail-account@gmail.com
SMTP_PASS=change-this-gmail-app-password
SMTP_FROM="Tier Maker <your-gmail-account@gmail.com>"

View File

@@ -1,14 +1,18 @@
const path = require('path')
const fs = require('fs')
const dotenv = require('dotenv')
const express = require('express')
const cors = require('cors')
const session = require('express-session')
const FileStoreFactory = require('session-file-store')
dotenv.config({ path: path.join(__dirname, '..', '.env.production') })
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()
@@ -82,6 +86,7 @@ app.use(async (req, res, next) => {
app.use('/api/auth', authRoutes)
app.use('/api/topics', topicsRoutes)
app.use('/api/tierlists', tierListsRoutes)
app.use('/api/users', usersRoutes)
app.use('/api/admin', adminRoutes)
app.listen(PORT, () => {

View File

@@ -11,11 +11,13 @@
"dependencies": {
"bcryptjs": "^3.0.3",
"cors": "^2.8.6",
"dotenv": "^17.4.0",
"express": "^5.2.1",
"express-session": "^1.19.0",
"multer": "^2.1.1",
"mysql2": "^3.20.0",
"nanoid": "^5.1.7",
"nodemailer": "^8.0.4",
"session-file-store": "^1.5.0",
"sharp": "^0.34.5",
"zod": "^4.3.6"
@@ -853,6 +855,18 @@
"node": ">=8"
}
},
"node_modules/dotenv": {
"version": "17.4.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.0.tgz",
"integrity": "sha512-kCKF62fwtzwYm0IGBNjRUjtJgMfGapII+FslMHIjMR5KTnwEmBmWLDRSnc3XSNP8bNy34tekgQyDT0hr7pERRQ==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1594,6 +1608,15 @@
"node": ">= 0.6"
}
},
"node_modules/nodemailer": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": {
"version": "3.1.14",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",

View File

@@ -4,8 +4,8 @@
"description": "",
"main": "index.js",
"scripts": {
"dev": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor nodemon --legacy-watch --watch index.js --watch src index.js",
"start": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node index.js",
"dev": "APP_ORIGIN=http://localhost:5173 DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor nodemon --legacy-watch --watch index.js --watch src index.js",
"start": "APP_ORIGIN=http://localhost:5173 DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node index.js",
"images:backfill": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/backfill-legacy-image-assets.js",
"images:migrate-legacy": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/migrate-legacy-uploads-to-assets.js",
"uploads:cleanup-legacy": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/cleanup-unreferenced-legacy-uploads.js"
@@ -17,11 +17,13 @@
"dependencies": {
"bcryptjs": "^3.0.3",
"cors": "^2.8.6",
"dotenv": "^17.4.0",
"express": "^5.2.1",
"express-session": "^1.19.0",
"multer": "^2.1.1",
"mysql2": "^3.20.0",
"nanoid": "^5.1.7",
"nodemailer": "^8.0.4",
"session-file-store": "^1.5.0",
"sharp": "^0.34.5",
"zod": "^4.3.6"

View File

@@ -59,10 +59,13 @@ function mapUserRow(row) {
id: row.id,
email: row.email,
nickname: row.nickname || '',
emailVerified: row.email_verified == null ? true : !!row.email_verified,
isAdmin: !!row.is_admin,
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),
recentActivityAt: Number(row.recent_activity_at || row.created_at || 0),
}
}
@@ -139,6 +142,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 || '',
@@ -275,12 +281,47 @@ async function ensureSchema() {
email VARCHAR(255) NOT NULL UNIQUE,
nickname VARCHAR(80) NOT NULL DEFAULT '',
password_hash VARCHAR(255) NOT NULL,
email_verified TINYINT(1) NOT NULL DEFAULT 1,
is_admin TINYINT(1) NOT NULL DEFAULT 0,
avatar_src VARCHAR(255) NOT NULL DEFAULT '',
created_at BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const userEmailVerifiedColumns = await query("SHOW COLUMNS FROM users LIKE 'email_verified'")
if (!userEmailVerifiedColumns.length) {
await query('ALTER TABLE users ADD COLUMN email_verified TINYINT(1) NOT NULL DEFAULT 1 AFTER password_hash')
await query('UPDATE users SET email_verified = 1 WHERE email_verified IS NULL')
}
await query(`
CREATE TABLE IF NOT EXISTS email_verification_tokens (
id VARCHAR(64) PRIMARY KEY,
user_id VARCHAR(64) NOT NULL,
token_hash CHAR(64) NOT NULL UNIQUE,
expires_at BIGINT NOT NULL,
consumed_at BIGINT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
INDEX idx_email_verification_user (user_id, consumed_at, expires_at),
INDEX idx_email_verification_expires (expires_at),
CONSTRAINT fk_email_verification_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await query(`
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id VARCHAR(64) PRIMARY KEY,
user_id VARCHAR(64) NOT NULL,
token_hash CHAR(64) NOT NULL UNIQUE,
expires_at BIGINT NOT NULL,
consumed_at BIGINT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
INDEX idx_password_reset_user (user_id, consumed_at, expires_at),
INDEX idx_password_reset_expires (expires_at),
CONSTRAINT fk_password_reset_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await query(`
CREATE TABLE IF NOT EXISTS topics (
id VARCHAR(120) PRIMARY KEY,
@@ -342,6 +383,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,
@@ -354,6 +398,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
@@ -383,6 +428,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,
@@ -489,6 +546,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")
@@ -567,7 +636,7 @@ async function countUsers() {
async function findUserByEmail(email) {
const rows = await query(
'SELECT id, email, nickname, password_hash, is_admin, avatar_src, created_at FROM users WHERE email = ? LIMIT 1',
'SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at FROM users WHERE email = ? LIMIT 1',
[email]
)
const row = rows[0]
@@ -581,7 +650,7 @@ async function findUserByNickname(nickname, excludeUserId = '') {
const rows = excludeUserId
? await query(
`
SELECT id, email, nickname, password_hash, is_admin, avatar_src, created_at
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at
FROM users
WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?)) AND id <> ?
LIMIT 1
@@ -590,7 +659,7 @@ async function findUserByNickname(nickname, excludeUserId = '') {
)
: await query(
`
SELECT id, email, nickname, password_hash, is_admin, avatar_src, created_at
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at
FROM users
WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?))
LIMIT 1
@@ -604,24 +673,138 @@ async function findUserByNickname(nickname, excludeUserId = '') {
async function findUserById(id) {
const rows = await query(
'SELECT id, email, nickname, is_admin, avatar_src, created_at FROM users WHERE id = ? LIMIT 1',
'SELECT id, email, nickname, email_verified, is_admin, avatar_src, created_at FROM users WHERE id = ? LIMIT 1',
[id]
)
return mapUserRow(rows[0])
}
async function createUser({ id, email, nickname, passwordHash, isAdmin }) {
async function createUser({ id, email, nickname, passwordHash, emailVerified = true, isAdmin }) {
const createdAt = now()
await query(
`
INSERT INTO users (id, email, nickname, password_hash, is_admin, avatar_src, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
INSERT INTO users (id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
[id, email, nickname || '', passwordHash, isAdmin ? 1 : 0, '', createdAt]
[id, email, nickname || '', passwordHash, emailVerified ? 1 : 0, isAdmin ? 1 : 0, '', createdAt]
)
return findUserById(id)
}
async function updateUserPassword({ id, passwordHash }) {
await query('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, id])
return findUserById(id)
}
async function verifyUserEmail(userId) {
await query('UPDATE users SET email_verified = 1 WHERE id = ?', [userId])
return findUserById(userId)
}
async function createEmailVerificationToken({ id, userId, tokenHash, expiresAt }) {
const createdAt = now()
await query('UPDATE email_verification_tokens SET consumed_at = ? WHERE user_id = ? AND consumed_at = 0', [createdAt, userId])
await query(
`
INSERT INTO email_verification_tokens (id, user_id, token_hash, expires_at, consumed_at, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`,
[id, userId, tokenHash, expiresAt, 0, createdAt]
)
return {
id,
userId,
tokenHash,
expiresAt,
consumedAt: 0,
createdAt,
}
}
async function findEmailVerificationTokenByHash(tokenHash) {
const rows = await query(
`
SELECT
id,
user_id,
token_hash,
expires_at,
consumed_at,
created_at
FROM email_verification_tokens
WHERE token_hash = ?
LIMIT 1
`,
[tokenHash]
)
const row = rows[0]
if (!row) return null
return {
id: row.id,
userId: row.user_id,
tokenHash: row.token_hash,
expiresAt: Number(row.expires_at || 0),
consumedAt: Number(row.consumed_at || 0),
createdAt: Number(row.created_at || 0),
}
}
async function consumeEmailVerificationToken(tokenId) {
await query('UPDATE email_verification_tokens SET consumed_at = ? WHERE id = ? AND consumed_at = 0', [now(), tokenId])
}
async function createPasswordResetToken({ id, userId, tokenHash, expiresAt }) {
const createdAt = now()
await query('UPDATE password_reset_tokens SET consumed_at = ? WHERE user_id = ? AND consumed_at = 0', [createdAt, userId])
await query(
`
INSERT INTO password_reset_tokens (id, user_id, token_hash, expires_at, consumed_at, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`,
[id, userId, tokenHash, expiresAt, 0, createdAt]
)
return {
id,
userId,
tokenHash,
expiresAt,
consumedAt: 0,
createdAt,
}
}
async function findPasswordResetTokenByHash(tokenHash) {
const rows = await query(
`
SELECT
id,
user_id,
token_hash,
expires_at,
consumed_at,
created_at
FROM password_reset_tokens
WHERE token_hash = ?
LIMIT 1
`,
[tokenHash]
)
const row = rows[0]
if (!row) return null
return {
id: row.id,
userId: row.user_id,
tokenHash: row.token_hash,
expiresAt: Number(row.expires_at || 0),
consumedAt: Number(row.consumed_at || 0),
createdAt: Number(row.created_at || 0),
}
}
async function consumePasswordResetToken(tokenId) {
await query('UPDATE password_reset_tokens SET consumed_at = ? WHERE id = ? AND consumed_at = 0', [now(), tokenId])
}
async function updateUserProfile({ id, nickname, avatarSrc }) {
if (typeof avatarSrc === 'string') {
await query('UPDATE users SET nickname = ?, avatar_src = ? WHERE id = ?', [nickname || '', avatarSrc, id])
@@ -633,7 +816,7 @@ async function updateUserProfile({ id, nickname, avatarSrc }) {
async function findPrimaryAdminUser() {
const rows = await query(
'SELECT id, email, nickname, 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, created_at FROM users WHERE is_admin = 1 ORDER BY created_at ASC, email ASC LIMIT 1'
)
return mapUserRow(rows[0])
}
@@ -658,6 +841,14 @@ 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'
: isAsc
? 'recent_activity_at ASC, u.created_at ASC, u.email ASC'
: 'recent_activity_at DESC, u.created_at ASC, u.email ASC'
@@ -668,18 +859,23 @@ async function listUsers({ queryText = '', sort = 'recent', direction = 'desc' }
u.id,
u.email,
u.nickname,
u.email_verified,
u.is_admin,
u.avatar_src,
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.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.created_at
ORDER BY ${orderBy}
`,
params
@@ -1846,6 +2042,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,
@@ -1855,8 +2054,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
)
@@ -1866,6 +2065,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,
@@ -1878,7 +2079,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' } = {}) {
@@ -1911,6 +2127,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,
@@ -1989,6 +2208,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) => {
@@ -2017,9 +2428,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
@@ -2056,6 +2476,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,
@@ -2077,7 +2500,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')
@@ -2088,23 +2511,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
@@ -2131,7 +2578,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
@@ -2140,12 +2588,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,
}
}
@@ -2161,6 +2618,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,
@@ -2369,13 +2829,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)
}
@@ -2500,7 +2985,16 @@ module.exports = {
findUserByEmail,
findUserByNickname,
findUserById,
findUserProfileById,
createUser,
updateUserPassword,
verifyUserEmail,
createEmailVerificationToken,
findEmailVerificationTokenByHash,
consumeEmailVerificationToken,
createPasswordResetToken,
findPasswordResetTokenByHash,
consumePasswordResetToken,
updateUserProfile,
findPrimaryAdminUser,
listUsers,
@@ -2545,14 +3039,19 @@ module.exports = {
listCustomItems,
findUnusedCustomItems,
listPublicTierLists,
listPublicTierListsByAuthor,
listFollowingTierLists,
listFavoriteTierLists,
listUserTierLists,
listAdminTierLists,
summarizeAdminTierLists,
findTierListById,
updateAdminTierListMeta,
updateTierListFeaturedStatus,
favoriteTopic,
unfavoriteTopic,
followUser,
unfollowUser,
favoriteTierList,
unfavoriteTierList,
deleteTierList,

113
backend/src/lib/mailer.js Normal file
View File

@@ -0,0 +1,113 @@
const nodemailer = require('nodemailer')
const SMTP_HOST = process.env.SMTP_HOST || 'smtp.gmail.com'
const SMTP_PORT = process.env.SMTP_PORT ? Number(process.env.SMTP_PORT) : 465
const SMTP_SECURE = process.env.SMTP_SECURE ? process.env.SMTP_SECURE === 'true' : SMTP_PORT === 465
const SMTP_USER = process.env.SMTP_USER || ''
const SMTP_PASS = process.env.SMTP_PASS || ''
const SMTP_FROM = process.env.SMTP_FROM || SMTP_USER
let transporterPromise = null
function isMailerConfigured() {
return !!SMTP_USER && !!SMTP_PASS && !!SMTP_FROM
}
async function getTransporter() {
if (!isMailerConfigured()) {
const error = new Error('mail_not_configured')
error.code = 'mail_not_configured'
throw error
}
if (!transporterPromise) {
transporterPromise = (async () => {
const transporter = nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: SMTP_SECURE,
auth: {
user: SMTP_USER,
pass: SMTP_PASS,
},
})
await transporter.verify()
return transporter
})()
}
return transporterPromise
}
async function sendMail({ to, subject, text, html }) {
const transporter = await getTransporter()
await transporter.sendMail({
from: SMTP_FROM,
to,
subject,
text,
html,
})
}
async function sendEmailVerificationMail({ to, nickname, verificationUrl }) {
const displayName = nickname || to.split('@')[0] || '사용자'
await sendMail({
to,
subject: '[Tier Maker] 이메일 인증을 완료해주세요',
text: [
`${displayName}님, Tier Maker 가입을 완료하려면 아래 링크로 이메일 인증을 진행해주세요.`,
'',
verificationUrl,
'',
'이 링크는 24시간 동안 유효합니다.',
'직접 요청하지 않았다면 이 메일은 무시하셔도 됩니다.',
].join('\n'),
html: `
<div style="font-family:Arial,sans-serif;line-height:1.7;color:#111827">
<h1 style="font-size:20px;margin:0 0 16px">Tier Maker 이메일 인증</h1>
<p style="margin:0 0 16px">${displayName}님, Tier Maker 가입을 완료하려면 아래 버튼으로 이메일 인증을 진행해주세요.</p>
<p style="margin:0 0 20px">
<a href="${verificationUrl}" style="display:inline-block;padding:12px 18px;border-radius:999px;background:#4c85f5;color:#ffffff;text-decoration:none;font-weight:700">이메일 인증하기</a>
</p>
<p style="margin:0 0 8px;font-size:13px;color:#6b7280">버튼이 열리지 않으면 아래 주소를 브라우저에 직접 붙여넣어주세요.</p>
<p style="margin:0 0 20px;font-size:13px;word-break:break-all;color:#2563eb">${verificationUrl}</p>
<p style="margin:0;font-size:13px;color:#6b7280">이 링크는 24시간 동안 유효합니다. 직접 요청하지 않았다면 이 메일은 무시하셔도 됩니다.</p>
</div>
`,
})
}
async function sendPasswordResetMail({ to, nickname, resetUrl }) {
const displayName = nickname || to.split('@')[0] || '사용자'
await sendMail({
to,
subject: '[Tier Maker] 비밀번호 재설정 안내',
text: [
`${displayName}님, Tier Maker 비밀번호를 다시 설정하려면 아래 링크를 열어주세요.`,
'',
resetUrl,
'',
'이 링크는 1시간 동안 유효합니다.',
'직접 요청하지 않았다면 이 메일은 무시하셔도 됩니다.',
].join('\n'),
html: `
<div style="font-family:Arial,sans-serif;line-height:1.7;color:#111827">
<h1 style="font-size:20px;margin:0 0 16px">Tier Maker 비밀번호 재설정</h1>
<p style="margin:0 0 16px">${displayName}님, 비밀번호를 다시 설정하려면 아래 버튼을 눌러주세요.</p>
<p style="margin:0 0 20px">
<a href="${resetUrl}" style="display:inline-block;padding:12px 18px;border-radius:999px;background:#4c85f5;color:#ffffff;text-decoration:none;font-weight:700">비밀번호 재설정</a>
</p>
<p style="margin:0 0 8px;font-size:13px;color:#6b7280">버튼이 열리지 않으면 아래 주소를 브라우저에 직접 붙여넣어주세요.</p>
<p style="margin:0 0 20px;font-size:13px;word-break:break-all;color:#2563eb">${resetUrl}</p>
<p style="margin:0;font-size:13px;color:#6b7280">이 링크는 1시간 동안 유효합니다. 직접 요청하지 않았다면 이 메일은 무시하셔도 됩니다.</p>
</div>
`,
})
}
module.exports = {
isMailerConfigured,
sendEmailVerificationMail,
sendPasswordResetMail,
}

View File

@@ -38,6 +38,7 @@ const {
summarizeAdminTierLists,
findTierListById,
updateAdminTierListMeta,
updateTierListFeaturedStatus,
listAdminTemplateRequests,
findTemplateRequestById,
updateTemplateRequestStatus,
@@ -349,6 +350,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 +361,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 +374,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 +382,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 })
@@ -950,7 +976,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', 'created', 'tierlists', 'followers', 'favorites']).optional().default('recent'),
direction: z.enum(['asc', 'desc']).optional().default('desc'),
})
const parsed = schema.safeParse(req.query)

View File

@@ -1,5 +1,6 @@
const express = require('express')
const bcrypt = require('bcryptjs')
const crypto = require('crypto')
const { z } = require('zod')
const { nanoid } = require('nanoid')
const multer = require('multer')
@@ -9,14 +10,25 @@ const {
findUserByNickname,
findUserById,
createUser,
updateUserPassword,
verifyUserEmail,
createEmailVerificationToken,
findEmailVerificationTokenByHash,
consumeEmailVerificationToken,
createPasswordResetToken,
findPasswordResetTokenByHash,
consumePasswordResetToken,
updateUserProfile,
findPrimaryAdminUser,
} = require('../db')
const { requireAuth } = require('../middleware/auth')
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
const { isMailerConfigured, sendEmailVerificationMail, sendPasswordResetMail } = require('../lib/mailer')
const { isReservedNickname } = require('../lib/user-validation')
const router = express.Router()
const EMAIL_VERIFICATION_TTL_MS = 24 * 60 * 60 * 1000
const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000
const signupSchema = z.object({
email: z.string().email(),
@@ -24,6 +36,28 @@ const signupSchema = z.object({
password: z.string().min(6),
})
const verifyEmailSchema = z.object({
token: z.string().min(16).max(256),
})
const resendVerificationSchema = z.object({
email: z.string().email(),
})
const requestPasswordResetSchema = z.object({
email: z.string().email(),
})
const confirmPasswordResetSchema = z.object({
token: z.string().min(16).max(256),
password: z.string().min(6),
})
const changePasswordSchema = z.object({
currentPassword: z.string().min(6),
nextPassword: z.string().min(6),
})
const profileSchema = z.object({
nickname: z.string().trim().min(1).max(40),
removeAvatar: z.union([z.string(), z.undefined()]).optional(),
@@ -57,10 +91,77 @@ async function serializeUser(user) {
isOperator: !!user.isAdmin && !isPrimaryAdmin,
role: isPrimaryAdmin ? 'owner' : user.isAdmin ? 'operator' : 'user',
avatarSrc: user.avatarSrc || '',
emailVerified: user.emailVerified !== false,
createdAt: user.createdAt,
}
}
function createRawToken() {
return crypto.randomBytes(32).toString('hex')
}
function hashToken(token) {
return crypto.createHash('sha256').update(String(token || '')).digest('hex')
}
function resolveAppOrigin(req) {
const envOrigin = String(process.env.APP_ORIGIN || process.env.PUBLIC_APP_ORIGIN || '').trim()
if (envOrigin) return envOrigin.replace(/\/+$/, '')
const forwardedProto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim()
const protocol = forwardedProto || req.protocol || 'http'
const host = req.get('host')
return host ? `${protocol}://${host}` : ''
}
async function issueEmailVerificationMail(req, user) {
if (!isMailerConfigured()) {
const error = new Error('mail_not_configured')
error.code = 'mail_not_configured'
throw error
}
const rawToken = createRawToken()
await createEmailVerificationToken({
id: nanoid(),
userId: user.id,
tokenHash: hashToken(rawToken),
expiresAt: Date.now() + EMAIL_VERIFICATION_TTL_MS,
})
const appOrigin = resolveAppOrigin(req)
const verificationUrl = `${appOrigin}/login?verifyToken=${encodeURIComponent(rawToken)}`
await sendEmailVerificationMail({
to: user.email,
nickname: user.nickname,
verificationUrl,
})
}
async function issuePasswordResetMail(req, user) {
if (!isMailerConfigured()) {
const error = new Error('mail_not_configured')
error.code = 'mail_not_configured'
throw error
}
const rawToken = createRawToken()
await createPasswordResetToken({
id: nanoid(),
userId: user.id,
tokenHash: hashToken(rawToken),
expiresAt: Date.now() + PASSWORD_RESET_TTL_MS,
})
const appOrigin = resolveAppOrigin(req)
const resetUrl = `${appOrigin}/login?resetToken=${encodeURIComponent(rawToken)}`
await sendPasswordResetMail({
to: user.email,
nickname: user.nickname,
resetUrl,
})
}
router.post('/signup', async (req, res) => {
const parsed = signupSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
@@ -74,11 +175,36 @@ router.post('/signup', async (req, res) => {
const passwordHash = await bcrypt.hash(password, 10)
const isAdmin = (await countUsers()) === 0
const user = await createUser({ id: nanoid(), email, nickname, passwordHash, isAdmin })
if (!isAdmin && !isMailerConfigured()) {
return res.status(503).json({ error: 'mail_not_configured' })
}
const user = await createUser({
id: nanoid(),
email,
nickname,
passwordHash,
emailVerified: isAdmin,
isAdmin,
})
if (!isAdmin) {
try {
await issueEmailVerificationMail(req, user)
return res.json({
user: null,
verificationRequired: true,
email: user.email,
})
} catch (err) {
const statusCode = err?.code === 'mail_not_configured' ? 503 : 502
return res.status(statusCode).json({ error: err?.code || 'mail_send_failed' })
}
}
try {
await establishSession(req, user)
res.json(await serializeUser(user))
res.json({ user: await serializeUser(user), verificationRequired: false })
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
@@ -97,10 +223,11 @@ router.post('/login', async (req, res) => {
const ok = await bcrypt.compare(password, user.passwordHash)
if (!ok) return res.status(401).json({ error: 'invalid_credentials' })
if (!user.emailVerified) return res.status(403).json({ error: 'email_unverified', email: user.email })
try {
await establishSession(req, user)
res.json(await serializeUser(user))
res.json({ user: await serializeUser(user) })
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
@@ -122,6 +249,102 @@ router.get('/meta', async (req, res) => {
res.json({ hasUsers: (await countUsers()) > 0 })
})
router.post('/email/verify', async (req, res) => {
const parsed = verifyEmailSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const tokenRow = await findEmailVerificationTokenByHash(hashToken(parsed.data.token))
if (!tokenRow || tokenRow.consumedAt || tokenRow.expiresAt < Date.now()) {
return res.status(400).json({ error: 'invalid_or_expired_token' })
}
const user = await verifyUserEmail(tokenRow.userId)
if (!user) return res.status(404).json({ error: 'not_found' })
await consumeEmailVerificationToken(tokenRow.id)
try {
await establishSession(req, user)
res.json({ user: await serializeUser(user) })
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
})
router.post('/email/resend', async (req, res) => {
const parsed = resendVerificationSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const user = await findUserByEmail(parsed.data.email)
if (!user || user.emailVerified) return res.json({ ok: true })
try {
await issueEmailVerificationMail(req, user)
res.json({ ok: true })
} catch (err) {
const statusCode = err?.code === 'mail_not_configured' ? 503 : 502
return res.status(statusCode).json({ error: err?.code || 'mail_send_failed' })
}
})
router.post('/password-reset/request', async (req, res) => {
const parsed = requestPasswordResetSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const user = await findUserByEmail(parsed.data.email)
if (!user) return res.json({ ok: true })
try {
await issuePasswordResetMail(req, user)
res.json({ ok: true })
} catch (err) {
const statusCode = err?.code === 'mail_not_configured' ? 503 : 502
return res.status(statusCode).json({ error: err?.code || 'mail_send_failed' })
}
})
router.post('/password-reset/confirm', async (req, res) => {
const parsed = confirmPasswordResetSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const tokenRow = await findPasswordResetTokenByHash(hashToken(parsed.data.token))
if (!tokenRow || tokenRow.consumedAt || tokenRow.expiresAt < Date.now()) {
return res.status(400).json({ error: 'invalid_or_expired_token' })
}
const passwordHash = await bcrypt.hash(parsed.data.password, 10)
const updatedUser = await updateUserPassword({ id: tokenRow.userId, passwordHash })
if (!updatedUser) return res.status(404).json({ error: 'not_found' })
const verifiedUser = updatedUser.emailVerified ? updatedUser : await verifyUserEmail(updatedUser.id)
await consumePasswordResetToken(tokenRow.id)
try {
await establishSession(req, verifiedUser)
res.json({ user: await serializeUser(verifiedUser) })
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
})
router.post('/password', requireAuth, async (req, res) => {
const parsed = changePasswordSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const user = await findUserById(req.session.userId)
if (!user) return res.status(404).json({ error: 'not_found' })
const authUser = await findUserByEmail(user.email)
if (!authUser) return res.status(404).json({ error: 'not_found' })
const passwordMatched = await bcrypt.compare(parsed.data.currentPassword, authUser.passwordHash)
if (!passwordMatched) return res.status(401).json({ error: 'invalid_current_password' })
const passwordHash = await bcrypt.hash(parsed.data.nextPassword, 10)
const updated = await updateUserPassword({ id: authUser.id, passwordHash })
res.json({ user: await serializeUser(updated) })
})
const upload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 })
router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) => {

View File

@@ -124,8 +124,8 @@ const tierListUpsertSchema = z.object({
router.get('/public', async (req, res) => {
const topicId = typeof req.query.topicId === 'string' ? req.query.topicId : ''
const queryText = typeof req.query.q === 'string' ? req.query.q : ''
const lists = await listPublicTierLists(topicId, req.session?.userId || '', queryText)
res.json({ tierLists: lists })
const result = await listPublicTierLists(topicId, req.session?.userId || '', queryText)
res.json(result)
})
router.get('/me', requireAuth, async (req, res) => {

View File

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

View File

@@ -41,6 +41,13 @@ services:
SESSION_COOKIE_SAME_SITE: "lax"
CORS_ORIGINS: https://tmaker.sori.studio
TRUST_PROXY: 1
APP_ORIGIN: https://tmaker.sori.studio
SMTP_HOST: ${SMTP_HOST}
SMTP_PORT: ${SMTP_PORT}
SMTP_SECURE: ${SMTP_SECURE}
SMTP_USER: ${SMTP_USER}
SMTP_PASS: ${SMTP_PASS}
SMTP_FROM: ${SMTP_FROM}
volumes:
- tmaker_uploads:/app/uploads
- tmaker_sessions:/app/.sessions

View File

@@ -1,5 +1,43 @@
# 의사결정 이력
## 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
- 프로필 저장 실패를 하나의 일반 실패 메시지로만 보여주면 사용자가 “서버가 고장났나?”라고 오해하기 쉬우므로, 중복 닉네임/예약어 닉네임처럼 사용자가 직접 고칠 수 있는 입력 오류는 원인별 안내를 분리하는 편이 맞다고 판단했다.
- 비밀번호를 잊은 사용자뿐 아니라 로그인 중인 사용자도 보안상 주기적으로 비밀번호를 직접 바꿀 수 있어야 하므로, 설정 화면에 현재 비밀번호 확인 기반 변경 폼을 추가하는 쪽으로 정리했다.
- 비밀번호 재설정 링크는 로그인 세션이 남아 있어도 링크 토큰 자체의 목적이 우선이므로, `/login?resetToken=...` 진입 시에는 기존 자동 리다이렉트보다 재설정 폼 렌더링을 우선하는 편이 맞다고 판단했다.
## 2026-04-03 v1.4.45
- 실제 서비스에서는 남의 이메일 주소로 가입만 먼저 해두는 문제가 생길 수 있으므로, 일반 회원은 가입 직후 인증 메일을 거쳐야 로그인할 수 있게 하고 비밀번호 분실도 메일 토큰 기반으로 복구하는 구조가 필요하다고 판단했다.
- 다만 초기 운영자가 바로 서비스를 띄울 수 있어야 하므로, 첫 번째 가입 계정만은 기존처럼 이메일 인증 없이 바로 최고 관리자 계정으로 활성화하는 예외를 유지하는 편이 맞다고 정리했다.
- 발신 인프라는 우선 사용자가 준비한 Gmail 계정과 앱 비밀번호로 SMTP를 먼저 붙이고, 도메인 발신 주소와 SPF/DKIM/DMARC는 실제 발송 품질을 본 뒤 Cloudflare DNS에서 후속 정리하는 단계적 접근이 더 현실적이라고 판단했다.
## 2026-04-03 v1.4.44
- 공통 카피라이트 링크 색을 고정 민트색으로 두면 다크 모드에서는 잘 보이지만 라이트 모드에서 대비가 부족해질 수 있으므로, 테마 텍스트 색을 따라가게 하고 굵기로 링크 인지를 보완하는 편이 더 안정적이라고 판단했다.
## 2026-04-03 v1.4.43
- Vue Router에서 같은 컴포넌트가 유지된 채 `params/query`만 바뀌는 에디터 이동은 `onMounted()`만으로는 새 데이터를 다시 불러오지 못할 수 있으므로, 에디터 로딩을 라우트 값 watch 기반으로 옮기는 편이 맞다고 판단했다.
- 복사본에서 원본으로 이동하는 액션은 사용자가 편집 중이던 내용을 잃을 수 있으므로, 저장하지 않은 변경이 감지되는 경우에는 바로 이동하지 않고 확인 모달로 한 번 끊어주는 쪽이 안전하다고 정리했다.
## 2026-04-03 v1.4.42
- 홈 템플릿 목록은 관리자가 아직 수동 순서를 건드리지 않은 신규 템플릿까지 이름순으로 섞이면 “새로 만든 항목이 앞에 보인다”는 운영 기대와 어긋나므로, 수동 순서가 없는 항목은 최신 생성순을 우선하는 정렬이 맞다고 판단했다.
- 티어표 편집 조작은 드래그만으로도 충분하지만, 세밀한 이동이나 터치패드 환경에서는 클릭 선택 후 대상 셀 클릭 방식이 더 편할 수 있으므로 두 조작을 병행 지원하는 쪽으로 확장했다.
- 다만 드래그 직후 click 이벤트가 이어서 들어오면 의도치 않은 재선택이 생길 수 있으므로, 드래그 시작 시 선택을 비우고 드래그 종료 직후 짧은 클릭 잠금을 두는 방식으로 충돌을 줄였다.
## 2026-04-03 v1.4.41
- 관리자 기본 아이템 업로드는 운영자가 한 번에 많은 캐릭터 이미지를 정리하는 작업이 잦으므로, 서버 개별 파일 제한뿐 아니라 한 요청당 업로드 개수와 프록시 본문 크기 제한도 같이 넉넉하게 올려두는 편이 맞다고 판단했다.
- 다중 업로드가 프런트에서 한 번의 `FormData` 요청으로 묶여 나가는 구조라면, 백엔드 `multer`만 올리고 Nginx `client_max_body_size`를 그대로 두면 병목이 남을 수 있으므로 프런트 프록시 제한도 함께 상향하는 쪽으로 정리했다.

View File

@@ -7,7 +7,7 @@
## `/topics/:topicId`
- 화면 파일: `frontend/src/views/TopicHubView.vue`
- 역할: 선택한 주제 정보 표시, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
- 역할: 선택한 주제 정보 표시, 관리자 추천 티어표 상단 강조 섹션과 일반 공개 티어표 목록 분리 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`
## `/editor/:topicId/new`, `/editor/:topicId/:tierListId`
@@ -17,8 +17,8 @@
## `/login`
- 화면 파일: `frontend/src/views/LoginView.vue`
- 역할: 로그인/회원가입 전환, 첫 가입 안내
- 연동 API: `GET /api/auth/meta`, `POST /api/auth/login`, `POST /api/auth/signup`
- 역할: 로그인/회원가입 전환, 첫 가입 안내, 일반 회원가입 후 이메일 인증 안내와 인증 메일 재전송, 비밀번호 재설정 메일 요청, `?verifyToken=...` 인증 링크 처리, `?resetToken=...` 새 비밀번호 설정 처리
- 연동 API: `GET /api/auth/meta`, `POST /api/auth/login`, `POST /api/auth/signup`, `POST /api/auth/email/verify`, `POST /api/auth/email/resend`, `POST /api/auth/password-reset/request`, `POST /api/auth/password-reset/confirm`
## `/me`
- 화면 파일: `frontend/src/views/MyTierListsView.vue`
@@ -30,6 +30,16 @@
- 역할: 즐겨찾기한 티어표 목록 조회, 검색/정렬, 라이브러리 카드형 표시, 편집 화면 이동, 즐겨찾기 상태 확인
- 연동 API: `GET /api/tierlists/favorites/me`, `DELETE /api/tierlists/:id/favorite`
## `/following`
- 화면 파일: `frontend/src/views/FollowingFeedView.vue`
- 역할: 팔로우한 작성자의 공개 티어표를 최신 업데이트순 카드 목록으로 모아보기, 제목/주제/작성자 검색, 티어표 상세 이동, 작성자 프로필 이동
- 연동 API: `GET /api/users/following-feed`
## `/users/:userId`
- 화면 파일: `frontend/src/views/UserProfileView.vue`
- 역할: 작성자 공개 프로필, 팔로워/팔로잉/공개 티어표 수 표시, 로그인 사용자의 팔로우/언팔로우 전환, 해당 작성자의 공개 티어표 목록 조회와 상세 이동
- 연동 API: `GET /api/users/:userId`, `GET /api/users/:userId/tierlists`, `POST /api/users/:userId/follow`, `DELETE /api/users/:userId/follow`
## `/search`
- 화면 파일: `frontend/src/views/SearchResultsView.vue`
- 역할: 좌측 전역 검색 입력에서 넘긴 키워드로 공개 티어표 전체를 검색하고, 자체 검색 툴바 없이 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 목록으로 표시
@@ -37,13 +47,13 @@
## `/admin`
- 화면 파일: `frontend/src/views/AdminView.vue`
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `템플릿 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 템플릿 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 템플릿 삭제
- 연동 API: `POST /api/admin/templates`, `POST /api/admin/templates/:templateId/thumbnail`, `POST /api/admin/templates/:templateId/images`, `PATCH /api/admin/templates/:templateId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `GET /api/admin/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/template-requests/:requestId/link-template`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/templates/:templateId/items/:itemId`, `DELETE /api/admin/templates/:templateId`
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `템플릿 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 템플릿 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/받은 즐겨찾기 수 표시/인기순 정렬/최소 즐겨찾기 필터/추천 지정 토글/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원의 작성 티어표·팔로워·받은 즐겨찾기 지표 확인과 회원 정보·권한 수정, 파일 입력 초기화, 아이템 삭제, 템플릿 삭제
- 연동 API: `POST /api/admin/templates`, `POST /api/admin/templates/:templateId/thumbnail`, `POST /api/admin/templates/:templateId/images`, `PATCH /api/admin/templates/:templateId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `GET /api/admin/tierlists/stats`, `PATCH /api/admin/tierlists/:tierListId/featured`, `GET /api/admin/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/template-requests/:requestId/link-template`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/templates/:templateId/items/:itemId`, `DELETE /api/admin/templates/:templateId`
## `/profile`
- 화면 파일: `frontend/src/views/ProfileView.vue`
- 역할: 프로필 표시, 작성자 닉네임 수정, 아바타 미리보기 후 저장, 설정 화면 하단 로그아웃 처리
- 연동 API: `GET /api/auth/me`, `POST /api/auth/profile`
- 역할: 넓은 화면에서는 왼쪽 프로필 정보 카드와 오른쪽 비밀번호 변경 카드로 나뉘는 설정 화면, 프로필 표시, 작성자 닉네임 수정, 아바타 미리보기 후 저장, 중복/예약어 닉네임 오류 안내, 현재 비밀번호 확인 기반 비밀번호 변경, 설정 화면 로그아웃 처리
- 연동 API: `GET /api/auth/me`, `POST /api/auth/profile`, `POST /api/auth/password`
## 공통 레이아웃
- 앱 셸 파일: `frontend/src/App.vue`
@@ -56,6 +66,8 @@
- 로컬 DB 실행 설정: `docker-compose.yml`
- 로컬 MariaDB 가이드: `docs/local-mariadb.md`
- 인증 라우트: `backend/src/routes/auth.js`
- 메일 발송 유틸: `backend/src/lib/mailer.js`
- 주제 라우트: `backend/src/routes/topics.js`
- 티어표 라우트: `backend/src/routes/tierlists.js`
- 사용자/팔로우 라우트: `backend/src/routes/users.js`
- 관리자 라우트: `backend/src/routes/admin.js`

View File

@@ -45,7 +45,7 @@
- 좌우 레일의 주요 CTA는 스크롤되는 본문과 분리된 하단 `56px` 액션 영역에 배치한다.
- 하단 액션은 화면 바닥에 바로 붙지 않도록 푸터 내부에 추가 하단 여백을 둔다.
- 홈 화면 기준 우측 패널은 임시 정보 카드 여러 개보다 핵심 CTA 하나만 남겨, 시안처럼 단순한 보조 레일 역할을 우선 유지한다.
- 광고 영역은 상단 헤더와 시각적으로 너무 붙지 않도록 `78px` 상단 여백을 두고, 하단 카피라이트는 중앙 정렬된 공통 footer로 표시한다.
- 광고 영역은 상단 헤더와 시각적으로 너무 붙지 않도록 `78px` 상단 여백을 두고, 하단 카피라이트는 중앙 정렬된 공통 footer로 표시한다. 카피라이트 링크는 다크/라이트 테마 모두에서 읽히도록 고정 민트색 대신 테마 텍스트 색과 굵기를 사용한다.
- 티어표 편집 화면
- 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다.
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 보드 영역은 메인 컬럼에, 우측 `320px` 편집 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
@@ -64,9 +64,25 @@
- `email`: string
- `nickname`: string
- `passwordHash`: string
- `emailVerified`: boolean
- `isAdmin`: boolean
- `avatarSrc`: string
- `createdAt`: number
- 관리자 목록 집계 응답에서는 `tierListCount`, `followerCount`, `receivedFavoriteCount`, `recentActivityAt`도 함께 내려준다.
- `emailVerificationTokens`
- `id`: string
- `userId`: string
- `tokenHash`: string
- `expiresAt`: number
- `consumedAt`: number
- `createdAt`: number
- `passwordResetTokens`
- `id`: string
- `userId`: string
- `tokenHash`: string
- `expiresAt`: number
- `consumedAt`: number
- `createdAt`: number
- `games`
- `id`: string
- `name`: string
@@ -94,6 +110,9 @@
- 사용자가 직접 지정하지 않으면 저장 시 티어표 대표 아이템 이미지로 자동 채운다.
- `description`: string
- `isPublic`: boolean
- `isFeatured`: boolean
- `featuredAt`: number
- `featuredBy`: string
- `groups`: `{ id, name, itemIds[] }[]`
- `pool`: `{ id, src, label, origin }[]`
- `createdAt`: number
@@ -102,6 +121,10 @@
- `userId`: string
- `tierListId`: string
- `createdAt`: number
- `userFollows`
- `followerId`: string
- `followingId`: string
- `createdAt`: number
- `gameSuggestions`
- `id`: string
- `name`: string
@@ -110,16 +133,29 @@
## 주요 API
- 인증
- `POST /api/auth/signup`
- 첫 관리자 계정은 바로 로그인 세션을 만들고, 이후 일반 계정은 인증 메일 발송 후 `verificationRequired` 상태를 반환한다.
- `POST /api/auth/login`
- 이메일 인증이 끝나지 않은 계정은 `email_unverified`로 차단한다.
- `POST /api/auth/logout`
- `GET /api/auth/me`
- `GET /api/auth/meta`
- `POST /api/auth/profile`
- `POST /api/auth/password`
- 로그인한 사용자가 현재 비밀번호를 확인한 뒤 새 비밀번호로 직접 변경한다.
- `POST /api/auth/email/verify`
- `login?verifyToken=...` 링크에서 받은 토큰으로 이메일 인증을 완료하고 바로 로그인 세션을 만든다.
- `POST /api/auth/email/resend`
- 미인증 계정의 인증 메일을 다시 발송한다.
- `POST /api/auth/password-reset/request`
- 입력한 이메일로 비밀번호 재설정 링크를 발송한다.
- `POST /api/auth/password-reset/confirm`
- `login?resetToken=...` 링크의 토큰과 새 비밀번호로 비밀번호를 재설정하고 바로 로그인 세션을 만든다.
- 주제
- `GET /api/topics`
- `GET /api/topics/:topicId`
- 티어표
- `GET /api/tierlists/public`
- `featuredTierLists`와 일반 공개 `tierLists`를 분리해서 반환한다.
- `topicId` 없이 `q`만 전달하면 전체 공개 티어표 검색에 사용한다.
- `GET /api/tierlists/me`
- `GET /api/tierlists/favorites/me`
@@ -131,6 +167,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`
@@ -138,6 +183,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`
@@ -149,6 +198,7 @@
- `DELETE /api/admin/custom-items/:itemId`
- `DELETE /api/admin/custom-items`
- `GET /api/admin/users`
- `sort=recent|created|tierlists|followers|favorites`, `direction=asc|desc`로 회원을 활동/작성량/팔로워/받은 즐겨찾기 기준으로 정렬한다.
- `PATCH /api/admin/users/:userId`
- `PATCH /api/admin/users/:userId/password`
- `DELETE /api/admin/users/:userId`
@@ -167,6 +217,8 @@
- 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다.
- 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다.
- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 내부에서 `템플릿 요청 관리 / 전체 티어표 관리`를 분리해 볼 수 있고, 확인용 완성본은 탐색 UI 없는 preview 전용 모달로 연다.
- `전체 티어표 관리`에서는 공개 티어표를 `추천 지정 / 추천 해제`할 수 있고, 추천 지정된 티어표는 주제별 공개 목록 상단의 `추천 티어표` 섹션에 먼저 노출된다. 비공개 티어표는 추천 지정할 수 없고, 추천글을 비공개로 바꾸면 추천 상태도 함께 해제된다.
- `전체 티어표 관리` 카드에는 받은 즐겨찾기 수를 함께 보여주며, 우측 운영 패널에서 즐겨찾기 많은 순 정렬과 최소 즐겨찾기 수 필터로 추천 후보를 좁힐 수 있다.
- `티어표 관리` 탭의 추가 아이템은 작은 그리드 카드로 표시하고, 클릭 시 `기존 템플릿에 추가 / 새 템플릿 만들기` 모달을 통해 목적지를 선택한다.
- `티어표 관리` 탭에서는 티어표 안의 커스텀 아이템을 개별 또는 일괄로 기존 게임 템플릿에 복제할 수 있다.
- `freeform` 티어표는 관리자 화면에서 새 게임 ID/이름을 입력해 새로운 게임 템플릿으로 복제 생성할 수 있다.
@@ -175,24 +227,33 @@
- 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다.
- 단, 일반 운영자는 최고 관리자 계정의 프로필 이미지/회원 정보/비밀번호/삭제 버튼을 사용할 수 없고, 최고 관리자만 다른 관리자 권한을 변경할 수 있다.
- 관리자 회원 정보 수정은 운영상 필요한 경우 예약어 닉네임도 저장할 수 있지만, 일반 회원가입과 개인 프로필 수정에서는 운영자 사칭성 예약어 닉네임을 계속 차단한다.
- 회원 관리 카드에는 아바타, 작성 티어표 수, 최근 활동 시각을 함께 표시한다.
- 회원 관리 카드에는 아바타, 작성 티어표 수, 팔로워 수, 받은 즐겨찾기 수, 최근 활동 시각을 함께 표시한다.
- 운영자는 회원 목록을 작성 티어표 수뿐 아니라 팔로워 수와 받은 즐겨찾기 수 기준으로도 정렬할 수 있어, 핵심 작성자를 더 빠르게 찾을 수 있다.
- 회원 비밀번호를 운영자가 임의로 덮어쓰는 기능은 비상 상황용 API로만 유지하고, 일반 회원 관리 카드에서는 비밀번호 초기화 버튼과 모달을 숨긴다. 평소 사용자 비밀번호 변경은 이메일 재설정 메일과 설정 화면 직접 변경을 우선 사용한다.
## 티어표 접근 메모
- `new` 작성 경로는 로그인한 사용자만 진입할 수 있다.
- 공유 링크로 여는 `preview=1` 화면은 `뷰어 모드`로 취급하며, 드래그/행열 편집/저장 같은 편집 UI 없이 완성본만 렌더링한다.
- 비로그인 사용자나 작성자 본인이 아닌 로그인 사용자는 저장된 티어표를 기본적으로 뷰어 모드로 열람하며, 일반 편집 URL로 직접 진입해도 소유자가 아니면 `preview=1` 주소로 자동 전환된다.
- 비로그인 사용자도 뷰어 모드 우측 레일의 `공유하기` 버튼으로 현재 공유 링크를 복사할 수 있다.
- 로그인한 타인 티어표 열람자는 뷰어 모드 우측 레일에서 `내 티어표로 복사`를 사용할 수 있고, 작성자 본인은 `수정 모드로 전환` 사용할 수 있다.
- 로그인한 사용자는 뷰어 모드 우측 레일에서 저장된 티어표를 복사할 수 있고, 타인 티어표면 `내 티어표로 복사`, 본인 티어표면 `복사본 만들기` 문구를 사용한다. 작성자 본인은 `수정 모드로 전환` 사용할 수 있다.
- 작성자 본인이 일반 편집 화면에서 저장된 본인 티어표를 보고 있을 때는 우측 패널의 `뷰어 모드로 보기`로 공유 화면 형태를 바로 확인할 수 있다.
- 편집/뷰어 우측 패널의 `작성자 프로필 보기`로 해당 작성자의 공개 프로필과 공개 티어표 목록을 열 수 있고, 로그인 상태에서는 작성자 프로필에서 팔로우/언팔로우를 전환할 수 있다.
- 같은 `TierEditorView` 안에서 `topicId / tierListId / preview` 라우트 값만 바뀌어도 화면이 이전 티어표 데이터에 남지 않도록, 라우트 변경마다 에디터 상태를 다시 로드한다.
- 복사한 티어표 상단의 `원본` 링크를 누르면 원본 티어표로 이동하며, 편집 모드에서 저장하지 않은 변경이 있으면 이동 전에 `저장 없이 이동` 확인 모달을 먼저 띄운다.
- 본인 티어표도 저장된 상태라면 편집/뷰어 우측 패널에서 복사본을 만들 수 있고, 편집 중 저장하지 않은 수정이 남아 있으면 복사 직전에 현재 수정본을 먼저 저장해 최신 상태 기준 복사본을 만든다.
- 비공개 티어표라도 관리자는 편집 화면에서 완성본을 열람할 수 있다.
- 공개 티어표는 목록과 상세 화면에서 즐겨찾기 토글 및 개수를 함께 표시한다.
- 카드형 목록에서는 즐겨찾기 수/상태만 표시하고, 실제 토글은 상세 화면에서 처리한다.
- 공개 티어표 목록은 현재 게임 기준으로 제목/작성자 검색을 지원한다.
- 주제별 공개 티어표 화면은 관리자 추천글을 상단 `추천 티어표` 섹션으로 먼저 보여주고, 일반 공개 목록은 아래 `전체 공개 티어표` 섹션으로 분리해 중복 없이 렌더링한다. 추천 섹션은 최대 16개까지 표시한다.
- `내 즐겨찾기` 화면에서는 즐겨찾기한 순, 최신 업데이트순, 인기순 정렬을 제공한다.
- 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다.
- 사용자가 직접 추가한 커스텀 아이템 이름은 편집 화면 우측 목록에서 정리할 수 있고, 저장 시 원본 커스텀 아이템 라벨과 함께 동기화된다.
- 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다.
- 티어 행에 넣은 아이템은 작은 제거 버튼으로 다시 우측 아이템 풀로 되돌릴 수 있다.
- 편집 가능한 티어표에서는 아이템을 드래그로 옮길 수 있고, 아이템을 클릭해 선택한 뒤 원하는 셀이나 아이템 풀 빈 영역을 클릭하는 방식으로도 위치를 바꿀 수 있다.
- 클릭 배치 모드에서 선택된 아이템은 파란 포커스 테두리로 표시하며, 드래그를 시작하면 선택 상태를 해제하고 드래그 직후 짧은 클릭 입력은 무시해 드래그/클릭 조작이 섞이지 않게 한다.
- 기존 저장 티어표/복사본을 수정 가능한 상태로 다시 열 때는 저장본의 그룹/풀을 먼저 복원하고, 이후 현재 템플릿에 새로 추가된 기본 아이템만 미사용 풀 끝에 병합한다.
- 관리자 템플릿에서 삭제된 기본 아이템이라도 이미 저장된 티어표의 그룹/풀에 포함되어 있던 항목은 자동 제거하지 않고 그대로 보존한다.
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
@@ -216,6 +277,7 @@
- 저장/삭제/가져오기 같은 사용자 행동 피드백은 전역 우측 상단 토스트로 표시한다.
- 전역 토스트는 동일 메시지/타입이 연속 발생하면 하나로 합쳐 카운트를 올리고, 종료 시 짧은 페이드아웃 애니메이션을 사용한다.
- 홈 게임 목록은 관리자 상단 고정 순서가 있으면 그 순서를 먼저 적용하고, 그 외 게임은 최근 생성순으로 뒤에 이어진다.
- 홈 주제 템플릿 목록의 실제 정렬 우선순위는 `즐겨찾기 여부 → 관리자 수동 순서(displayRank) → 최신 생성순(createdAt DESC) → 이름순`이다.
- `커스텀 티어표 만들기`는 카드가 아니라 홈 우측 상단 버튼으로 진입한다.
## 업로드 제한 메모
@@ -240,6 +302,22 @@
- `TRUST_PROXY`: 프록시 홉 수
- `SESSION_COOKIE_SECURE`: `true`면 HTTPS 전용 쿠키
- `SESSION_COOKIE_SAME_SITE`: 기본 `lax`
- `APP_ORIGIN`: 이메일 인증/비밀번호 재설정 링크를 만들 때 사용할 서비스 기준 주소
- `SMTP_HOST`: 메일 서버 호스트, Gmail SMTP 사용 시 보통 `smtp.gmail.com`
- `SMTP_PORT`: 메일 서버 포트, Gmail SSL SMTP 기준 보통 `465`
- `SMTP_SECURE`: `true`면 SMTP SSL/TLS 연결을 사용
- `SMTP_USER`: 발신용 Gmail 계정
- `SMTP_PASS`: Gmail 앱 비밀번호
- `SMTP_FROM`: 실제 메일 From 주소, 비워두면 `SMTP_USER`를 기본값으로 사용한다
## 회원 인증 메모
- 첫 번째 가입 계정은 운영 초기 부트스트랩을 위해 이메일 인증 없이 바로 최고 관리자 계정으로 활성화한다.
- 두 번째 이후 일반 회원가입은 가입 직후 로그인 세션을 만들지 않고, 인증 메일 링크를 눌러 `email_verified=1`이 된 뒤에만 로그인할 수 있게 한다.
- 인증 메일/비밀번호 재설정 메일 토큰은 원문을 DB에 저장하지 않고 SHA-256 해시만 저장하며, 새 토큰을 발급할 때는 같은 사용자의 이전 미사용 토큰을 먼저 만료 처리한다.
- 이메일 인증 토큰은 24시간, 비밀번호 재설정 토큰은 1시간 유효 기간을 사용한다.
- 비밀번호 재설정 링크로 새 비밀번호를 저장한 사용자는 같은 메일 주소를 확인한 것으로 보고, 기존에 미인증 상태였더라도 저장과 함께 이메일 인증을 완료 처리한다.
- 로그인한 상태로도 `login?resetToken=...` 재설정 링크를 열 수 있으며, 이때는 기존 로그인 세션이 있어도 자동으로 내 티어표 화면으로 보내지 않고 새 비밀번호 입력 화면을 먼저 보여준다.
- 설정 화면의 직접 비밀번호 변경은 현재 비밀번호가 맞는지 먼저 확인하고, 맞지 않으면 `invalid_current_password`로 차단한다.
## 운영 배포 메모
- 프로덕션 컴포즈 파일은 [docker-compose.prod.yml](/Users/bicute/Desktop/zenn.dev/tier-cursor/docker-compose.prod.yml)이다.

View File

@@ -1,6 +1,36 @@
# 할 일 및 이슈
## 단기 확인
- `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한다.
- 로그인한 상태로 비밀번호 재설정 메일의 `login?resetToken=...` 링크를 눌렀을 때도 바로 내 티어표 화면으로 튕기지 않고 `새 비밀번호 설정` 화면이 먼저 뜨는지 확인한다.
- `v1.4.48`에서 로컬 `APP_ORIGIN``localhost:5173`으로 먼저 주입하도록 바꿨으므로, 백엔드를 다시 띄운 뒤 새 회원가입 인증 메일과 비밀번호 재설정 메일 링크가 운영 도메인이 아니라 로컬 주소로 열리는지 확인한다.
- `v1.4.47`에서 로컬 백엔드가 루트 `.env.production`을 읽도록 바꿨으므로, `SMTP_PASS` 교체 후 백엔드를 다시 띄우고 로컬 회원가입이 더 이상 `mail_not_configured` 503으로 떨어지지 않는지 확인한다.
- `.env.production``SMTP_PASS=여기에_Gmail_앱_비밀번호_입력` placeholder를 실제 Gmail 앱 비밀번호로 교체한 뒤, 운영 컨테이너를 재기동해서 회원가입 인증 메일과 비밀번호 재설정 메일이 실제로 발송되는지 확인한다.
- `v1.4.45`에서 이메일 인증/비밀번호 재설정 메일 발송을 Gmail SMTP로 붙였으므로, 운영 `.env``SMTP_USER`, `SMTP_PASS`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_FROM`, `APP_ORIGIN`을 넣은 뒤 실제 회원가입 인증 메일과 비밀번호 재설정 메일이 도착하는지 확인한다.
- 일반 회원가입 직후에는 자동 로그인되지 않고 인증 안내 문구가 떠야 하며, 메일의 `login?verifyToken=...` 링크를 누르면 이메일 인증과 로그인 세션 생성이 함께 끝나는지 QA한다.
- 미인증 계정으로 로그인하면 `email_unverified` 상태 안내가 뜨고, `인증 메일 재전송` 버튼으로 같은 메일 주소에 인증 링크를 다시 보낼 수 있는지 확인한다.
- `비밀번호를 잊으셨나요?`에서 재설정 메일을 요청한 뒤 `login?resetToken=...` 링크로 들어가면 새 비밀번호 입력 화면이 열리고, 저장 후 바로 로그인 상태로 내 티어표 화면으로 이동하는지 확인한다.
- Gmail 주소 그대로 발송하는 1차 단계에서는 도메인 DNS 인증을 당장 쓰지 않지만, 이후 `noreply@sori.studio` 같은 도메인 발신 주소로 바꿀 경우 Cloudflare DNS에 SPF/DKIM/DMARC를 설정하는 후속 작업이 필요하다.
- `v1.4.44`에서 공통 카피라이트 `zenn` 링크를 테마 텍스트 색으로 바꿨으므로, 다크/라이트 모드 양쪽에서 하단 링크가 배경에 묻히지 않고 hover 상태도 자연스러운지 확인한다.
- `v1.4.43`에서 같은 `TierEditorView` 라우트 안에서도 `topicId / tierListId / preview`가 바뀌면 상태를 다시 불러오게 했으므로, 타인 티어표 복사 직후 화면이 내 복사본으로 바뀌는지와 상단 원본 링크 클릭 시 실제 원본 티어표 내용으로 전환되는지 확인한다.
- 편집 중 미저장 변경이 있는 상태에서 상단 원본 링크를 눌렀을 때는 경고 모달이 뜨고, `계속 편집`은 현재 화면 유지, `저장 없이 이동`은 원본으로 이동하면서 변경분을 버리는지 QA한다.
- `v1.4.42`에서 홈 템플릿 정렬을 `즐겨찾기 → 수동 순서 → 최신 생성순 → 이름순`으로 바꿨으므로, 관리자에서 아무 수동 정렬을 하지 않은 신규 템플릿이 가장 앞쪽에 보이고, 즐겨찾기/수동 고정 항목은 기존 우선순위를 유지하는지 확인한다.
- 티어표 편집기의 클릭 배치를 추가했으므로, 풀 아이템 클릭→빈 셀 클릭, 셀 아이템 클릭→다른 셀 클릭, 셀 아이템 클릭→풀 빈 영역 클릭, 같은 아이템 재클릭 선택 해제, 드래그 직후 의도치 않은 재선택 방지까지 한 번씩 QA한다.
- 클릭 배치에서 이미 아이템이 들어 있는 셀 안의 빈 영역을 눌렀을 때는 해당 셀 끝에 추가되고, 같은 셀을 다시 누르면 선택만 해제되는지 확인한다.
- `v1.4.41`에서 관리자 템플릿 기본 아이템 다중 업로드를 `100개/파일당 20MB`와 Nginx `client_max_body_size 1024m`으로 올렸으므로, 운영 NAS 앞단 리버스 프록시에도 별도 본문 크기 제한이 있으면 같은 수준으로 맞춰야 하는지 확인한다.
- 실제 QA에서는 10개 이상, 50개 이상, 100개 근처의 이미지 묶음을 한 번에 올렸을 때 브라우저/프런트 Nginx/백엔드 중 어느 단계에서도 `413`이나 업로드 실패가 나지 않는지 확인한다.
- `v1.4.40`에서 `preview=1` 공유 화면을 뷰어 모드로 정리했으므로, 비로그인/로그인한 타인/작성자 본인 세 경우에 드래그 편집이 막히고 오른쪽 레일 버튼이 각각 `공유하기`, `내 티어표로 복사`, `수정 모드로 전환` 조건대로 노출되는지 확인한다. 특히 비로그인/타인이 일반 편집 URL로 직접 들어왔을 때도 자동으로 `preview=1`로 바뀌는지 본다.
@@ -114,6 +144,10 @@
- 아이템 관리의 같은 이미지 참조 요약과 상세 모달은 붙였으므로, 실제 운영 데이터에서 카드 수치와 모달 목록이 기대한 참조 관계와 맞는지 한 번 더 QA한다.
## 중기 개선
- 목록 카드의 작성자 메타를 카드 전체 열기 버튼과 충돌 없이 직접 프로필 링크로 분리하는 후속 UX를 검토한다.
- 추천 티어표는 전체 누적 즐겨찾기 기준 정렬/필터부터 붙였으므로, 다음 단계에서는 최근 N일 기준 급상승 추천 후보 필터와 추천 섹션 노출 개수 설정을 관리자 화면에 추가할지 검토한다.
- 이메일 인증/비밀번호 재설정 1차 구현이 들어갔으므로, 다음 단계에서는 Gmail 발신 기반이 실제 운영에서 스팸함으로 얼마나 가는지 보고 필요하면 Cloudflare DNS의 SPF/DKIM/DMARC와 도메인 발신 주소 전환을 정리한다.
- 구글 계정 로그인은 아직 붙이지 않았으므로, 이메일 인증 안정화 후 Google OAuth 클라이언트를 만들고 일반 이메일 계정과 같은 이메일의 구글 계정을 자동 연결할지 정책을 먼저 결정한다.
- 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다.
- 관리자 템플릿 요청은 동일 `src` 중복 생성 방지와 `연결된 게임/이미 반영 n개` 표시까지 붙였으므로, 이후에는 요청별 반영 이력(예: 어떤 아이템을 언제 반영했는지)까지 별도로 남길지 검토한다.
- 관리자 URL 분리는 시작했으므로, 다음 단계에서는 `AdminView.vue` 단일 대형 파일을 섹션별 뷰/컴포저블로 쪼개고 직접 진입 시 선택 게임/요청 작업 상태 복원 범위도 함께 정리한다.
@@ -127,7 +161,7 @@
- 라이트모드/다크모드 2차 보정까지 반영했으므로, 남은 작업은 전체 화면을 실제 사용 흐름으로 돌려 보며 대비·명도·아이콘 가독성을 미세하게 QA하는 최종 테마 점검 단계로 가져간다.
- 라이트모드 공통 토큰 재정비와 카드/아바타/즐겨찾기 버튼 보정까지 반영했으므로, 다음 QA에서는 로그인/홈/주제 허브/에디터/관리자 순으로 실제 플로우를 돌리며 남은 하드코딩 색과 과한 대비가 없는지 확인한다.
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
- 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다.
- 회원 일괄 작업(다중 선택, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다. 비밀번호는 평소 운영자가 직접 덮어쓰기보다 이메일 재설정 흐름을 우선하므로, 관리자 일괄 비밀번호 초기화는 별도 긴급 대응 정책이 생긴 뒤에만 다시 검토한다.
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
- 로그인/회원가입/관리자 비밀번호 초기화에 요청 횟수 제한을 추가한다.
- 업로드 파일은 MIME 타입뿐 아니라 파일 시그니처 기반 검증까지 확장한다.

View File

@@ -1,5 +1,65 @@
# 업데이트 로그
## 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열로 내려가도록 레이아웃을 재정리했다.
- 왼쪽 카드는 아바타/닉네임/이메일/로그아웃/프로필 저장을, 오른쪽 카드는 현재 비밀번호 확인과 새 비밀번호 저장을 담당하게 분리해, 설정 화면의 정보 묶음이 더 명확하게 읽히도록 맞췄다.
## 2026-04-03 v1.4.49
- 설정 화면에 현재 비밀번호 확인 후 새 비밀번호를 직접 저장하는 `비밀번호 변경` 섹션을 추가하고, 백엔드에는 로그인 사용자용 `POST /api/auth/password` API를 붙였다.
- 프로필 닉네임 저장 실패가 모두 `프로필 저장에 실패했어요.`로 뭉뚱그려 보이던 부분을 고쳐, 중복 닉네임은 `닉네임이 이미 사용 중이에요.`, 예약어 닉네임은 `사용할 수 없는 닉네임이에요.`처럼 회원가입 화면과 같은 맥락의 원인 안내로 분리했다.
- 로그인한 상태로 `login?resetToken=...` 비밀번호 재설정 링크를 열면 기존 로그인 감시가 바로 내 티어표 화면으로 보내버릴 수 있었으므로, 인증/재설정 토큰이 있는 동안에는 자동 리다이렉트를 멈추고 재설정 입력 화면을 우선 보여주도록 보정했다.
## 2026-04-03 v1.4.48
- 로컬 백엔드도 `.env.production`을 읽는 구조가 되면서 이메일 인증/비밀번호 재설정 링크의 `APP_ORIGIN`이 운영 도메인으로 잡히던 문제를 막기 위해, `backend``dev/start` 스크립트에서 로컬 실행 시 `APP_ORIGIN=http://localhost:5173`을 먼저 주입하도록 분리했다.
- 이로써 로컬 개발에서는 인증 메일 링크가 `localhost:5173`으로 열리고, 상용 Docker 배포에서는 `docker-compose.prod.yml``APP_ORIGIN=https://tmaker.sori.studio`를 그대로 사용하도록 환경이 구분된다.
## 2026-04-03 v1.4.47
- 로컬 개발 서버를 `npm run dev:backend`로 띄울 때 루트 `.env.production``SMTP_*` 값이 자동으로 들어가지 않아 일반 회원가입이 `mail_not_configured` 503으로 실패할 수 있었으므로, 백엔드 엔트리에서 `dotenv`로 루트 `.env.production`을 먼저 로드하도록 보강했다.
- 이 변경으로 Docker Compose 운영 환경은 기존 컨테이너 환경변수를 그대로 쓰면서, 로컬 개발 서버도 같은 `.env.production`의 Gmail SMTP 설정을 읽어 이메일 인증/비밀번호 재설정 메일 발송을 테스트할 수 있게 됐다.
## 2026-04-03 v1.4.46
- 운영용 `.env.production`에는 Git에 올리지 않는 로컬 비밀값을 유지한 채, Gmail SMTP 발송에 필요한 `APP_ORIGIN`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM` 항목을 추가했다.
- Git에 추적되는 `.env.production.example`에도 같은 SMTP 환경변수 예시를 추가해, 실제 배포 설정에서 어떤 키를 채워야 하는지 파일만 보고도 바로 알 수 있게 정리했다.
## 2026-04-03 v1.4.45
- Gmail SMTP를 사용하는 이메일 인증/비밀번호 재설정 1차 흐름을 추가했다. 첫 관리자 계정은 기존처럼 바로 활성화되지만, 일반 회원은 가입 직후 인증 메일을 받고 `login?verifyToken=...` 링크로 인증을 마쳐야 로그인할 수 있게 바꿨다.
- 로그인 화면에 `인증 메일 재전송`, `비밀번호를 잊으셨나요?`, `login?resetToken=...` 기반 새 비밀번호 설정 UI를 추가해, 메일 링크를 받은 사용자가 같은 `/login` 화면에서 인증 완료와 비밀번호 재설정을 이어서 처리할 수 있게 했다.
- 백엔드 `users``email_verified`를 추가하고, 이메일 인증/비밀번호 재설정 토큰을 해시로 저장하는 전용 테이블과 API를 추가했다. 운영 배포용 `docker-compose.prod.yml`에는 `APP_ORIGIN`, `SMTP_*` 환경변수 자리를 열어 Gmail 앱 비밀번호를 코드에 넣지 않고 주입할 수 있게 정리했다.
## 2026-04-03 v1.4.44
- 오른쪽 레일 공통 카피라이트의 `zenn` 링크가 민트 단색이라 라이트 모드에서 배경과 충분히 분리되지 않을 수 있었으므로, 테마 텍스트 색 기반의 굵은 링크 스타일로 바꿔 다크/라이트 양쪽에서 읽히도록 조정했다.
## 2026-04-03 v1.4.43
- 다른 사람 티어표를 복사한 직후 URL은 복사본 ID로 바뀌었는데 화면 데이터가 기존 원본에 남아 있을 수 있었던 문제를 고치기 위해, `TierEditorView`가 같은 컴포넌트 안에서 `topicId / tierListId / preview` 라우트 값이 바뀔 때마다 편집기 상태를 다시 로드하도록 바꿨다.
- 복사한 티어표 상단의 원본 링크를 클릭했을 때도 주소만 바뀌고 화면이 그대로 남지 않도록, 원본 이동 버튼이 같은 재로딩 흐름을 타게 정리했다.
- 작성자 본인 편집 모드에서 저장하지 않은 수정 내용이 있는 상태로 원본 링크를 누르면, 현재 변경 내용이 사라진다는 확인 모달을 먼저 띄우고 `저장 없이 이동`을 선택한 경우에만 원본 티어표로 이동하도록 보강했다.
## 2026-04-03 v1.4.42
- 홈 주제 템플릿 목록 정렬에서 수동 고정 순서가 같은 항목끼리 이름순으로 다시 정렬되던 부분을 바꿔, 즐겨찾기 우선과 관리자 수동 순서를 유지하되 수동 순서가 없는 템플릿은 최신 생성순으로 먼저 보이도록 맞췄다.
- 티어표 편집기에서 아이템을 클릭으로도 옮길 수 있게 해, 아이템을 한 번 클릭하면 선택 포커스가 표시되고 원하는 티어 셀이나 아이템 풀 빈 영역을 클릭하면 해당 위치로 이동하도록 보강했다.
- 클릭 배치와 기존 드래그 배치가 충돌하지 않도록 드래그 시작 시 선택 상태를 해제하고, 드래그 직후 짧은 시간 동안 아이템 클릭 선택을 무시하는 보호를 추가했다.
## 2026-04-03 v1.4.41
- 관리자 템플릿 기본 아이템 다중 업로드 제한을 한 번에 `100개`, 파일당 `20MB`까지 받을 수 있도록 백엔드 `multer` 설정과 업로드 라우트 배열 제한을 함께 상향했다.
- 프런트 Nginx 프록시에도 `client_max_body_size 1024m`을 추가해, 여러 이미지를 한 번의 `FormData` 요청으로 올릴 때 합산 본문 크기 제한 때문에 먼저 `413`으로 막히는 상황을 줄였다.

View File

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

View File

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

View File

@@ -23,6 +23,7 @@ const props = defineProps({
adminTierListTotal: { type: Number, required: true },
adminTierListStats: { type: Object, required: true },
openAdminTierListManageModal: { type: Function, required: true },
toggleAdminTierListFeatured: { type: Function, required: true },
moveAdminTierListPage: { type: Function, required: true },
})
</script>
@@ -128,6 +129,7 @@ const props = defineProps({
<div class="panel__title">전체 티어표 관리</div>
<div class="tierAdminHeaderStats">
<span class="pill">전체 {{ props.adminTierListStats.total || 0 }}</span>
<span class="pill pill--accent">추천 {{ props.adminTierListStats.featuredCount || 0 }}</span>
<span class="pill pill--soft">공개 {{ props.adminTierListStats.publicCount || 0 }}</span>
<span class="pill pill--soft">비공개 {{ props.adminTierListStats.privateCount || 0 }}</span>
</div>
@@ -156,6 +158,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>

View File

@@ -17,14 +17,11 @@ 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 },
openUserDeleteModal: { type: Function, required: true },
openUserEditModal: { type: Function, required: true },
lockResetIcon: { type: String, required: true },
deleteIcon: { type: String, required: true },
})
@@ -49,7 +46,7 @@ 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>
@@ -59,6 +56,8 @@ const userSortDirectionModel = computed({
<option value="recent">최근 활동순</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,6 +112,8 @@ 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>{{ 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.email }}</strong></div>
<div class="userInfoLine"><span>닉네임</span><strong>{{ user.nickname || '미설정' }}</strong></div>
@@ -120,15 +121,6 @@ const userSortDirectionModel = computed({
</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"

View File

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

View File

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

View File

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

View File

@@ -30,16 +30,28 @@ export const useAuthStore = defineStore('auth', {
return refreshPromise
},
async signup(email, nickname, password) {
const user = await api.signup({ email, nickname, password })
this.user = user
const data = await api.signup({ email, nickname, password })
this.user = data?.user || null
this.hydrated = true
return user
return data
},
async login(email, password) {
const user = await api.login({ email, password })
this.user = user
const data = await api.login({ email, password })
this.user = data?.user || null
this.hydrated = true
return user
return data?.user || null
},
async verifyEmail(token) {
const data = await api.verifyEmail({ token })
this.user = data?.user || null
this.hydrated = true
return this.user
},
async confirmPasswordReset(token, password) {
const data = await api.confirmPasswordReset({ token, password })
this.user = data?.user || null
this.hydrated = true
return this.user
},
async logout() {
await api.logout()

View File

@@ -4,7 +4,6 @@ import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api'
import { editorPath } 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'
@@ -51,10 +50,12 @@ 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)
@@ -277,6 +278,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}` },
@@ -823,6 +825,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 +843,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 +1319,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
@@ -1472,6 +1492,27 @@ async function deleteAdminTierListEntry() {
}
}
async function toggleAdminTierListFeatured(tierList) {
if (!tierList?.id) return
const nextFeatured = !tierList.isFeatured
resetMessages()
try {
const data = await api.updateAdminTierListFeatured(tierList.id, { isFeatured: nextFeatured })
const updated = data.tierList || {}
adminTierLists.value = adminTierLists.value.map((entry) => (entry.id === tierList.id ? { ...entry, ...updated } : entry))
if (previewTierList.value?.id === tierList.id) previewTierList.value = { ...previewTierList.value, ...updated }
if (modalTargetAdminTierList.value?.id === tierList.id) {
modalTargetAdminTierList.value = { ...modalTargetAdminTierList.value, ...updated }
}
await refreshAdminTierListStats()
success.value = nextFeatured ? '추천 티어표로 지정했어요.' : '추천 지정을 해제했어요.'
} catch (e) {
error.value =
e?.data?.error === 'public_tierlist_required' ? '공개 티어표만 추천으로 지정할 수 있어요.' : '추천 상태 변경에 실패했어요.'
}
}
function openAdminTierList(tierList) {
previewTierList.value = tierList
previewModalOpen.value = true
@@ -1782,6 +1823,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 +1843,11 @@ 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-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 +1921,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>
@@ -2326,6 +2340,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>

View File

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

View File

@@ -30,6 +30,9 @@ const templates = computed(() => {
const rankA = a.displayRank == null ? Number.MAX_SAFE_INTEGER : a.displayRank
const rankB = b.displayRank == null ? Number.MAX_SAFE_INTEGER : b.displayRank
if (rankA !== rankB) return rankA - rankB
if (Number(a.createdAt || 0) !== Number(b.createdAt || 0)) {
return Number(b.createdAt || 0) - Number(a.createdAt || 0)
}
return (a.name || '').localeCompare(b.name || '', 'ko')
})
})

View File

@@ -15,24 +15,92 @@ const password = ref('')
const passwordConfirm = ref('')
const mode = ref('login')
const error = ref('')
const notice = ref('')
const hasUsers = ref(true)
const emailError = ref('')
const nicknameError = ref('')
const pendingVerificationEmail = ref('')
const isSubmitting = ref(false)
const title = computed(() => (mode.value === 'signup' ? '회원가입' : '로그인'))
const description = computed(() =>
mode.value === 'signup'
? '티어표를 저장하고 즐겨찾기, 개인 설정을 관리할 수 있도록 계정을 만들어요.'
: '저장한 티어표와 즐겨찾기, 프로필 설정을 이어서 관리할 수 있어요.'
)
const submitLabel = computed(() => (mode.value === 'signup' ? '가입하기' : '로그인'))
const title = computed(() => {
if (mode.value === 'signup') return '회원가입'
if (mode.value === 'reset-request') return '비밀번호 재설정'
if (mode.value === 'reset-confirm') return '새 비밀번호 설정'
return '로그인'
})
const description = computed(() => {
if (mode.value === 'signup') return '티어표를 저장하고 즐겨찾기, 개인 설정을 관리할 수 있도록 계정을 만들어요.'
if (mode.value === 'reset-request') return '가입한 이메일로 비밀번호 재설정 링크를 보내드릴게요.'
if (mode.value === 'reset-confirm') return '메일로 받은 재설정 링크를 확인했어요. 새 비밀번호를 입력해주세요.'
return '저장한 티어표와 즐겨찾기, 프로필 설정을 이어서 관리할 수 있어요.'
})
const submitLabel = computed(() => {
if (mode.value === 'signup') return '가입하기'
if (mode.value === 'reset-request') return '재설정 메일 보내기'
if (mode.value === 'reset-confirm') return '새 비밀번호 저장'
return '로그인'
})
const authReady = computed(() => auth.hydrated)
const checkingSession = computed(() => !authReady.value || auth.status === 'loading')
const resetToken = computed(() => (typeof route.query.resetToken === 'string' ? route.query.resetToken : ''))
const verifyToken = computed(() => (typeof route.query.verifyToken === 'string' ? route.query.verifyToken : ''))
const redirectPath = computed(() => (typeof route.query.redirect === 'string' ? route.query.redirect : mePath()))
function clearFormFeedback() {
error.value = ''
emailError.value = ''
nicknameError.value = ''
}
function clearAuthQueryTokens() {
if (!resetToken.value && !verifyToken.value) return
const nextQuery = { ...route.query }
delete nextQuery.resetToken
delete nextQuery.verifyToken
router.replace({ path: route.path, query: nextQuery })
}
function switchMode(nextMode) {
if (mode.value === nextMode) return
mode.value = nextMode
clearFormFeedback()
notice.value = ''
pendingVerificationEmail.value = ''
password.value = ''
passwordConfirm.value = ''
if (nextMode !== 'signup') nickname.value = ''
if (nextMode !== 'reset-confirm') clearAuthQueryTokens()
}
async function completeEmailVerification(token) {
isSubmitting.value = true
try {
await auth.verifyEmail(token)
notice.value = '이메일 인증이 완료됐어요. 내 티어표 화면으로 이동합니다.'
router.replace(redirectPath.value)
} catch (e) {
mode.value = 'login'
error.value = '인증 링크가 만료되었거나 유효하지 않아요. 다시 로그인하거나 인증 메일을 재전송해주세요.'
clearAuthQueryTokens()
} finally {
isSubmitting.value = false
}
}
onMounted(async () => {
if (!auth.hydrated) await auth.refresh()
if (verifyToken.value) {
await completeEmailVerification(verifyToken.value)
return
}
if (resetToken.value) {
mode.value = 'reset-confirm'
password.value = ''
passwordConfirm.value = ''
return
}
if (auth.user) {
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : mePath())
router.replace(redirectPath.value)
return
}
try {
@@ -47,15 +115,14 @@ watch(
() => [auth.hydrated, auth.user],
([hydrated, user]) => {
if (!hydrated || !user) return
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : mePath())
if (verifyToken.value || resetToken.value) return
router.replace(redirectPath.value)
},
{ immediate: true }
)
watch(mode, () => {
error.value = ''
emailError.value = ''
nicknameError.value = ''
clearFormFeedback()
})
watch(email, () => {
@@ -68,23 +135,81 @@ watch(nickname, () => {
if (error.value === '닉네임이 이미 사용 중이에요.' || error.value === '사용할 수 없는 닉네임이에요.') error.value = ''
})
watch(
() => route.query.resetToken,
(value) => {
if (typeof value === 'string' && value) {
switchMode('reset-confirm')
}
}
)
async function resendVerificationEmail() {
const targetEmail = email.value.trim() || pendingVerificationEmail.value
if (!targetEmail) {
emailError.value = '이메일을 먼저 입력해주세요.'
error.value = '인증 메일을 다시 받을 이메일이 필요해요.'
return
}
clearFormFeedback()
isSubmitting.value = true
try {
await api.resendVerificationEmail({ email: targetEmail })
pendingVerificationEmail.value = targetEmail
notice.value = `${targetEmail} 주소로 인증 메일을 다시 보냈어요. 메일함과 스팸함을 함께 확인해주세요.`
} catch (e) {
const code = e?.data?.error
error.value = code === 'mail_not_configured'
? '메일 발송 설정이 아직 완료되지 않았어요. 잠시 후 다시 시도해주세요.'
: '인증 메일 재전송에 실패했어요.'
} finally {
isSubmitting.value = false
}
}
async function submit() {
error.value = ''
emailError.value = ''
nicknameError.value = ''
clearFormFeedback()
notice.value = ''
if (mode.value === 'signup' && nickname.value.trim().length < 2) {
nicknameError.value = '닉네임은 2자 이상 입력해주세요.'
error.value = '닉네임을 확인해주세요.'
return
}
if (mode.value === 'signup' && password.value !== passwordConfirm.value) {
if ((mode.value === 'signup' || mode.value === 'reset-confirm') && password.value !== passwordConfirm.value) {
error.value = '비밀번호 확인이 일치하지 않아요.'
return
}
if (mode.value === 'reset-confirm' && !resetToken.value) {
error.value = '재설정 토큰이 없어 비밀번호를 바꿀 수 없어요. 메일 링크를 다시 확인해주세요.'
return
}
isSubmitting.value = true
try {
if (mode.value === 'signup') await auth.signup(email.value, nickname.value, password.value)
else await auth.login(email.value, password.value)
router.push(typeof route.query.redirect === 'string' ? route.query.redirect : mePath())
if (mode.value === 'signup') {
const result = await auth.signup(email.value, nickname.value, password.value)
if (result?.verificationRequired) {
pendingVerificationEmail.value = result.email || email.value.trim()
mode.value = 'login'
password.value = ''
passwordConfirm.value = ''
notice.value = `${pendingVerificationEmail.value} 주소로 인증 메일을 보냈어요. 인증 후 로그인해주세요.`
return
}
} else if (mode.value === 'reset-request') {
const targetEmail = email.value.trim()
await api.requestPasswordReset({ email: targetEmail })
switchMode('login')
notice.value = `${targetEmail} 주소로 비밀번호 재설정 메일을 보냈어요. 메일함과 스팸함을 함께 확인해주세요.`
return
} else if (mode.value === 'reset-confirm') {
await auth.confirmPasswordReset(resetToken.value, password.value)
clearAuthQueryTokens()
} else {
await auth.login(email.value, password.value)
}
router.push(redirectPath.value)
} catch (e) {
const code = e?.data?.error
if (mode.value === 'signup') {
@@ -103,8 +228,35 @@ async function submit() {
error.value = '사용할 수 없는 닉네임이에요.'
return
}
if (code === 'mail_not_configured') {
error.value = '메일 발송 설정이 아직 완료되지 않아 이메일 인증을 보낼 수 없어요.'
return
}
if (code === 'mail_send_failed') {
error.value = '인증 메일 발송에 실패했어요. 잠시 후 다시 시도해주세요.'
return
}
}
error.value = mode.value === 'signup' ? '회원가입에 실패했어요.' : '로그인에 실패했어요.'
if (mode.value === 'login' && code === 'email_unverified') {
pendingVerificationEmail.value = e?.data?.email || email.value.trim()
error.value = '이메일 인증이 아직 완료되지 않았어요. 아래 버튼으로 인증 메일을 다시 받을 수 있어요.'
return
}
if (mode.value === 'reset-request') {
error.value = code === 'mail_not_configured'
? '메일 발송 설정이 아직 완료되지 않아 재설정 메일을 보낼 수 없어요.'
: '재설정 메일 발송에 실패했어요.'
return
}
if (mode.value === 'reset-confirm') {
error.value = code === 'invalid_or_expired_token'
? '재설정 링크가 만료되었거나 유효하지 않아요. 비밀번호 재설정을 다시 요청해주세요.'
: '새 비밀번호 저장에 실패했어요.'
return
}
error.value = '로그인에 실패했어요.'
} finally {
isSubmitting.value = false
}
}
</script>
@@ -126,18 +278,23 @@ async function submit() {
<section v-else class="authScreen">
<div class="authTabs" :class="{ 'authTabs--signup': mode === 'signup' }" role="tablist" aria-label="로그인 또는 회원가입">
<span class="authTabs__indicator" aria-hidden="true"></span>
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'login' }" @click="mode = 'login'">
<button
type="button"
class="authTabs__button"
:class="{ 'authTabs__button--active': mode === 'login' || mode === 'reset-request' || mode === 'reset-confirm' }"
@click="switchMode('login')"
>
로그인
</button>
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'signup' }" @click="mode = 'signup'">
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'signup' }" @click="switchMode('signup')">
회원가입
</button>
</div>
<form class="authFields" @submit.prevent="submit">
<label class="field">
<label v-if="mode !== 'reset-confirm'" class="field">
<span class="field__label">이메일</span>
<input v-model="email" class="field__input" placeholder="you@example.com" autocomplete="email" maxlength="255" />
<input v-model="email" class="field__input" type="email" placeholder="you@example.com" autocomplete="email" maxlength="255" />
<span v-if="emailError" class="field__error">{{ emailError }}</span>
<span class="field__hint">로그인과 알림에 사용되는 계정 이메일입니다. {{ email.length }}/255</span>
</label>
@@ -149,20 +306,20 @@ async function submit() {
<span class="field__hint">다른 사용자와 구분되는 이름으로 2~40자까지 입력할 있어요.</span>
</label>
<label class="field">
<span class="field__label">비밀번호</span>
<label v-if="mode !== 'reset-request'" class="field">
<span class="field__label">{{ mode === 'reset-confirm' ? '새 비밀번호' : '비밀번호' }}</span>
<input
v-model="password"
class="field__input"
type="password"
placeholder="********"
autocomplete="current-password"
:autocomplete="mode === 'login' ? 'current-password' : 'new-password'"
maxlength="120"
/>
<span class="field__hint">6~120 입력 가능 · {{ password.length }}/120</span>
</label>
<label v-if="mode === 'signup'" class="field">
<label v-if="mode === 'signup' || mode === 'reset-confirm'" class="field">
<span class="field__label">비밀번호 확인</span>
<input
v-model="passwordConfirm"
@@ -175,12 +332,28 @@ async function submit() {
<span class="field__hint">같은 비밀번호를 입력해주세요. {{ passwordConfirm.length }}/120</span>
</label>
<div v-if="!hasUsers" class="roleBadge"> 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>
<div v-if="mode === 'signup' && !hasUsers" class="roleBadge"> 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>
<div v-if="notice" class="authNotice">{{ notice }}</div>
<div v-if="error" class="authError">{{ error }}</div>
<div v-if="mode === 'login'" class="authHelpRow">
<button type="button" class="linkAction" @click="switchMode('reset-request')">비밀번호를 잊으셨나요?</button>
<button
v-if="pendingVerificationEmail || error === '이메일 인증이 아직 완료되지 않았어요. 아래 버튼으로 인증 메일을 다시 받을 수 있어요.'"
type="button"
class="linkAction"
:disabled="isSubmitting"
@click="resendVerificationEmail"
>
인증 메일 재전송
</button>
</div>
<div class="authActions">
<button class="secondaryAction" type="button" @click="router.push(homePath())">취소</button>
<button class="primaryAction" type="submit">{{ submitLabel }}</button>
<button class="secondaryAction" type="button" @click="mode === 'reset-request' || mode === 'reset-confirm' ? switchMode('login') : router.push(homePath())">
{{ mode === 'reset-request' || mode === 'reset-confirm' ? '로그인으로 돌아가기' : '취소' }}
</button>
<button class="primaryAction" type="submit" :disabled="isSubmitting">{{ isSubmitting ? '처리 중...' : submitLabel }}</button>
</div>
</form>
</section>
@@ -316,6 +489,40 @@ async function submit() {
font-weight: 700;
}
.authNotice {
padding: 10px 12px;
border-radius: 14px;
border: 1px solid rgba(34, 197, 94, 0.28);
background: rgba(34, 197, 94, 0.1);
color: #7ddf97;
font-size: 13px;
font-weight: 700;
}
.authHelpRow {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-top: -6px;
}
.linkAction {
border: 0;
background: transparent;
color: var(--theme-text-muted);
font-size: 13px;
font-weight: 700;
text-decoration: underline;
text-underline-offset: 3px;
cursor: pointer;
}
.linkAction:disabled {
opacity: 0.5;
cursor: progress;
}
.authActions {
display: flex;
gap: 12px;
@@ -337,6 +544,11 @@ async function submit() {
color: var(--theme-accent-text);
}
.primaryAction:disabled {
opacity: 0.65;
cursor: progress;
}
.secondaryAction {
border: 1px solid var(--theme-border-strong);
background: var(--theme-surface-soft);

View File

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

View File

@@ -46,7 +46,14 @@ async function loadResults() {
error.value = ''
try {
const data = await api.searchAllPublicTierLists(query.value)
tierLists.value = data.tierLists || []
const featuredItems = Array.isArray(data.featuredTierLists) ? data.featuredTierLists : []
const publicItems = Array.isArray(data.tierLists) ? data.tierLists : []
const seen = new Set()
tierLists.value = [...featuredItems, ...publicItems].filter((tierList) => {
if (!tierList?.id || seen.has(tierList.id)) return false
seen.add(tierList.id)
return true
})
} catch (e) {
error.value = '검색 결과를 불러오지 못했어요.'
} finally {

View File

@@ -1,5 +1,5 @@
<script setup>
import { Teleport, computed, inject, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { Teleport, computed, inject, nextTick, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Sortable from 'sortablejs'
import * as htmlToImage from 'html-to-image'
@@ -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'
@@ -56,8 +56,10 @@ const templateRequestDraftDescription = ref('')
const isDeleteModalOpen = ref(false)
const isGroupDeleteModalOpen = ref(false)
const isColumnDeleteModalOpen = ref(false)
const isNavigationConfirmModalOpen = ref(false)
const pendingRemoveGroupId = ref('')
const pendingRemoveColumnIndex = ref(-1)
const pendingNavigationPath = ref('')
const ownerId = ref('')
const authorName = ref('')
const authorAccountName = ref('')
@@ -74,6 +76,10 @@ const isFavorited = ref(false)
const isRequestingTemplate = ref(false)
const isDeleting = ref(false)
const poolSearchQuery = ref('')
const selectedItemId = ref('')
const recentDragFinishedAt = ref(0)
const savedEditorSnapshot = ref('')
let editorLoadToken = 0
const boardEl = ref(null)
const exportBoardEl = ref(null)
@@ -116,13 +122,15 @@ 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 = []
if (sourceSnapshotTitle.value) parts.push(`원본 ${sourceSnapshotTitle.value}`)
if (sourceSnapshotTitle.value) parts.push(sourceSnapshotTitle.value)
if (sourceSnapshotAuthor.value) parts.push(sourceSnapshotAuthor.value)
return parts.join(' · ') || '복사해 온 티어표'
})
@@ -278,6 +286,33 @@ function buildGroupPayload() {
}))
}
function createEditorSnapshot() {
return JSON.stringify({
title: (title.value || '').trim(),
description: description.value || '',
isPublic: !!isPublic.value,
showCharacterNames: !!showCharacterNames.value,
iconSize: Number(iconSize.value || 80),
columns: columns.value.map((column) => ({ id: column.id, name: column.name || '' })),
groups: buildGroupPayload(),
pool: pool.value.map((itemId) => {
const item = itemsById.value[itemId]
return {
id: item?.id || itemId,
src: item?.src || '',
label: item?.label || '',
origin: item?.origin || 'template',
}
}),
})
}
function syncSavedEditorSnapshot() {
savedEditorSnapshot.value = createEditorSnapshot()
}
const hasUnsavedChanges = computed(() => canEdit.value && savedEditorSnapshot.value && createEditorSnapshot() !== savedEditorSnapshot.value)
function removeItemFromGroup(groupId, columnIndex, itemId) {
if (!canEdit.value || !groupId || columnIndex == null || !itemId) return
const targetGroup = groups.value.find((group) => group.id === groupId)
@@ -287,6 +322,87 @@ function removeItemFromGroup(groupId, columnIndex, itemId) {
targetGroup.cells = nextCells
syncGroupItemIds(targetGroup)
pool.value = [itemId, ...pool.value.filter((id) => id !== itemId)]
if (selectedItemId.value === itemId) selectedItemId.value = ''
}
function shouldIgnoreItemClick() {
return Date.now() - recentDragFinishedAt.value < 180
}
function getItemLocation(itemId) {
if (!itemId) return { type: null, groupId: '', columnIndex: -1, index: -1 }
const poolIndex = pool.value.findIndex((id) => id === itemId)
if (poolIndex >= 0) {
return { type: 'pool', groupId: '', columnIndex: -1, index: poolIndex }
}
for (const group of groups.value) {
for (let columnIndex = 0; columnIndex < columns.value.length; columnIndex += 1) {
const index = getGroupCellIds(group, columnIndex).findIndex((id) => id === itemId)
if (index >= 0) {
return { type: 'group', groupId: group.id, columnIndex, index }
}
}
}
return { type: null, groupId: '', columnIndex: -1, index: -1 }
}
function detachItemById(itemId) {
if (!itemId) return
pool.value = pool.value.filter((id) => id !== itemId)
groups.value.forEach((group) => {
group.cells = (group.cells || []).map((cell) => (cell || []).filter((id) => id !== itemId))
syncGroupItemIds(group)
})
}
function selectItemByClick(itemId) {
if (!canEdit.value || !itemId || shouldIgnoreItemClick()) return
selectedItemId.value = selectedItemId.value === itemId ? '' : itemId
}
function placeSelectedItemInGroup(groupId, columnIndex) {
if (!canEdit.value || !selectedItemId.value || !groupId || !Number.isInteger(columnIndex)) return
if (shouldIgnoreItemClick()) return
const targetGroup = groups.value.find((group) => group.id === groupId)
if (!targetGroup) return
const selectedId = selectedItemId.value
const currentLocation = getItemLocation(selectedId)
const sameTarget =
currentLocation.type === 'group' &&
currentLocation.groupId === groupId &&
currentLocation.columnIndex === columnIndex
if (sameTarget) {
selectedItemId.value = ''
return
}
detachItemById(selectedId)
const nextCells = [...targetGroup.cells]
nextCells[columnIndex] = [...getGroupCellIds(targetGroup, columnIndex), selectedId]
targetGroup.cells = nextCells
syncGroupItemIds(targetGroup)
selectedItemId.value = ''
}
function moveSelectedItemToPool() {
if (!canEdit.value || !selectedItemId.value || shouldIgnoreItemClick()) return
const selectedId = selectedItemId.value
const currentLocation = getItemLocation(selectedId)
if (currentLocation.type === 'pool') {
selectedItemId.value = ''
return
}
detachItemById(selectedId)
pool.value = [selectedId, ...pool.value]
selectedItemId.value = ''
}
function setGroupDropEl(groupId, columnIndex, el) {
@@ -360,6 +476,12 @@ async function initSortables() {
draggable: '[data-item-id]',
ghostClass: 'ghost',
chosenClass: 'chosen',
onStart: () => {
selectedItemId.value = ''
},
onEnd: () => {
recentDragFinishedAt.value = Date.now()
},
onSort: () => normalizeSort(poolEl.value),
onAdd: () => normalizeSort(poolEl.value),
})
@@ -371,6 +493,12 @@ async function initSortables() {
draggable: '[data-item-id]',
ghostClass: 'ghost',
chosenClass: 'chosen',
onStart: () => {
selectedItemId.value = ''
},
onEnd: () => {
recentDragFinishedAt.value = Date.now()
},
onSort: () => normalizeSort(el),
onAdd: () => normalizeSort(el),
})
@@ -732,6 +860,8 @@ async function persistTierList({ showModal = false } = {}) {
authorAccountName.value = res.tierList?.authorAccountName || authorAccountName.value
favoriteCount.value = Number(res.tierList?.favoriteCount || favoriteCount.value || 0)
isFavorited.value = !!res.tierList?.isFavorited
await nextTick()
syncSavedEditorSnapshot()
if (showModal) isSaveModalOpen.value = true
return { ...res, savedTierListId }
}
@@ -784,6 +914,39 @@ function openEditMode() {
router.push(editorPath(templateId.value, persistedTierListId.value || tierListId.value))
}
function closeNavigationConfirmModal() {
isNavigationConfirmModalOpen.value = false
pendingNavigationPath.value = ''
}
function requestEditorNavigation(path) {
if (!path) return
if (hasUnsavedChanges.value) {
pendingNavigationPath.value = path
isNavigationConfirmModalOpen.value = true
return
}
router.push(path)
}
function confirmNavigationDiscard() {
const nextPath = pendingNavigationPath.value
closeNavigationConfirmModal()
if (!nextPath) return
savedEditorSnapshot.value = ''
router.push(nextPath)
}
function openSourceTierList() {
if (!sourceTierListId.value) return
requestEditorNavigation(editorPath(templateId.value, sourceTierListId.value))
}
function openAuthorProfile() {
if (!canOpenAuthorProfile.value) return
router.push(userProfilePath(ownerId.value))
}
function closeSaveModal() {
isSaveModalOpen.value = false
}
@@ -842,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')
@@ -923,88 +1089,156 @@ async function requestTemplate(type) {
}
}
onMounted(() => {
;(async () => {
await auth.refresh()
authorName.value = (auth.user?.nickname || '').trim()
authorAccountName.value = ((auth.user?.email || '').trim().split('@')[0] || '').trim()
function resetEditorStateForRoute() {
destroySortables()
if (thumbnailPreviewUrl.value) {
URL.revokeObjectURL(thumbnailPreviewUrl.value)
thumbnailPreviewUrl.value = ''
}
columns.value = [{ id: 'col-1', name: '' }]
groups.value = normalizeLoadedGroups([], columns.value)
pool.value = []
itemsById.value = {}
title.value = ''
persistedTierListId.value = ''
thumbnailSrc.value = ''
pendingThumbnailFile.value = null
description.value = ''
isPublic.value = true
showCharacterNames.value = false
isSaveModalOpen.value = false
isTemplateRequestModalOpen.value = false
isTemplateUpdateModalOpen.value = false
isDeleteModalOpen.value = false
isGroupDeleteModalOpen.value = false
isColumnDeleteModalOpen.value = false
isNavigationConfirmModalOpen.value = false
pendingRemoveGroupId.value = ''
pendingRemoveColumnIndex.value = -1
pendingNavigationPath.value = ''
ownerId.value = ''
authorName.value = ''
authorAccountName.value = ''
updatedAt.value = 0
sourceTierListId.value = ''
sourceSnapshotTitle.value = ''
sourceSnapshotAuthor.value = ''
isDragActive.value = false
isThumbnailDragActive.value = false
iconSize.value = 80
isFavoriteBusy.value = false
favoriteCount.value = 0
isFavorited.value = false
isRequestingTemplate.value = false
isDeleting.value = false
poolSearchQuery.value = ''
selectedItemId.value = ''
recentDragFinishedAt.value = 0
savedEditorSnapshot.value = ''
resetTemplateRequestDrafts()
}
if (isNewTierList.value && !auth.user) {
router.replace(loginPath(editorNewPath(templateId.value)))
return
}
async function loadEditorState() {
const loadToken = ++editorLoadToken
resetEditorStateForRoute()
await auth.refresh()
if (loadToken !== editorLoadToken) return
let currentTemplateItems = []
authorName.value = (auth.user?.nickname || '').trim()
authorAccountName.value = ((auth.user?.email || '').trim().split('@')[0] || '').trim()
if (isNewTierList.value && !auth.user) {
router.replace(loginPath(editorNewPath(templateId.value)))
return
}
let currentTemplateItems = []
try {
const topicRes = await api.getTopic(templateId.value)
if (loadToken !== editorLoadToken) return
templateName.value = topicRes.topic?.name || templateId.value
const base = (topicRes.items || []).map((img) => ({
id: img.id,
src: img.src,
label: img.label,
origin: 'template',
}))
currentTemplateItems = base
const map = {}
base.forEach((it) => (map[it.id] = it))
itemsById.value = map
pool.value = base.map((it) => it.id)
} catch (e) {
if (loadToken !== editorLoadToken) return
error.value = '기본 템플릿 이미지를 불러오지 못했어요.'
}
if (tierListId.value && tierListId.value !== 'new') {
try {
const topicRes = await api.getTopic(templateId.value)
templateName.value = topicRes.topic?.name || templateId.value
const base = (topicRes.items || []).map((img) => ({
id: img.id,
src: img.src,
label: img.label,
origin: 'template',
}))
currentTemplateItems = base
const map = {}
base.forEach((it) => (map[it.id] = it))
itemsById.value = map
pool.value = base.map((it) => it.id)
} catch (e) {
error.value = '기본 템플릿 이미지를 불러오지 못했어요.'
}
const res = await api.getTierList(tierListId.value)
if (loadToken !== editorLoadToken) return
if (tierListId.value && tierListId.value !== 'new') {
try {
const res = await api.getTierList(tierListId.value)
const t = res.tierList
ownerId.value = t.authorId
persistedTierListId.value = t.id || ''
title.value = t.title
thumbnailSrc.value = t.thumbnailSrc || ''
description.value = t.description || ''
isPublic.value = !!t.isPublic
showCharacterNames.value = !!t.showCharacterNames
iconSize.value = Number(t.iconSize || 80)
authorName.value = t.authorName || ''
authorAccountName.value = t.authorAccountName || ''
updatedAt.value = Number(t.updatedAt || 0)
sourceTierListId.value = t.sourceTierListId || ''
sourceSnapshotTitle.value = t.sourceSnapshotTitle || ''
sourceSnapshotAuthor.value = t.sourceSnapshotAuthor || ''
favoriteCount.value = Number(t.favoriteCount || 0)
isFavorited.value = !!t.isFavorited
const t = res.tierList
ownerId.value = t.authorId
persistedTierListId.value = t.id || ''
title.value = t.title
thumbnailSrc.value = t.thumbnailSrc || ''
description.value = t.description || ''
isPublic.value = !!t.isPublic
showCharacterNames.value = !!t.showCharacterNames
iconSize.value = Number(t.iconSize || 80)
authorName.value = t.authorName || ''
authorAccountName.value = t.authorAccountName || ''
updatedAt.value = Number(t.updatedAt || 0)
sourceTierListId.value = t.sourceTierListId || ''
sourceSnapshotTitle.value = t.sourceSnapshotTitle || ''
sourceSnapshotAuthor.value = t.sourceSnapshotAuthor || ''
favoriteCount.value = Number(t.favoriteCount || 0)
isFavorited.value = !!t.isFavorited
if (!previewMode.value && !canEdit.value) {
router.replace(editorPath(templateId.value, t.id, { preview: true }))
return
}
columns.value = normalizeLoadedColumns(t.groups)
groups.value = normalizeLoadedGroups(t.groups, columns.value)
const map = {}
;(t.pool || []).forEach((it) => (map[it.id] = it))
const grouped = new Set()
groups.value.forEach((group) => group.itemIds.forEach((id) => grouped.add(id)))
const merged = mergeLatestTemplateItemsIntoPool(
map,
Object.keys(map).filter((id) => !grouped.has(id)),
currentTemplateItems,
grouped,
canEdit.value && !previewMode.value
)
itemsById.value = merged.nextMap
pool.value = merged.nextPoolIds
} catch (e) {
error.value = '티어표를 불러오지 못했어요.'
if (!previewMode.value && !canEdit.value) {
router.replace(editorPath(templateId.value, t.id, { preview: true }))
return
}
}
await nextTick()
if (canEdit.value) {
await initSortables()
columns.value = normalizeLoadedColumns(t.groups)
groups.value = normalizeLoadedGroups(t.groups, columns.value)
const map = {}
;(t.pool || []).forEach((it) => (map[it.id] = it))
const grouped = new Set()
groups.value.forEach((group) => group.itemIds.forEach((id) => grouped.add(id)))
const merged = mergeLatestTemplateItemsIntoPool(
map,
Object.keys(map).filter((id) => !grouped.has(id)),
currentTemplateItems,
grouped,
canEdit.value && !previewMode.value
)
itemsById.value = merged.nextMap
pool.value = merged.nextPoolIds
} catch (e) {
if (loadToken !== editorLoadToken) return
error.value = '티어표를 불러오지 못했어요.'
}
})()
})
}
await nextTick()
if (loadToken !== editorLoadToken) return
syncSavedEditorSnapshot()
if (canEdit.value) {
await initSortables()
}
}
watch(
() => [route.params.topicId, route.params.tierListId, route.query.preview],
() => {
loadEditorState()
},
{ immediate: true }
)
onUnmounted(() => {
if (thumbnailPreviewUrl.value) URL.revokeObjectURL(thumbnailPreviewUrl.value)
@@ -1073,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>
@@ -1096,6 +1333,19 @@ onUnmounted(() => {
</div>
</div>
<div v-if="isNavigationConfirmModalOpen" class="modalOverlay" @click.self="closeNavigationConfirmModal">
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="navigationConfirmTitle">
<div id="navigationConfirmTitle" class="modalCard__title">원본 티어표로 이동</div>
<div class="modalCard__desc">
아직 저장하지 않은 수정 내용이 있어요. 이대로 이동하면 현재 변경 내용은 사라집니다.
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" type="button" @click="closeNavigationConfirmModal">계속 편집</button>
<button class="btn btn--danger" type="button" @click="confirmNavigationDiscard">저장 없이 이동</button>
</div>
</div>
</div>
<div v-if="isTemplateRequestModalOpen" class="modalOverlay" @click.self="closeTemplateRequestModal">
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="templateRequestTitle">
<div id="templateRequestTitle" class="modalCard__title">템플릿 등록 요청</div>
@@ -1213,8 +1463,8 @@ onUnmounted(() => {
</template>
</div>
<div v-if="sourceTierListId" class="editorMain__sourceNote">
<span>복사</span>
<button class="editorMain__sourceLink" type="button" @click="router.push(editorPath(templateId, sourceTierListId))">{{ copiedFromLabel }}</button>
<span></span>
<button class="editorMain__sourceLink" type="button" @click="openSourceTierList">{{ copiedFromLabel }}</button>
</div>
</div>
</section>
@@ -1294,10 +1544,19 @@ onUnmounted(() => {
:data-group-id="g.id"
:data-column-index="columnIndex"
:ref="(el) => setGroupDropEl(g.id, columnIndex, el)"
:class="{ 'row__drop--clickTarget': canEdit && !!selectedItemId }"
@click="placeSelectedItemInGroup(g.id, columnIndex)"
>
<div v-if="columns.length > 1" class="row__columnBadge">{{ column.name || ' ' + (columnIndex + 1) }}</div>
<div v-if="!isExporting" class="row__empty" v-show="getGroupCellIds(g, columnIndex).length === 0">여기로 드래그해서 배치</div>
<div v-for="id in getGroupCellIds(g, columnIndex)" :key="id" class="cell" :data-item-id="id">
<div
v-for="id in getGroupCellIds(g, columnIndex)"
:key="id"
class="cell"
:class="{ 'cell--selected': selectedItemId === id }"
:data-item-id="id"
@click.stop="selectItemByClick(id)"
>
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
<button
@@ -1351,7 +1610,7 @@ onUnmounted(() => {
<div class="sidebar__count">{{ visiblePoolCount }} / {{ pool.length }}</div>
</div>
<div class="sidebar__hint">
{{ canEdit ? '등록된 아이템 리스트입니다. 드래그해서 표에 넣을 수 있습니다.' : '공개 티어표는 보기 전용입니다.' }}
{{ canEdit ? '아이템을 드래그하거나, 클릭으로 선택한 뒤 원하는 셀/풀을 클릭해서 옮길 수 있어요.' : '공개 티어표는 보기 전용입니다.' }}
</div>
<input
v-model="poolSearchQuery"
@@ -1360,13 +1619,24 @@ onUnmounted(() => {
maxlength="60"
placeholder="아이템 이름 검색"
/>
<div ref="poolEl" class="pool" data-list-type="pool">
<div
ref="poolEl"
class="pool"
:class="{ 'pool--clickTarget': canEdit && !!selectedItemId }"
data-list-type="pool"
@click.self="moveSelectedItemToPool"
>
<div
v-for="id in pool"
:key="id"
class="poolItem"
:class="{ 'poolItem--readonly': !canEdit, 'poolItem--hidden': !isPoolItemVisible(id) }"
:class="{
'poolItem--readonly': !canEdit,
'poolItem--hidden': !isPoolItemVisible(id),
'poolItem--selected': selectedItemId === id,
}"
:data-item-id="id"
@click.stop="selectItemByClick(id)"
>
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
@@ -1473,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"
@@ -2214,6 +2485,11 @@ onUnmounted(() => {
overflow: hidden;
position: relative;
}
.row__drop--clickTarget {
cursor: copy;
border-color: rgba(96, 165, 250, 0.42);
box-shadow: inset 0 0 0 1px rgba(96, 165, 250, 0.12);
}
.row__empty {
opacity: 0.6;
font-size: 13px;
@@ -2227,6 +2503,11 @@ onUnmounted(() => {
display: inline-flex;
flex: 0 0 auto;
position: relative;
cursor: pointer;
border-radius: 12px;
}
.cell--selected {
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.92), 0 0 0 6px rgba(96, 165, 250, 0.18);
}
.itemNameOverlay {
position: absolute;
@@ -2606,6 +2887,9 @@ onUnmounted(() => {
gap: 10px;
align-content: start;
}
.pool--clickTarget {
cursor: copy;
}
.poolItem {
min-width: 0;
display: grid;
@@ -2617,11 +2901,17 @@ onUnmounted(() => {
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.10);
background: var(--theme-pill-bg);
cursor: pointer;
}
.poolItem--readonly {
cursor: default;
opacity: 0.58;
filter: grayscale(0.25) brightness(0.78);
}
.poolItem--selected {
border-color: rgba(96, 165, 250, 0.58);
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.92), 0 0 0 6px rgba(96, 165, 250, 0.18);
}
.poolItem .thumb {
width: 100%;
max-width: var(--thumb-size, 80px);

View File

@@ -12,6 +12,7 @@ const auth = useAuthStore()
const topicId = computed(() => route.params.topicId)
const topicName = ref('')
const featuredTierLists = ref([])
const tierLists = ref([])
const error = ref('')
const query = ref('')
@@ -19,6 +20,7 @@ const brokenThumbnailIds = ref({})
const isTopicLoading = ref(false)
const isListView = computed(() => route.query.view === 'list')
const topicTitle = computed(() => topicName.value || (isTopicLoading.value ? '주제 불러오는 중...' : ''))
const publicTierLists = computed(() => tierLists.value.filter((tierList) => !tierList.isFeatured))
function fmt(ts) {
return new Date(ts).toLocaleDateString(undefined, {
@@ -59,6 +61,7 @@ async function loadTierLists() {
])
topicName.value = topicRes.topic?.name || ''
brokenThumbnailIds.value = {}
featuredTierLists.value = listRes.featuredTierLists || []
tierLists.value = listRes.tierLists || []
} catch (e) {
error.value = '주제 정보를 불러오지 못했어요.'
@@ -110,10 +113,65 @@ watch(
</section>
<div v-if="error" class="error">{{ error }}</div>
<section v-if="featuredTierLists.length" class="featuredPanel">
<div class="featuredHead">
<div>
<div class="featuredHead__eyebrow">Featured</div>
<h3 class="featuredHead__title">추천 티어표</h3>
</div>
<div class="featuredHead__count">{{ featuredTierLists.length }}</div>
</div>
<div class="list featuredList" :class="{ 'list--table': isListView }">
<article
v-for="t in featuredTierLists"
:key="`featured-${t.id}`"
class="boardCard boardCard--featured"
:class="{ 'boardCard--list': isListView }"
>
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" @click="openTierList(t.id)">
<div class="boardCard__thumbWrap">
<img
v-if="tierListThumbnailUrl(t)"
class="boardCard__thumb"
:src="tierListThumbnailUrl(t)"
alt=""
draggable="false"
@error="handleThumbnailError(t.id)"
/>
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div>
<div class="boardCard__head">
<div class="boardCard__titleRow">
<div class="boardCard__title">{{ t.title }}</div>
<div class="favoriteStat" :title="t.isFavorited ? '이미 즐겨찾기한 티어표' : '즐겨찾기 수'">
{{ t.isFavorited ? '♥' : '♡' }} {{ t.favoriteCount || 0 }}
</div>
</div>
<div class="boardCard__metaRow">
<div class="boardCard__author">
<img
v-if="avatarSrcOf(t)"
class="boardCard__avatar"
:src="avatarSrcOf(t)"
:alt="displayNameOf(t)"
draggable="false"
/>
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
</div>
<div class="boardCard__date">{{ fmt(t.updatedAt) }}</div>
</div>
</div>
</button>
</article>
</div>
</section>
<section class="panel">
<div v-if="tierLists.length === 0" class="empty">아직 공개 티어표 없어요.</div>
<div class="sectionLabel">전체 공개 티어표</div>
<div v-if="publicTierLists.length === 0" class="empty">아직 일반 공개 티어표가 없어요.</div>
<div v-else class="list" :class="{ 'list--table': isListView }">
<article v-for="t in tierLists" :key="t.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
<article v-for="t in publicTierLists" :key="t.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" @click="openTierList(t.id)">
<div class="boardCard__thumbWrap">
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" alt="" draggable="false" @error="handleThumbnailError(t.id)" />
@@ -148,6 +206,44 @@ watch(
border-radius: 0;
padding: 0;
}
.featuredPanel {
margin-bottom: 28px;
padding: 24px;
border-radius: 28px;
border: 1px solid var(--theme-card-border);
background: linear-gradient(180deg, var(--theme-surface-soft) 0%, var(--theme-surface) 100%);
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
}
.featuredHead {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.featuredHead__eyebrow,
.sectionLabel {
font-size: 11px;
font-weight: 900;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--theme-text-faint);
}
.featuredHead__title {
margin: 6px 0 0;
font-size: 22px;
font-weight: 900;
color: var(--theme-text);
}
.featuredHead__count {
flex: 0 0 auto;
font-size: 13px;
font-weight: 800;
color: var(--theme-text-muted);
}
.sectionLabel {
margin-bottom: 14px;
}
.toolbar {
display: flex;
gap: 10px;
@@ -206,6 +302,12 @@ watch(
background: var(--theme-card-bg-hover);
transform: translateY(-2px);
}
.boardCard--featured {
border-color: color-mix(in srgb, var(--theme-accent) 35%, var(--theme-card-border));
background:
linear-gradient(180deg, color-mix(in srgb, var(--theme-accent) 7%, transparent), transparent 55%),
var(--theme-card-bg);
}
.boardCard__body {
min-width: 0;
text-align: left;
@@ -361,6 +463,17 @@ watch(
}
@media (max-width: 720px) {
.featuredPanel {
padding: 18px;
border-radius: 22px;
}
.featuredHead {
align-items: flex-start;
flex-direction: column;
gap: 8px;
}
.list {
grid-template-columns: 1fr;
}

View File

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