Compare commits

..

12 Commits

27 changed files with 1848 additions and 207 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,10 +1,13 @@
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')

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,6 +59,7 @@ 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),
@@ -139,6 +140,9 @@ function mapTierListRow(row) {
thumbnailSrc: row.thumbnail_src || '',
description: row.description || '',
isPublic: !!row.is_public,
isFeatured: !!row.is_featured,
featuredAt: Number(row.featured_at || 0),
featuredBy: row.featured_by || '',
showCharacterNames: !!row.show_character_names,
iconSize: Number(row.icon_size || 80),
sourceTierListId: row.source_tierlist_id || '',
@@ -275,12 +279,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 +381,9 @@ async function ensureSchema() {
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
description TEXT NOT NULL,
is_public TINYINT(1) NOT NULL DEFAULT 0,
is_featured TINYINT(1) NOT NULL DEFAULT 0,
featured_at BIGINT NOT NULL DEFAULT 0,
featured_by VARCHAR(64) NOT NULL DEFAULT '',
show_character_names TINYINT(1) NOT NULL DEFAULT 0,
icon_size INT NOT NULL DEFAULT 80,
source_tierlist_id VARCHAR(64) NULL DEFAULT NULL,
@@ -354,6 +396,7 @@ async function ensureSchema() {
INDEX idx_tierlists_author_id (author_id),
INDEX idx_tierlists_topic_id (topic_id),
INDEX idx_tierlists_public_topic_updated (is_public, topic_id, updated_at),
INDEX idx_tierlists_featured_topic (is_public, is_featured, topic_id, featured_at),
CONSTRAINT fk_tierlists_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_tierlists_topic FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
@@ -489,6 +532,18 @@ async function ensureSchema() {
if (!tierListShowNamesColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN show_character_names TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public")
}
const tierListFeaturedColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'is_featured'")
if (!tierListFeaturedColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN is_featured TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public")
}
const tierListFeaturedAtColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'featured_at'")
if (!tierListFeaturedAtColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN featured_at BIGINT NOT NULL DEFAULT 0 AFTER is_featured")
}
const tierListFeaturedByColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'featured_by'")
if (!tierListFeaturedByColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN featured_by VARCHAR(64) NOT NULL DEFAULT '' AFTER featured_at")
}
const tierListIconSizeColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'icon_size'")
if (!tierListIconSizeColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN icon_size INT NOT NULL DEFAULT 80 AFTER show_character_names")
@@ -567,7 +622,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 +636,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 +645,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 +659,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 +802,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])
}
@@ -668,6 +837,7 @@ 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,
@@ -679,7 +849,7 @@ async function listUsers({ queryText = '', sort = 'recent', direction = 'desc' }
FROM users u
LEFT JOIN tierlists t ON t.author_id = u.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 +2016,9 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '')
t.topic_id,
t.title,
t.thumbnail_src,
t.is_featured,
t.featured_at,
t.featured_by,
t.created_at,
t.updated_at,
t.author_id,
@@ -1855,8 +2028,8 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '')
FROM tierlists t
INNER JOIN users u ON u.id = t.author_id
${whereClause}
ORDER BY t.updated_at DESC
LIMIT 50
ORDER BY t.is_featured DESC, t.featured_at DESC, t.updated_at DESC
LIMIT 200
`,
params
)
@@ -1866,6 +2039,8 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '')
topicId: row.topic_id,
title: row.title,
thumbnailSrc: row.thumbnail_src || '',
isFeatured: !!row.is_featured,
featuredAt: Number(row.featured_at || 0),
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
authorId: row.author_id,
@@ -1878,7 +2053,22 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '')
tierLists.map((tierList) => tierList.id),
currentUserId
)
return applyFavoriteMetaToTierLists(tierLists, favoriteStats)
const mergedTierLists = applyFavoriteMetaToTierLists(tierLists, favoriteStats)
const featuredTierLists = mergedTierLists
.filter((tierList) => tierList.isFeatured)
.slice()
.sort(
(a, b) =>
Number(b.featuredAt || 0) - Number(a.featuredAt || 0) ||
Number(b.favoriteCount || 0) - Number(a.favoriteCount || 0) ||
Number(b.updatedAt || 0) - Number(a.updatedAt || 0)
)
.slice(0, 16)
return {
featuredTierLists,
tierLists: mergedTierLists.filter((tierList) => !tierList.isFeatured),
}
}
async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited' } = {}) {
@@ -1911,6 +2101,9 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited
t.thumbnail_src,
t.description,
t.is_public,
t.is_featured,
t.featured_at,
t.featured_by,
t.show_character_names,
t.icon_size,
t.source_tierlist_id,
@@ -2056,6 +2249,9 @@ async function listAdminTierLists({ queryText = '', topicId = '', page = 1, limi
t.thumbnail_src,
t.description,
t.is_public,
t.is_featured,
t.featured_at,
t.featured_by,
t.show_character_names,
t.icon_size,
t.source_tierlist_id,
@@ -2131,7 +2327,7 @@ async function summarizeAdminTierLists({ queryText = '', topicId = '' } = {}) {
const whereClause = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''
const rows = await query(
`
SELECT t.is_public
SELECT t.is_public, t.is_featured
FROM tierlists t
INNER JOIN users u ON u.id = t.author_id
INNER JOIN topics tp ON tp.id = t.topic_id
@@ -2142,10 +2338,12 @@ async function summarizeAdminTierLists({ queryText = '', topicId = '' } = {}) {
const total = rows.length
const publicCount = rows.filter((row) => Number(row.is_public) === 1).length
const featuredCount = rows.filter((row) => Number(row.is_featured) === 1).length
return {
total,
publicCount,
privateCount: Math.max(0, total - publicCount),
featuredCount,
}
}
@@ -2161,6 +2359,9 @@ async function findTierListById(id, currentUserId = '') {
t.thumbnail_src,
t.description,
t.is_public,
t.is_featured,
t.featured_at,
t.featured_by,
t.show_character_names,
t.icon_size,
t.source_tierlist_id,
@@ -2369,13 +2570,38 @@ async function deleteTierList(id) {
}
async function updateAdminTierListMeta({ id, title, description = '', isPublic }) {
const nextUpdatedAt = now()
if (!isPublic) {
await query(
`
UPDATE tierlists
SET title = ?, description = ?, is_public = 0, is_featured = 0, featured_at = 0, featured_by = '', updated_at = ?
WHERE id = ?
`,
[title, description || '', nextUpdatedAt, id]
)
return findTierListById(id)
}
await query(
`
UPDATE tierlists
SET title = ?, description = ?, is_public = ?, updated_at = ?
WHERE id = ?
`,
[title, description || '', isPublic ? 1 : 0, now(), id]
[title, description || '', 1, nextUpdatedAt, id]
)
return findTierListById(id)
}
async function updateTierListFeaturedStatus({ id, isFeatured, adminUserId }) {
await query(
`
UPDATE tierlists
SET is_featured = ?, featured_at = ?, featured_by = ?
WHERE id = ?
`,
[isFeatured ? 1 : 0, isFeatured ? now() : 0, isFeatured ? adminUserId || '' : '', id]
)
return findTierListById(id)
}
@@ -2501,6 +2727,14 @@ module.exports = {
findUserByNickname,
findUserById,
createUser,
updateUserPassword,
verifyUserEmail,
createEmailVerificationToken,
findEmailVerificationTokenByHash,
consumeEmailVerificationToken,
createPasswordResetToken,
findPasswordResetTokenByHash,
consumePasswordResetToken,
updateUserProfile,
findPrimaryAdminUser,
listUsers,
@@ -2551,6 +2785,7 @@ module.exports = {
summarizeAdminTierLists,
findTierListById,
updateAdminTierListMeta,
updateTierListFeaturedStatus,
favoriteTopic,
unfavoriteTopic,
favoriteTierList,

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,
@@ -89,7 +90,7 @@ function buildItemLabelFromSrc(src) {
return normalized || 'item'
}
const upload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024, maxCount: 50 })
const upload = createMemoryUpload(multer, { fileSize: 20 * 1024 * 1024, maxCount: 100 })
const avatarUpload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 })
function decorateAdminUser(user, primaryAdmin) {
@@ -199,7 +200,7 @@ router.post('/templates/:templateId/thumbnail', requireAdmin, upload.single('thu
res.json({ template: updated })
})
router.post('/templates/:templateId/images', requireAdmin, upload.array('images', 50), async (req, res) => {
router.post('/templates/:templateId/images', requireAdmin, upload.array('images', 100), async (req, res) => {
const files = Array.isArray(req.files) ? req.files : []
if (!files.length) return res.status(400).json({ error: 'file_required' })
const templateId = getTemplateIdFromParams(req)
@@ -380,6 +381,25 @@ router.get('/tierlists/stats', requireAdmin, async (req, res) => {
res.json(result)
})
router.patch('/tierlists/:tierListId/featured', requireAdmin, async (req, res) => {
const schema = z.object({
isFeatured: z.boolean(),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const tierList = await findTierListById(req.params.tierListId, req.session?.userId || '')
if (!tierList) return res.status(404).json({ error: 'not_found' })
if (parsed.data.isFeatured && !tierList.isPublic) return res.status(400).json({ error: 'public_tierlist_required' })
const updated = await updateTierListFeaturedStatus({
id: tierList.id,
isFeatured: parsed.data.isFeatured,
adminUserId: req.session.userId,
})
res.json({ tierList: updated })
})
router.get('/template-requests', requireAdmin, async (req, res) => {
const requests = await listAdminTemplateRequests({ statuses: ['pending', 'reviewing'] })
res.json({ requests })

View File

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

@@ -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,36 @@
# 의사결정 이력
## 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`를 그대로 두면 병목이 남을 수 있으므로 프런트 프록시 제한도 함께 상향하는 쪽으로 정리했다.
## 2026-04-03 v1.4.40
- 공유 링크 진입 화면은 사용자가 조작 가능한 편집 화면보다 `뷰어 모드`로 명확히 분리하는 편이 안전하므로, 비로그인 사용자와 타인 티어표는 기본적으로 드래그 없는 완성본 열람 상태를 보여주기로 정리했다.
- 공유 링크를 받은 비로그인 사용자도 다시 전달할 수 있어야 하므로 `공유하기`는 로그인 여부와 무관하게 뷰어 모드에서 열어두고, `내 티어표로 복사`는 로그인한 타인 열람자에게만 노출하는 쪽으로 권한을 나눴다.

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`
@@ -37,13 +37,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 +56,7 @@
- 로컬 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/admin.js`

View File

@@ -45,7 +45,7 @@
- 좌우 레일의 주요 CTA는 스크롤되는 본문과 분리된 하단 `56px` 액션 영역에 배치한다.
- 하단 액션은 화면 바닥에 바로 붙지 않도록 푸터 내부에 추가 하단 여백을 둔다.
- 홈 화면 기준 우측 패널은 임시 정보 카드 여러 개보다 핵심 CTA 하나만 남겨, 시안처럼 단순한 보조 레일 역할을 우선 유지한다.
- 광고 영역은 상단 헤더와 시각적으로 너무 붙지 않도록 `78px` 상단 여백을 두고, 하단 카피라이트는 중앙 정렬된 공통 footer로 표시한다.
- 광고 영역은 상단 헤더와 시각적으로 너무 붙지 않도록 `78px` 상단 여백을 두고, 하단 카피라이트는 중앙 정렬된 공통 footer로 표시한다. 카피라이트 링크는 다크/라이트 테마 모두에서 읽히도록 고정 민트색 대신 테마 텍스트 색과 굵기를 사용한다.
- 티어표 편집 화면
- 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다.
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 보드 영역은 메인 컬럼에, 우측 `320px` 편집 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
@@ -64,9 +64,24 @@
- `email`: string
- `nickname`: string
- `passwordHash`: string
- `emailVerified`: boolean
- `isAdmin`: boolean
- `avatarSrc`: string
- `createdAt`: number
- `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 +109,9 @@
- 사용자가 직접 지정하지 않으면 저장 시 티어표 대표 아이템 이미지로 자동 채운다.
- `description`: string
- `isPublic`: boolean
- `isFeatured`: boolean
- `featuredAt`: number
- `featuredBy`: string
- `groups`: `{ id, name, itemIds[] }[]`
- `pool`: `{ id, src, label, origin }[]`
- `createdAt`: number
@@ -110,16 +128,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`
@@ -135,9 +166,11 @@
- `POST /api/admin/templates`
- `POST /api/admin/templates/:templateId/thumbnail`
- `POST /api/admin/templates/:templateId/images`
- 여러 이미지를 한 번에 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다.
- 여러 이미지를 한 번에 최대 `100개`까지 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다.
- `PATCH /api/admin/templates/:templateId/items/:itemId`
- `GET /api/admin/tierlists`
- `GET /api/admin/tierlists/stats`
- `PATCH /api/admin/tierlists/:tierListId/featured`
- `GET /api/admin/template-requests`
- `POST /api/admin/template-requests/:requestId/approve`
- `POST /api/admin/template-requests/:requestId/reject`
@@ -167,6 +200,7 @@
- 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다.
- 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다.
- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 내부에서 `템플릿 요청 관리 / 전체 티어표 관리`를 분리해 볼 수 있고, 확인용 완성본은 탐색 UI 없는 preview 전용 모달로 연다.
- `전체 티어표 관리`에서는 공개 티어표를 `추천 지정 / 추천 해제`할 수 있고, 추천 지정된 티어표는 주제별 공개 목록 상단의 `추천 티어표` 섹션에 먼저 노출된다. 비공개 티어표는 추천 지정할 수 없고, 추천글을 비공개로 바꾸면 추천 상태도 함께 해제된다.
- `티어표 관리` 탭의 추가 아이템은 작은 그리드 카드로 표시하고, 클릭 시 `기존 템플릿에 추가 / 새 템플릿 만들기` 모달을 통해 목적지를 선택한다.
- `티어표 관리` 탭에서는 티어표 안의 커스텀 아이템을 개별 또는 일괄로 기존 게임 템플릿에 복제할 수 있다.
- `freeform` 티어표는 관리자 화면에서 새 게임 ID/이름을 입력해 새로운 게임 템플릿으로 복제 생성할 수 있다.
@@ -184,15 +218,20 @@
- 비로그인 사용자도 뷰어 모드 우측 레일의 `공유하기` 버튼으로 현재 공유 링크를 복사할 수 있다.
- 로그인한 타인 티어표 열람자는 뷰어 모드 우측 레일에서 `내 티어표로 복사`를 사용할 수 있고, 작성자 본인은 `수정 모드로 전환`을 사용할 수 있다.
- 작성자 본인이 일반 편집 화면에서 저장된 본인 티어표를 보고 있을 때는 우측 패널의 `뷰어 모드로 보기`로 공유 화면 형태를 바로 확인할 수 있다.
- 같은 `TierEditorView` 안에서 `topicId / tierListId / preview` 라우트 값만 바뀌어도 화면이 이전 티어표 데이터에 남지 않도록, 라우트 변경마다 에디터 상태를 다시 로드한다.
- 복사한 티어표 상단의 `원본` 링크를 누르면 원본 티어표로 이동하며, 편집 모드에서 저장하지 않은 변경이 있으면 이동 전에 `저장 없이 이동` 확인 모달을 먼저 띄운다.
- 비공개 티어표라도 관리자는 편집 화면에서 완성본을 열람할 수 있다.
- 공개 티어표는 목록과 상세 화면에서 즐겨찾기 토글 및 개수를 함께 표시한다.
- 카드형 목록에서는 즐겨찾기 수/상태만 표시하고, 실제 토글은 상세 화면에서 처리한다.
- 공개 티어표 목록은 현재 게임 기준으로 제목/작성자 검색을 지원한다.
- 주제별 공개 티어표 화면은 관리자 추천글을 상단 `추천 티어표` 섹션으로 먼저 보여주고, 일반 공개 목록은 아래 `전체 공개 티어표` 섹션으로 분리해 중복 없이 렌더링한다. 추천 섹션은 최대 16개까지 표시한다.
- `내 즐겨찾기` 화면에서는 즐겨찾기한 순, 최신 업데이트순, 인기순 정렬을 제공한다.
- 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다.
- 사용자가 직접 추가한 커스텀 아이템 이름은 편집 화면 우측 목록에서 정리할 수 있고, 저장 시 원본 커스텀 아이템 라벨과 함께 동기화된다.
- 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다.
- 티어 행에 넣은 아이템은 작은 제거 버튼으로 다시 우측 아이템 풀로 되돌릴 수 있다.
- 편집 가능한 티어표에서는 아이템을 드래그로 옮길 수 있고, 아이템을 클릭해 선택한 뒤 원하는 셀이나 아이템 풀 빈 영역을 클릭하는 방식으로도 위치를 바꿀 수 있다.
- 클릭 배치 모드에서 선택된 아이템은 파란 포커스 테두리로 표시하며, 드래그를 시작하면 선택 상태를 해제하고 드래그 직후 짧은 클릭 입력은 무시해 드래그/클릭 조작이 섞이지 않게 한다.
- 기존 저장 티어표/복사본을 수정 가능한 상태로 다시 열 때는 저장본의 그룹/풀을 먼저 복원하고, 이후 현재 템플릿에 새로 추가된 기본 아이템만 미사용 풀 끝에 병합한다.
- 관리자 템플릿에서 삭제된 기본 아이템이라도 이미 저장된 티어표의 그룹/풀에 포함되어 있던 항목은 자동 제거하지 않고 그대로 보존한다.
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
@@ -216,11 +255,14 @@
- 저장/삭제/가져오기 같은 사용자 행동 피드백은 전역 우측 상단 토스트로 표시한다.
- 전역 토스트는 동일 메시지/타입이 연속 발생하면 하나로 합쳐 카운트를 올리고, 종료 시 짧은 페이드아웃 애니메이션을 사용한다.
- 홈 게임 목록은 관리자 상단 고정 순서가 있으면 그 순서를 먼저 적용하고, 그 외 게임은 최근 생성순으로 뒤에 이어진다.
- 홈 주제 템플릿 목록의 실제 정렬 우선순위는 `즐겨찾기 여부 → 관리자 수동 순서(displayRank) → 최신 생성순(createdAt DESC) → 이름순`이다.
- `커스텀 티어표 만들기`는 카드가 아니라 홈 우측 상단 버튼으로 진입한다.
## 업로드 제한 메모
- 프로필 아바타 업로드는 파일당 최대 `3MB`다.
- 관리자 게임 썸네일/기본 아이템 업로드와 사용자 커스텀 이미지 업로드는 파일당 최대 `6MB`다.
- 관리자 템플릿 썸네일/기본 아이템 업로드는 파일당 최대 `20MB`다.
- 사용자 커스텀 이미지 업로드는 파일당 최대 `6MB`다.
- 운영 프런트 Nginx는 다중 이미지 업로드 한 번의 요청 본문을 최대 `1024MB`까지 허용한다.
- 현재는 업로드 전에 이미지 리사이즈/압축을 하지 않고 원본 파일을 그대로 저장한다.
## 운영 환경 변수
@@ -238,6 +280,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,29 @@
# 할 일 및 이슈
## 단기 확인
- `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`로 바뀌는지 본다.
- 작성자 본인 편집 화면에는 `뷰어 모드로 보기`가 추가됐으므로, 저장된 본인 티어표에서 뷰어 모드로 진입한 뒤 다시 `수정 모드로 전환`으로 돌아오는 왕복 라우팅이 자연스러운지 QA한다.
- 뷰어 모드 오른쪽 레일이 공통 로컬 레일 마운트를 다시 사용하게 바뀌었으므로, 데스크톱/태블릿 폭에서 광고가 상단에 나오고 액션 카드가 하단으로 내려가며, 우측 레일 접기/펼치기 시 콘텐츠가 깨지지 않는지 확인한다.
@@ -112,6 +135,10 @@
- 아이템 관리의 같은 이미지 참조 요약과 상세 모달은 붙였으므로, 실제 운영 데이터에서 카드 수치와 모달 목록이 기대한 참조 관계와 맞는지 한 번 더 QA한다.
## 중기 개선
- 특정 작성자 팔로우, 작성자 프로필 페이지, 팔로우한 작성자 티어표만 모아보는 피드 화면을 추가한다.
- 추천 티어표는 이번에 관리자 수동 지정부터 붙였으므로, 다음 단계에서는 최근 N일 좋아요 수 기준 추천 후보 필터와 추천 섹션 노출 개수 설정을 관리자 화면에 추가할지 검토한다.
- 이메일 인증/비밀번호 재설정 1차 구현이 들어갔으므로, 다음 단계에서는 Gmail 발신 기반이 실제 운영에서 스팸함으로 얼마나 가는지 보고 필요하면 Cloudflare DNS의 SPF/DKIM/DMARC와 도메인 발신 주소 전환을 정리한다.
- 구글 계정 로그인은 아직 붙이지 않았으므로, 이메일 인증 안정화 후 Google OAuth 클라이언트를 만들고 일반 이메일 계정과 같은 이메일의 구글 계정을 자동 연결할지 정책을 먼저 결정한다.
- 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다.
- 관리자 템플릿 요청은 동일 `src` 중복 생성 방지와 `연결된 게임/이미 반영 n개` 표시까지 붙였으므로, 이후에는 요청별 반영 이력(예: 어떤 아이템을 언제 반영했는지)까지 별도로 남길지 검토한다.
- 관리자 URL 분리는 시작했으므로, 다음 단계에서는 `AdminView.vue` 단일 대형 파일을 섹션별 뷰/컴포저블로 쪼개고 직접 진입 시 선택 게임/요청 작업 상태 복원 범위도 함께 정리한다.

View File

@@ -1,5 +1,57 @@
# 업데이트 로그
## 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`으로 막히는 상황을 줄였다.
## 2026-04-03 v1.4.40
- 공유 링크로 여는 `preview=1` 화면을 `뷰어 모드`로 정의하고, 드래그/편집 없이 완성본만 보이는 상태에서 오른쪽 레일 상단에는 광고, 하단에는 공유·복사·수정 전환 액션을 노출하도록 정리했다.
- 비로그인 사용자나 작성자 본인이 아닌 사용자가 일반 편집 URL로 저장된 티어표를 직접 열어도 자동으로 `preview=1` 뷰어 모드 주소로 전환되도록 로딩 후 라우팅을 보정했다.

View File

@@ -1,6 +1,7 @@
server {
listen 80;
server_name _;
client_max_body_size 1024m;
root /usr/share/nginx/html;
index index.html;

View File

@@ -1318,12 +1318,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,7 @@ const props = defineProps({
<div class="tierAdminCard__stats">
<span class="pill" :class="tierList.isPublic ? 'pill--public' : 'pill--private'">{{ props.tierListVisibilityLabel(tierList) }}</span>
<span v-if="tierList.isFeatured" class="pill pill--accent">추천 노출중</span>
<span class="pill">전체 아이템 {{ tierList.itemCount }}</span>
<span class="pill" :class="{ 'pill--accent': tierList.extraItemCount > 0 }">추가 아이템 {{ tierList.extraItemCount }}</span>
</div>
@@ -177,6 +180,14 @@ const props = defineProps({
</div>
<div class="tierAdminSection__actions">
<button
class="btn btn--small"
:class="tierList.isFeatured ? 'btn--ghost' : 'btn--primary'"
:disabled="!tierList.isPublic && !tierList.isFeatured"
@click="props.toggleAdminTierListFeatured(tierList)"
>
{{ tierList.isFeatured ? '추천 해제' : '추천 지정' }}
</button>
<button class="btn btn--ghost btn--small" @click="props.openAdminTierListManageModal(tierList)">관리</button>
</div>
</div>

View File

@@ -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'),
@@ -80,6 +87,8 @@ export const api = {
request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId)}`),
updateAdminTierList: (tierListId, payload) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'PATCH', body: payload }),
updateAdminTierListFeatured: (tierListId, payload) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/featured`, { method: 'PATCH', body: payload }),
deleteAdminTierList: (tierListId) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'DELETE' }),
listAdminTemplateRequests: () => request('/api/admin/template-requests'),

View File

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

@@ -54,7 +54,7 @@ const adminTierListTopicId = ref('')
const adminTierListPage = ref(1)
const adminTierListLimit = ref(50)
const adminTierListTotal = ref(0)
const adminTierListStats = ref({ total: 0, publicCount: 0, privateCount: 0 })
const adminTierListStats = ref({ total: 0, publicCount: 0, privateCount: 0, featuredCount: 0 })
const selectedTemplateTierListStats = ref({ total: 0, publicCount: 0, privateCount: 0 })
const templateRequests = ref([])
const importModalOpen = ref(false)
@@ -277,6 +277,7 @@ const adminOverviewStats = computed(() => {
]
: [
{ label: '검색 결과', value: `${adminTierListStats.value.total || 0}` },
{ label: '추천', value: `${adminTierListStats.value.featuredCount || 0}` },
{ label: '공개', value: `${adminTierListStats.value.publicCount || 0}` },
{ label: '비공개', value: `${adminTierListStats.value.privateCount || 0}` },
{ label: '현재 페이지', value: `${adminTierListPage.value}/${adminTierListPageCount.value}` },
@@ -844,9 +845,10 @@ async function refreshAdminTierListStats() {
total: data.total || 0,
publicCount: data.publicCount || 0,
privateCount: data.privateCount || 0,
featuredCount: data.featuredCount || 0,
}
} catch (e) {
adminTierListStats.value = { total: 0, publicCount: 0, privateCount: 0 }
adminTierListStats.value = { total: 0, publicCount: 0, privateCount: 0, featuredCount: 0 }
}
}
@@ -1472,6 +1474,27 @@ async function deleteAdminTierListEntry() {
}
}
async function toggleAdminTierListFeatured(tierList) {
if (!tierList?.id) return
const nextFeatured = !tierList.isFeatured
resetMessages()
try {
const data = await api.updateAdminTierListFeatured(tierList.id, { isFeatured: nextFeatured })
const updated = data.tierList || {}
adminTierLists.value = adminTierLists.value.map((entry) => (entry.id === tierList.id ? { ...entry, ...updated } : entry))
if (previewTierList.value?.id === tierList.id) previewTierList.value = { ...previewTierList.value, ...updated }
if (modalTargetAdminTierList.value?.id === tierList.id) {
modalTargetAdminTierList.value = { ...modalTargetAdminTierList.value, ...updated }
}
await refreshAdminTierListStats()
success.value = nextFeatured ? '추천 티어표로 지정했어요.' : '추천 지정을 해제했어요.'
} catch (e) {
error.value =
e?.data?.error === 'public_tierlist_required' ? '공개 티어표만 추천으로 지정할 수 있어요.' : '추천 상태 변경에 실패했어요.'
}
}
function openAdminTierList(tierList) {
previewTierList.value = tierList
previewModalOpen.value = true
@@ -1782,6 +1805,7 @@ function userAvatarFallback(user) {
:admin-tier-list-total="adminTierListTotal"
:admin-tier-list-stats="adminTierListStats"
:open-admin-tier-list-manage-modal="openAdminTierListManageModal"
:toggle-admin-tier-list-featured="toggleAdminTierListFeatured"
:move-admin-tier-list-page="moveAdminTierListPage"
/>

View File

@@ -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'
@@ -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)
@@ -122,7 +128,7 @@ const canSwitchToEditMode = computed(() => isOwnTierList.value && hasSavedTierLi
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 +284,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 +320,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 +474,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 +491,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 +858,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 +912,34 @@ 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 closeSaveModal() {
isSaveModalOpen.value = false
}
@@ -923,88 +1079,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)
@@ -1096,6 +1320,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 +1450,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 +1531,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 +1597,7 @@ onUnmounted(() => {
<div class="sidebar__count">{{ visiblePoolCount }} / {{ pool.length }}</div>
</div>
<div class="sidebar__hint">
{{ canEdit ? '등록된 아이템 리스트입니다. 드래그해서 표에 넣을 수 있습니다.' : '공개 티어표는 보기 전용입니다.' }}
{{ canEdit ? '아이템을 드래그하거나, 클릭으로 선택한 뒤 원하는 셀/풀을 클릭해서 옮길 수 있어요.' : '공개 티어표는 보기 전용입니다.' }}
</div>
<input
v-model="poolSearchQuery"
@@ -1360,13 +1606,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>
@@ -2214,6 +2471,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 +2489,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 +2873,9 @@ onUnmounted(() => {
gap: 10px;
align-content: start;
}
.pool--clickTarget {
cursor: copy;
}
.poolItem {
min-width: 0;
display: grid;
@@ -2617,11 +2887,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;
}