Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f9767624d1 | |||
| 9847b4dd8f | |||
| 8a43a2dd2c | |||
| 3b9f5f18e0 | |||
| 8922c62f58 | |||
| cd41a6caa1 | |||
| a60c3c9896 | |||
| eb3fb71f24 | |||
| 2a39ee03e5 | |||
| 050ad04bc8 | |||
| fcf4228bff | |||
| 04e9a0420a | |||
| bf726b6161 | |||
| 73a269d61d | |||
| 713b07a1de | |||
| 764e18c16b | |||
| 2107223634 | |||
| 5eb08e1757 | |||
| 15c03835ef |
@@ -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>"
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,6 +8,7 @@ backend/.sessions/
|
||||
backend/uploads/avatars/
|
||||
backend/uploads/games/
|
||||
backend/uploads/custom/
|
||||
backend/uploads/assets/
|
||||
|
||||
.DS_Store
|
||||
.env.production
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const dotenv = require('dotenv')
|
||||
const express = require('express')
|
||||
const cors = require('cors')
|
||||
const session = require('express-session')
|
||||
const FileStoreFactory = require('session-file-store')
|
||||
|
||||
dotenv.config({ path: path.join(__dirname, '..', '.env.production') })
|
||||
|
||||
const { ensureData } = require('./src/db')
|
||||
const authRoutes = require('./src/routes/auth')
|
||||
const topicsRoutes = require('./src/routes/topics')
|
||||
const tierListsRoutes = require('./src/routes/tierlists')
|
||||
const usersRoutes = require('./src/routes/users')
|
||||
const adminRoutes = require('./src/routes/admin')
|
||||
|
||||
const app = express()
|
||||
@@ -82,6 +86,7 @@ app.use(async (req, res, next) => {
|
||||
app.use('/api/auth', authRoutes)
|
||||
app.use('/api/topics', topicsRoutes)
|
||||
app.use('/api/tierlists', tierListsRoutes)
|
||||
app.use('/api/users', usersRoutes)
|
||||
app.use('/api/admin', adminRoutes)
|
||||
|
||||
app.listen(PORT, () => {
|
||||
|
||||
23
backend/package-lock.json
generated
23
backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -383,6 +426,18 @@ async function ensureSchema() {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS user_follows (
|
||||
follower_id VARCHAR(64) NOT NULL,
|
||||
following_id VARCHAR(64) NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
PRIMARY KEY (follower_id, following_id),
|
||||
INDEX idx_user_follows_following (following_id, created_at),
|
||||
CONSTRAINT fk_user_follows_follower FOREIGN KEY (follower_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_user_follows_following FOREIGN KEY (following_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS image_assets (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
@@ -489,6 +544,18 @@ async function ensureSchema() {
|
||||
if (!tierListShowNamesColumns.length) {
|
||||
await query("ALTER TABLE tierlists ADD COLUMN show_character_names TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public")
|
||||
}
|
||||
const tierListFeaturedColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'is_featured'")
|
||||
if (!tierListFeaturedColumns.length) {
|
||||
await query("ALTER TABLE tierlists ADD COLUMN is_featured TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public")
|
||||
}
|
||||
const tierListFeaturedAtColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'featured_at'")
|
||||
if (!tierListFeaturedAtColumns.length) {
|
||||
await query("ALTER TABLE tierlists ADD COLUMN featured_at BIGINT NOT NULL DEFAULT 0 AFTER is_featured")
|
||||
}
|
||||
const tierListFeaturedByColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'featured_by'")
|
||||
if (!tierListFeaturedByColumns.length) {
|
||||
await query("ALTER TABLE tierlists ADD COLUMN featured_by VARCHAR(64) NOT NULL DEFAULT '' AFTER featured_at")
|
||||
}
|
||||
const tierListIconSizeColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'icon_size'")
|
||||
if (!tierListIconSizeColumns.length) {
|
||||
await query("ALTER TABLE tierlists ADD COLUMN icon_size INT NOT NULL DEFAULT 80 AFTER show_character_names")
|
||||
@@ -567,7 +634,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 +648,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 +657,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 +671,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 +814,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 +849,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 +861,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 +2028,9 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '')
|
||||
t.topic_id,
|
||||
t.title,
|
||||
t.thumbnail_src,
|
||||
t.is_featured,
|
||||
t.featured_at,
|
||||
t.featured_by,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
t.author_id,
|
||||
@@ -1855,8 +2040,8 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '')
|
||||
FROM tierlists t
|
||||
INNER JOIN users u ON u.id = t.author_id
|
||||
${whereClause}
|
||||
ORDER BY t.updated_at DESC
|
||||
LIMIT 50
|
||||
ORDER BY t.is_featured DESC, t.featured_at DESC, t.updated_at DESC
|
||||
LIMIT 200
|
||||
`,
|
||||
params
|
||||
)
|
||||
@@ -1866,6 +2051,8 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '')
|
||||
topicId: row.topic_id,
|
||||
title: row.title,
|
||||
thumbnailSrc: row.thumbnail_src || '',
|
||||
isFeatured: !!row.is_featured,
|
||||
featuredAt: Number(row.featured_at || 0),
|
||||
createdAt: Number(row.created_at),
|
||||
updatedAt: Number(row.updated_at),
|
||||
authorId: row.author_id,
|
||||
@@ -1878,7 +2065,22 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '')
|
||||
tierLists.map((tierList) => tierList.id),
|
||||
currentUserId
|
||||
)
|
||||
return applyFavoriteMetaToTierLists(tierLists, favoriteStats)
|
||||
const mergedTierLists = applyFavoriteMetaToTierLists(tierLists, favoriteStats)
|
||||
const featuredTierLists = mergedTierLists
|
||||
.filter((tierList) => tierList.isFeatured)
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(b.featuredAt || 0) - Number(a.featuredAt || 0) ||
|
||||
Number(b.favoriteCount || 0) - Number(a.favoriteCount || 0) ||
|
||||
Number(b.updatedAt || 0) - Number(a.updatedAt || 0)
|
||||
)
|
||||
.slice(0, 16)
|
||||
|
||||
return {
|
||||
featuredTierLists,
|
||||
tierLists: mergedTierLists.filter((tierList) => !tierList.isFeatured),
|
||||
}
|
||||
}
|
||||
|
||||
async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited' } = {}) {
|
||||
@@ -1911,6 +2113,9 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited
|
||||
t.thumbnail_src,
|
||||
t.description,
|
||||
t.is_public,
|
||||
t.is_featured,
|
||||
t.featured_at,
|
||||
t.featured_by,
|
||||
t.show_character_names,
|
||||
t.icon_size,
|
||||
t.source_tierlist_id,
|
||||
@@ -1989,6 +2194,198 @@ async function listUserTierLists(userId) {
|
||||
return applyFavoriteMetaToTierLists(tierLists, favoriteStats)
|
||||
}
|
||||
|
||||
async function findUserProfileById(userId, currentUserId = '') {
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT
|
||||
u.id,
|
||||
u.email,
|
||||
u.nickname,
|
||||
u.avatar_src,
|
||||
u.created_at,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM tierlists t
|
||||
WHERE t.author_id = u.id AND t.is_public = 1
|
||||
) AS public_tierlist_count,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM user_follows uf
|
||||
WHERE uf.following_id = u.id
|
||||
) AS follower_count,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM user_follows uf
|
||||
WHERE uf.follower_id = u.id
|
||||
) AS following_count,
|
||||
${
|
||||
currentUserId
|
||||
? `EXISTS(
|
||||
SELECT 1
|
||||
FROM user_follows uf
|
||||
WHERE uf.follower_id = ? AND uf.following_id = u.id
|
||||
)`
|
||||
: '0'
|
||||
} AS is_following
|
||||
FROM users u
|
||||
WHERE u.id = ?
|
||||
LIMIT 1
|
||||
`,
|
||||
currentUserId ? [currentUserId, userId] : [userId]
|
||||
)
|
||||
const row = rows[0]
|
||||
if (!row) return null
|
||||
return {
|
||||
id: row.id,
|
||||
nickname: row.nickname || '',
|
||||
accountName: getUserAccountName(row),
|
||||
avatarSrc: row.avatar_src || '',
|
||||
createdAt: Number(row.created_at || 0),
|
||||
publicTierListCount: Number(row.public_tierlist_count || 0),
|
||||
followerCount: Number(row.follower_count || 0),
|
||||
followingCount: Number(row.following_count || 0),
|
||||
isFollowing: !!row.is_following,
|
||||
isSelf: !!currentUserId && currentUserId === row.id,
|
||||
}
|
||||
}
|
||||
|
||||
async function followUser({ followerId, followingId }) {
|
||||
await query('INSERT IGNORE INTO user_follows (follower_id, following_id, created_at) VALUES (?, ?, ?)', [
|
||||
followerId,
|
||||
followingId,
|
||||
now(),
|
||||
])
|
||||
}
|
||||
|
||||
async function unfollowUser({ followerId, followingId }) {
|
||||
await query('DELETE FROM user_follows WHERE follower_id = ? AND following_id = ?', [followerId, followingId])
|
||||
}
|
||||
|
||||
async function listPublicTierListsByAuthor(authorId, currentUserId = '', queryText = '') {
|
||||
const params = [authorId]
|
||||
let whereClause = 'WHERE t.author_id = ? AND t.is_public = 1'
|
||||
if ((queryText || '').trim()) {
|
||||
const search = `%${queryText.trim()}%`
|
||||
whereClause += ' AND (t.title LIKE ? OR tp.name LIKE ?)'
|
||||
params.push(search, search)
|
||||
}
|
||||
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT
|
||||
t.id,
|
||||
t.topic_id,
|
||||
tp.name AS topic_name,
|
||||
t.title,
|
||||
t.thumbnail_src,
|
||||
t.is_public,
|
||||
t.is_featured,
|
||||
t.featured_at,
|
||||
t.featured_by,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
t.author_id,
|
||||
u.nickname,
|
||||
u.email,
|
||||
u.avatar_src
|
||||
FROM tierlists t
|
||||
INNER JOIN users u ON u.id = t.author_id
|
||||
INNER JOIN topics tp ON tp.id = t.topic_id
|
||||
${whereClause}
|
||||
ORDER BY t.is_featured DESC, t.featured_at DESC, t.updated_at DESC
|
||||
LIMIT 200
|
||||
`,
|
||||
params
|
||||
)
|
||||
|
||||
const tierLists = rows.map((row) => ({
|
||||
id: row.id,
|
||||
topicId: row.topic_id,
|
||||
topicName: row.topic_name || '',
|
||||
title: row.title,
|
||||
thumbnailSrc: row.thumbnail_src || '',
|
||||
isPublic: !!row.is_public,
|
||||
isFeatured: !!row.is_featured,
|
||||
featuredAt: Number(row.featured_at || 0),
|
||||
createdAt: Number(row.created_at),
|
||||
updatedAt: Number(row.updated_at),
|
||||
authorId: row.author_id,
|
||||
authorName: getUserDisplayName(row),
|
||||
authorAccountName: getUserAccountName(row),
|
||||
authorAvatarSrc: row.avatar_src || '',
|
||||
}))
|
||||
|
||||
const favoriteStats = await getFavoriteStatsForTierListIds(
|
||||
tierLists.map((tierList) => tierList.id),
|
||||
currentUserId
|
||||
)
|
||||
return applyFavoriteMetaToTierLists(tierLists, favoriteStats)
|
||||
}
|
||||
|
||||
async function listFollowingTierLists(userId, queryText = '') {
|
||||
const params = [userId]
|
||||
let whereClause = 'WHERE uf.follower_id = ? AND t.is_public = 1'
|
||||
if ((queryText || '').trim()) {
|
||||
const search = `%${queryText.trim()}%`
|
||||
whereClause += ' AND (t.title LIKE ? OR tp.name LIKE ? OR u.nickname LIKE ? OR u.email LIKE ?)'
|
||||
params.push(search, search, search, search)
|
||||
}
|
||||
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT
|
||||
t.id,
|
||||
t.topic_id,
|
||||
tp.name AS topic_name,
|
||||
t.title,
|
||||
t.thumbnail_src,
|
||||
t.is_public,
|
||||
t.is_featured,
|
||||
t.featured_at,
|
||||
t.featured_by,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
t.author_id,
|
||||
uf.created_at AS followed_at,
|
||||
u.nickname,
|
||||
u.email,
|
||||
u.avatar_src
|
||||
FROM user_follows uf
|
||||
INNER JOIN tierlists t ON t.author_id = uf.following_id
|
||||
INNER JOIN users u ON u.id = t.author_id
|
||||
INNER JOIN topics tp ON tp.id = t.topic_id
|
||||
${whereClause}
|
||||
ORDER BY t.updated_at DESC, uf.created_at DESC
|
||||
LIMIT 200
|
||||
`,
|
||||
params
|
||||
)
|
||||
|
||||
const tierLists = rows.map((row) => ({
|
||||
id: row.id,
|
||||
topicId: row.topic_id,
|
||||
topicName: row.topic_name || '',
|
||||
title: row.title,
|
||||
thumbnailSrc: row.thumbnail_src || '',
|
||||
isPublic: !!row.is_public,
|
||||
isFeatured: !!row.is_featured,
|
||||
featuredAt: Number(row.featured_at || 0),
|
||||
createdAt: Number(row.created_at),
|
||||
updatedAt: Number(row.updated_at),
|
||||
authorId: row.author_id,
|
||||
authorName: getUserDisplayName(row),
|
||||
authorAccountName: getUserAccountName(row),
|
||||
authorAvatarSrc: row.avatar_src || '',
|
||||
isFavorited: false,
|
||||
}))
|
||||
|
||||
const favoriteStats = await getFavoriteStatsForTierListIds(
|
||||
tierLists.map((tierList) => tierList.id),
|
||||
userId
|
||||
)
|
||||
return applyFavoriteMetaToTierLists(tierLists, favoriteStats)
|
||||
}
|
||||
|
||||
function uniqueTierListItems(poolItems) {
|
||||
const map = new Map()
|
||||
;(poolItems || []).forEach((item) => {
|
||||
@@ -2056,6 +2453,9 @@ async function listAdminTierLists({ queryText = '', topicId = '', page = 1, limi
|
||||
t.thumbnail_src,
|
||||
t.description,
|
||||
t.is_public,
|
||||
t.is_featured,
|
||||
t.featured_at,
|
||||
t.featured_by,
|
||||
t.show_character_names,
|
||||
t.icon_size,
|
||||
t.source_tierlist_id,
|
||||
@@ -2131,7 +2531,7 @@ async function summarizeAdminTierLists({ queryText = '', topicId = '' } = {}) {
|
||||
const whereClause = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT t.is_public
|
||||
SELECT t.is_public, t.is_featured
|
||||
FROM tierlists t
|
||||
INNER JOIN users u ON u.id = t.author_id
|
||||
INNER JOIN topics tp ON tp.id = t.topic_id
|
||||
@@ -2142,10 +2542,12 @@ async function summarizeAdminTierLists({ queryText = '', topicId = '' } = {}) {
|
||||
|
||||
const total = rows.length
|
||||
const publicCount = rows.filter((row) => Number(row.is_public) === 1).length
|
||||
const featuredCount = rows.filter((row) => Number(row.is_featured) === 1).length
|
||||
return {
|
||||
total,
|
||||
publicCount,
|
||||
privateCount: Math.max(0, total - publicCount),
|
||||
featuredCount,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2161,6 +2563,9 @@ async function findTierListById(id, currentUserId = '') {
|
||||
t.thumbnail_src,
|
||||
t.description,
|
||||
t.is_public,
|
||||
t.is_featured,
|
||||
t.featured_at,
|
||||
t.featured_by,
|
||||
t.show_character_names,
|
||||
t.icon_size,
|
||||
t.source_tierlist_id,
|
||||
@@ -2369,13 +2774,38 @@ async function deleteTierList(id) {
|
||||
}
|
||||
|
||||
async function updateAdminTierListMeta({ id, title, description = '', isPublic }) {
|
||||
const nextUpdatedAt = now()
|
||||
if (!isPublic) {
|
||||
await query(
|
||||
`
|
||||
UPDATE tierlists
|
||||
SET title = ?, description = ?, is_public = 0, is_featured = 0, featured_at = 0, featured_by = '', updated_at = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
[title, description || '', nextUpdatedAt, id]
|
||||
)
|
||||
return findTierListById(id)
|
||||
}
|
||||
|
||||
await query(
|
||||
`
|
||||
UPDATE tierlists
|
||||
SET title = ?, description = ?, is_public = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
[title, description || '', isPublic ? 1 : 0, now(), id]
|
||||
[title, description || '', 1, nextUpdatedAt, id]
|
||||
)
|
||||
return findTierListById(id)
|
||||
}
|
||||
|
||||
async function updateTierListFeaturedStatus({ id, isFeatured, adminUserId }) {
|
||||
await query(
|
||||
`
|
||||
UPDATE tierlists
|
||||
SET is_featured = ?, featured_at = ?, featured_by = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
[isFeatured ? 1 : 0, isFeatured ? now() : 0, isFeatured ? adminUserId || '' : '', id]
|
||||
)
|
||||
return findTierListById(id)
|
||||
}
|
||||
@@ -2500,7 +2930,16 @@ module.exports = {
|
||||
findUserByEmail,
|
||||
findUserByNickname,
|
||||
findUserById,
|
||||
findUserProfileById,
|
||||
createUser,
|
||||
updateUserPassword,
|
||||
verifyUserEmail,
|
||||
createEmailVerificationToken,
|
||||
findEmailVerificationTokenByHash,
|
||||
consumeEmailVerificationToken,
|
||||
createPasswordResetToken,
|
||||
findPasswordResetTokenByHash,
|
||||
consumePasswordResetToken,
|
||||
updateUserProfile,
|
||||
findPrimaryAdminUser,
|
||||
listUsers,
|
||||
@@ -2545,14 +2984,19 @@ module.exports = {
|
||||
listCustomItems,
|
||||
findUnusedCustomItems,
|
||||
listPublicTierLists,
|
||||
listPublicTierListsByAuthor,
|
||||
listFollowingTierLists,
|
||||
listFavoriteTierLists,
|
||||
listUserTierLists,
|
||||
listAdminTierLists,
|
||||
summarizeAdminTierLists,
|
||||
findTierListById,
|
||||
updateAdminTierListMeta,
|
||||
updateTierListFeaturedStatus,
|
||||
favoriteTopic,
|
||||
unfavoriteTopic,
|
||||
followUser,
|
||||
unfollowUser,
|
||||
favoriteTierList,
|
||||
unfavoriteTierList,
|
||||
deleteTierList,
|
||||
|
||||
113
backend/src/lib/mailer.js
Normal file
113
backend/src/lib/mailer.js
Normal 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,
|
||||
}
|
||||
@@ -38,6 +38,7 @@ const {
|
||||
summarizeAdminTierLists,
|
||||
findTierListById,
|
||||
updateAdminTierListMeta,
|
||||
updateTierListFeaturedStatus,
|
||||
listAdminTemplateRequests,
|
||||
findTemplateRequestById,
|
||||
updateTemplateRequestStatus,
|
||||
@@ -54,8 +55,6 @@ const {
|
||||
} = require('../db')
|
||||
const { requireAdmin } = require('../middleware/auth')
|
||||
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
|
||||
const { isReservedNickname } = require('../lib/user-validation')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
function getTemplateIdFromParams(req) {
|
||||
@@ -91,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) {
|
||||
@@ -201,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)
|
||||
@@ -212,28 +211,57 @@ router.post('/templates/:templateId/images', requireAdmin, upload.array('images'
|
||||
const labels = Array.isArray(labelsRaw) ? labelsRaw : labelsRaw ? [labelsRaw] : []
|
||||
const normalizedLabels = labels.map((label) => (typeof label === 'string' ? label.trim().slice(0, 60) : ''))
|
||||
if (normalizedLabels.some((label) => label.length > 60)) return res.status(400).json({ error: 'bad_request' })
|
||||
const totalBytes = files.reduce((sum, file) => sum + Number(file?.size || 0), 0)
|
||||
|
||||
const items = await Promise.all(
|
||||
files.map(async (file, index) => {
|
||||
const optimized = await writeOptimizedImage({
|
||||
file,
|
||||
directory: 'topics',
|
||||
width: 512,
|
||||
height: 512,
|
||||
fit: 'inside',
|
||||
quality: 84,
|
||||
})
|
||||
console.info('[admin] template image upload start', {
|
||||
templateId: template.id,
|
||||
fileCount: files.length,
|
||||
totalBytes,
|
||||
fileNames: files.map((file) => file.originalname),
|
||||
})
|
||||
|
||||
return createTopicItem({
|
||||
id: nanoid(),
|
||||
topicId: template.id,
|
||||
src: optimized.src,
|
||||
label: normalizedLabels[index] || buildItemLabelFromFilename(file),
|
||||
try {
|
||||
const items = await Promise.all(
|
||||
files.map(async (file, index) => {
|
||||
const optimized = await writeOptimizedImage({
|
||||
file,
|
||||
directory: 'topics',
|
||||
width: 512,
|
||||
height: 512,
|
||||
fit: 'inside',
|
||||
quality: 84,
|
||||
})
|
||||
|
||||
return createTopicItem({
|
||||
id: nanoid(),
|
||||
topicId: template.id,
|
||||
src: optimized.src,
|
||||
label: normalizedLabels[index] || buildItemLabelFromFilename(file),
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
console.info('[admin] template image upload success', {
|
||||
templateId: template.id,
|
||||
fileCount: items.length,
|
||||
totalBytes,
|
||||
})
|
||||
)
|
||||
|
||||
res.json({ item: items[0], items })
|
||||
res.json({ item: items[0], items })
|
||||
} catch (error) {
|
||||
console.error('[admin] template image upload failed', {
|
||||
templateId: template.id,
|
||||
fileCount: files.length,
|
||||
totalBytes,
|
||||
message: error?.message || 'upload_failed',
|
||||
code: error?.code || '',
|
||||
stack: error?.stack || '',
|
||||
})
|
||||
res.status(500).json({
|
||||
error: 'template_image_upload_failed',
|
||||
detail: error?.message || 'upload_failed',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/templates/:templateId/items/:itemId', requireAdmin, async (req, res) => {
|
||||
@@ -353,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 })
|
||||
@@ -965,9 +1012,6 @@ router.patch('/users/:userId', requireAdmin, async (req, res) => {
|
||||
return res.status(403).json({ error: 'primary_admin_only' })
|
||||
}
|
||||
|
||||
if (isReservedNickname(parsed.data.nickname)) {
|
||||
return res.status(400).json({ error: 'nickname_reserved' })
|
||||
}
|
||||
const duplicateEmail = await findUserByEmail(parsed.data.email)
|
||||
if (duplicateEmail && duplicateEmail.id !== targetUser.id) {
|
||||
return res.status(409).json({ error: 'email_taken' })
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -124,8 +124,8 @@ const tierListUpsertSchema = z.object({
|
||||
router.get('/public', async (req, res) => {
|
||||
const topicId = typeof req.query.topicId === 'string' ? req.query.topicId : ''
|
||||
const queryText = typeof req.query.q === 'string' ? req.query.q : ''
|
||||
const lists = await listPublicTierLists(topicId, req.session?.userId || '', queryText)
|
||||
res.json({ tierLists: lists })
|
||||
const result = await listPublicTierLists(topicId, req.session?.userId || '', queryText)
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
router.get('/me', requireAuth, async (req, res) => {
|
||||
|
||||
77
backend/src/routes/users.js
Normal file
77
backend/src/routes/users.js
Normal file
@@ -0,0 +1,77 @@
|
||||
const express = require('express')
|
||||
const { z } = require('zod')
|
||||
const {
|
||||
findUserProfileById,
|
||||
followUser,
|
||||
unfollowUser,
|
||||
listPublicTierListsByAuthor,
|
||||
listFollowingTierLists,
|
||||
} = require('../db')
|
||||
const { requireAuth } = require('../middleware/auth')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.get('/following-feed', requireAuth, async (req, res) => {
|
||||
const schema = z.object({
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
})
|
||||
const parsed = schema.safeParse(req.query)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const tierLists = await listFollowingTierLists(req.session.userId, parsed.data.q)
|
||||
res.json({ tierLists })
|
||||
})
|
||||
|
||||
router.get('/:userId', async (req, res) => {
|
||||
const user = await findUserProfileById(req.params.userId, req.session?.userId || '')
|
||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
||||
res.json({ user })
|
||||
})
|
||||
|
||||
router.get('/:userId/tierlists', async (req, res) => {
|
||||
const schema = z.object({
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
})
|
||||
const parsed = schema.safeParse(req.query)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const user = await findUserProfileById(req.params.userId, req.session?.userId || '')
|
||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const tierLists = await listPublicTierListsByAuthor(
|
||||
req.params.userId,
|
||||
req.session?.userId || '',
|
||||
parsed.data.q
|
||||
)
|
||||
res.json({ tierLists })
|
||||
})
|
||||
|
||||
router.post('/:userId/follow', requireAuth, async (req, res) => {
|
||||
const targetUserId = req.params.userId || ''
|
||||
if (!targetUserId || targetUserId === req.session.userId) {
|
||||
return res.status(400).json({ error: 'self_follow_not_allowed' })
|
||||
}
|
||||
|
||||
const user = await findUserProfileById(targetUserId, req.session.userId)
|
||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
await followUser({ followerId: req.session.userId, followingId: targetUserId })
|
||||
const updated = await findUserProfileById(targetUserId, req.session.userId)
|
||||
res.json({ user: updated })
|
||||
})
|
||||
|
||||
router.delete('/:userId/follow', requireAuth, async (req, res) => {
|
||||
const targetUserId = req.params.userId || ''
|
||||
if (!targetUserId || targetUserId === req.session.userId) {
|
||||
return res.status(400).json({ error: 'self_follow_not_allowed' })
|
||||
}
|
||||
|
||||
const user = await findUserProfileById(targetUserId, req.session.userId)
|
||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
await unfollowUser({ followerId: req.session.userId, followingId: targetUserId })
|
||||
const updated = await findUserProfileById(targetUserId, req.session.userId)
|
||||
res.json({ user: updated })
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,57 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-04-03 v1.4.53
|
||||
- 본인 티어표 복사 기능이 타인 티어표 전용 조건으로만 남아 있었지만, 실제 사용에서는 자기 작업본을 변형용 복사본으로 다시 만들고 싶은 경우도 많으므로 저장된 본인 티어표에도 복사 버튼을 여는 편이 맞다고 판단했다.
|
||||
- 편집 중 저장하지 않은 변경이 있는 상태에서 복사본을 만들 때는 마지막 저장본이 아니라 현재 화면 상태가 복사되기를 기대하기 쉬우므로, 본인 편집본 복사는 복사 직전에 현재 원본을 한 번 저장한 뒤 새 복사본을 만드는 쪽으로 정리했다.
|
||||
- 팔로우 기능은 처음부터 추천 알고리즘까지 섞기보다, 작성자 프로필과 팔로우 피드라는 명확한 사용자 경로를 먼저 열어두는 편이 제품 구조상 자연스럽다고 보고 `user_follows` 기반 1차 구현을 먼저 붙였다.
|
||||
- 작성자 프로필 진입점은 목록 카드 내부 작성자 클릭을 바로 분리하면 기존 카드 전체 클릭 문법과 충돌할 수 있으므로, 이번 단계에서는 티어표 편집/뷰어 우측 패널의 `작성자 프로필 보기`를 우선 진입점으로 두고 카드 내부 세부 클릭 분리는 후속 UX로 미루는 편이 안전하다고 판단했다.
|
||||
|
||||
## 2026-04-03 v1.4.51
|
||||
- 불특정 다수가 만드는 공개 티어표를 전부 같은 선상에 두면 첫 화면 품질 편차가 너무 커질 수 있으므로, 주제별 목록 상단에 관리자 큐레이션 `추천 티어표` 섹션을 두고 아래 일반 공개 목록과 분리하는 편이 맞다고 판단했다.
|
||||
- 추천 선정은 처음부터 완전 자동화보다 운영자가 직접 지정/해제할 수 있는 수동 큐레이션을 먼저 넣는 편이 안전하므로, 좋아요 수 기반 자동 후보 필터와 팔로우 피드는 후속 작업으로 미루고 이번 릴리스에서는 추천 표시 구조와 관리자 토글만 먼저 완성했다.
|
||||
- 비공개 글이 추천 섹션에 올라가면 접근 정책이 꼬이므로, 추천 지정은 공개 글만 허용하고 공개글을 비공개로 바꾸면 추천 상태도 함께 해제하는 쪽으로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.49
|
||||
- 프로필 저장 실패를 하나의 일반 실패 메시지로만 보여주면 사용자가 “서버가 고장났나?”라고 오해하기 쉬우므로, 중복 닉네임/예약어 닉네임처럼 사용자가 직접 고칠 수 있는 입력 오류는 원인별 안내를 분리하는 편이 맞다고 판단했다.
|
||||
- 비밀번호를 잊은 사용자뿐 아니라 로그인 중인 사용자도 보안상 주기적으로 비밀번호를 직접 바꿀 수 있어야 하므로, 설정 화면에 현재 비밀번호 확인 기반 변경 폼을 추가하는 쪽으로 정리했다.
|
||||
- 비밀번호 재설정 링크는 로그인 세션이 남아 있어도 링크 토큰 자체의 목적이 우선이므로, `/login?resetToken=...` 진입 시에는 기존 자동 리다이렉트보다 재설정 폼 렌더링을 우선하는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-03 v1.4.45
|
||||
- 실제 서비스에서는 남의 이메일 주소로 가입만 먼저 해두는 문제가 생길 수 있으므로, 일반 회원은 가입 직후 인증 메일을 거쳐야 로그인할 수 있게 하고 비밀번호 분실도 메일 토큰 기반으로 복구하는 구조가 필요하다고 판단했다.
|
||||
- 다만 초기 운영자가 바로 서비스를 띄울 수 있어야 하므로, 첫 번째 가입 계정만은 기존처럼 이메일 인증 없이 바로 최고 관리자 계정으로 활성화하는 예외를 유지하는 편이 맞다고 정리했다.
|
||||
- 발신 인프라는 우선 사용자가 준비한 Gmail 계정과 앱 비밀번호로 SMTP를 먼저 붙이고, 도메인 발신 주소와 SPF/DKIM/DMARC는 실제 발송 품질을 본 뒤 Cloudflare DNS에서 후속 정리하는 단계적 접근이 더 현실적이라고 판단했다.
|
||||
|
||||
## 2026-04-03 v1.4.44
|
||||
- 공통 카피라이트 링크 색을 고정 민트색으로 두면 다크 모드에서는 잘 보이지만 라이트 모드에서 대비가 부족해질 수 있으므로, 테마 텍스트 색을 따라가게 하고 굵기로 링크 인지를 보완하는 편이 더 안정적이라고 판단했다.
|
||||
|
||||
## 2026-04-03 v1.4.43
|
||||
- Vue Router에서 같은 컴포넌트가 유지된 채 `params/query`만 바뀌는 에디터 이동은 `onMounted()`만으로는 새 데이터를 다시 불러오지 못할 수 있으므로, 에디터 로딩을 라우트 값 watch 기반으로 옮기는 편이 맞다고 판단했다.
|
||||
- 복사본에서 원본으로 이동하는 액션은 사용자가 편집 중이던 내용을 잃을 수 있으므로, 저장하지 않은 변경이 감지되는 경우에는 바로 이동하지 않고 확인 모달로 한 번 끊어주는 쪽이 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.42
|
||||
- 홈 템플릿 목록은 관리자가 아직 수동 순서를 건드리지 않은 신규 템플릿까지 이름순으로 섞이면 “새로 만든 항목이 앞에 보인다”는 운영 기대와 어긋나므로, 수동 순서가 없는 항목은 최신 생성순을 우선하는 정렬이 맞다고 판단했다.
|
||||
- 티어표 편집 조작은 드래그만으로도 충분하지만, 세밀한 이동이나 터치패드 환경에서는 클릭 선택 후 대상 셀 클릭 방식이 더 편할 수 있으므로 두 조작을 병행 지원하는 쪽으로 확장했다.
|
||||
- 다만 드래그 직후 click 이벤트가 이어서 들어오면 의도치 않은 재선택이 생길 수 있으므로, 드래그 시작 시 선택을 비우고 드래그 종료 직후 짧은 클릭 잠금을 두는 방식으로 충돌을 줄였다.
|
||||
|
||||
## 2026-04-03 v1.4.41
|
||||
- 관리자 기본 아이템 업로드는 운영자가 한 번에 많은 캐릭터 이미지를 정리하는 작업이 잦으므로, 서버 개별 파일 제한뿐 아니라 한 요청당 업로드 개수와 프록시 본문 크기 제한도 같이 넉넉하게 올려두는 편이 맞다고 판단했다.
|
||||
- 다중 업로드가 프런트에서 한 번의 `FormData` 요청으로 묶여 나가는 구조라면, 백엔드 `multer`만 올리고 Nginx `client_max_body_size`를 그대로 두면 병목이 남을 수 있으므로 프런트 프록시 제한도 함께 상향하는 쪽으로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.40
|
||||
- 공유 링크 진입 화면은 사용자가 조작 가능한 편집 화면보다 `뷰어 모드`로 명확히 분리하는 편이 안전하므로, 비로그인 사용자와 타인 티어표는 기본적으로 드래그 없는 완성본 열람 상태를 보여주기로 정리했다.
|
||||
- 공유 링크를 받은 비로그인 사용자도 다시 전달할 수 있어야 하므로 `공유하기`는 로그인 여부와 무관하게 뷰어 모드에서 열어두고, `내 티어표로 복사`는 로그인한 타인 열람자에게만 노출하는 쪽으로 권한을 나눴다.
|
||||
- 작성자 본인도 공유 화면이 어떻게 보이는지 확인할 필요가 있으므로, 본인 티어표는 `수정 모드`와 `뷰어 모드`를 양방향 전환할 수 있게 두는 편이 자연스럽다고 판단했다.
|
||||
- 뷰어 모드 우측 레일은 빈 공간을 크게 남기기보다 다른 화면처럼 광고를 상단에 두고, 실제 행동 버튼은 하단 카드로 모아 시각 균형과 전환 흐름을 함께 맞추는 방향으로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.39
|
||||
- 기존 저장 티어표를 다시 열 때마다 최신 템플릿 아이템으로 전체를 덮어쓰면 과거 결과물이 깨질 수 있으므로, 저장본의 기존 그룹/풀은 보존하고 새로 추가된 템플릿 아이템만 미사용 풀에 합류시키는 병합 방식이 더 안전하다고 판단했다.
|
||||
- 관리자 템플릿에서 삭제된 기본 아이템을 과거 저장본에서도 자동 삭제하면 사용자 결과물 보존성이 떨어지므로, 삭제는 이후 새 티어표 생성에만 반영하고 기존 저장본은 유지하는 정책을 계속 유지하기로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.38
|
||||
- 최고 관리자 보호는 서버에서만 막고 프런트 버튼은 열어두면 운영자가 계속 실패 액션을 누르게 되므로, 최고 관리자 대상 행은 일반 운영자 화면에서 버튼 자체를 비활성화하는 편이 맞다고 판단했다.
|
||||
- 예약어 닉네임은 일반 가입/프로필 수정에서는 계속 막아야 하지만, 운영자가 직접 회원 계정을 정리할 때는 `공식`, `운영자` 같은 표기를 의도적으로 부여해야 할 수 있으므로 관리자 수정 API만 예외를 두는 쪽으로 정리했다.
|
||||
- 공유 프리뷰는 별도 단독 페이지처럼 보이는 것보다 홈과 같은 좌측/우측 레일, 같은 중앙 헤더, 같은 메인 배경 위에 자연스럽게 얹히는 편이 서비스 유입과 탐색 전환에 더 유리하다고 판단해, 프리뷰 전용 셸을 공통 앱 셸로 흡수했다.
|
||||
|
||||
## 2026-04-02 v1.4.33
|
||||
- 서비스 공개 전 단계에서는 가입 자체를 열어두는 것보다, 이메일/닉네임 중복과 운영자 사칭성 닉네임을 먼저 막아두는 편이 훨씬 중요하다고 판단했다.
|
||||
- 닉네임 제한은 회원가입 한 곳에만 두면 이후 프로필 수정이나 관리자 수정으로 쉽게 우회되므로, auth/profile/admin 수정 흐름 전부가 같은 예약어 정책을 공유하도록 정리했다.
|
||||
@@ -612,6 +664,22 @@
|
||||
- 게임 이미지 경로는 저장 시 상대 경로(`/uploads/...`)를 유지하는 방향으로 정리했다.
|
||||
- 현재 단계에서는 구조 변경 비용을 고려해 DB를 유지하되, 운영/확장성 요구가 커지기 전 RDB 이관 판단이 필요하다고 기록했다.
|
||||
|
||||
## 2026-04-03 v1.4.37
|
||||
- 드롭 또는 클릭 안내를 보여주는 업로드 박스는 실제 클릭 동작까지 연결돼 있어야 UX가 자연스러우므로, 이후 같은 패턴에서는 박스 자체가 트리거 역할을 하도록 맞추기로 했다.
|
||||
|
||||
## 2026-04-03 v1.4.36
|
||||
- 복사 기능은 “타인 티어표 가져오기”에만 묶기보다, 본인 작업도 파생본을 빠르게 만드는 용도로 열어두는 편이 실제 제작 흐름에 더 맞는다고 정리했다.
|
||||
- 공유 프리뷰도 서비스와 완전히 단절된 단일 화면보다, 광고 레일과 카피라이트를 포함한 가벼운 사이트 문맥을 유지하는 편이 자연스럽다고 판단했다.
|
||||
|
||||
## 2026-04-03 v1.4.35
|
||||
- 실제 사용 테스트에서 아이템 수가 80개 안팎으로 늘어나면 “기능은 있는데 찾을 수 없는 상태”가 되기 쉬워, 편집기 풀에는 가벼운 이름 검색이 필수라고 정리했다.
|
||||
- 공유 링크는 완성본만 보여주는 데서 끝내지 말고 서비스 메인으로 돌아오는 손잡이를 함께 두는 편이 자연스럽다고 판단했다.
|
||||
- 공개 프리뷰의 작성 시각은 분 단위까지 노출할 필요가 낮아, 신뢰에 필요한 최소 정보만 남기는 쪽으로 줄이기로 했다.
|
||||
|
||||
## 2026-04-02 v1.4.34
|
||||
- 라이트모드는 단순 토글 존재만으로 충분하지 않고, 셸/카드/버튼/오버레이가 같은 색 문법을 공유해야 품질이 안정된다고 판단해 공통 토큰을 다시 정리했다.
|
||||
- 홈 카드 즐겨찾기 버튼처럼 다크 전용 하드코딩이 남아 있으면 전체 인상이 쉽게 무너지므로, 이후 테마 보정은 공통 변수 우선 원칙으로 계속 가져가기로 했다.
|
||||
|
||||
## 2026-03-19
|
||||
- 초기 저장소는 빠른 구현을 위해 `lowdb(JSON 파일)`를 채택했다.
|
||||
- 인증은 JWT 대신 서버 세션(`express-session` + `session-file-store`)을 사용했다.
|
||||
|
||||
32
docs/map.md
32
docs/map.md
@@ -6,19 +6,19 @@
|
||||
- 연동 API: `GET /api/topics`
|
||||
|
||||
## `/topics/:topicId`
|
||||
- 화면 파일: `frontend/src/views/GameHubView.vue`
|
||||
- 역할: 선택한 주제 정보 표시, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
|
||||
- 화면 파일: `frontend/src/views/TopicHubView.vue`
|
||||
- 역할: 선택한 주제 정보 표시, 관리자 추천 티어표 상단 강조 섹션과 일반 공개 티어표 목록 분리 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 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`
|
||||
- 화면 파일: `frontend/src/views/TierEditorView.vue`
|
||||
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청
|
||||
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰 렌더링
|
||||
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/:id`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`, `POST /api/tierlists/template-request`
|
||||
|
||||
## `/login`
|
||||
- 화면 파일: `frontend/src/views/LoginView.vue`
|
||||
- 역할: 로그인/회원가입 전환, 첫 가입 안내
|
||||
- 연동 API: `GET /api/auth/meta`, `POST /api/auth/login`, `POST /api/auth/signup`
|
||||
- 역할: 로그인/회원가입 전환, 첫 가입 안내, 일반 회원가입 후 이메일 인증 안내와 인증 메일 재전송, 비밀번호 재설정 메일 요청, `?verifyToken=...` 인증 링크 처리, `?resetToken=...` 새 비밀번호 설정 처리
|
||||
- 연동 API: `GET /api/auth/meta`, `POST /api/auth/login`, `POST /api/auth/signup`, `POST /api/auth/email/verify`, `POST /api/auth/email/resend`, `POST /api/auth/password-reset/request`, `POST /api/auth/password-reset/confirm`
|
||||
|
||||
## `/me`
|
||||
- 화면 파일: `frontend/src/views/MyTierListsView.vue`
|
||||
@@ -30,6 +30,16 @@
|
||||
- 역할: 즐겨찾기한 티어표 목록 조회, 검색/정렬, 라이브러리 카드형 표시, 편집 화면 이동, 즐겨찾기 상태 확인
|
||||
- 연동 API: `GET /api/tierlists/favorites/me`, `DELETE /api/tierlists/:id/favorite`
|
||||
|
||||
## `/following`
|
||||
- 화면 파일: `frontend/src/views/FollowingFeedView.vue`
|
||||
- 역할: 팔로우한 작성자의 공개 티어표를 최신 업데이트순 카드 목록으로 모아보기, 제목/주제/작성자 검색, 티어표 상세 이동, 작성자 프로필 이동
|
||||
- 연동 API: `GET /api/users/following-feed`
|
||||
|
||||
## `/users/:userId`
|
||||
- 화면 파일: `frontend/src/views/UserProfileView.vue`
|
||||
- 역할: 작성자 공개 프로필, 팔로워/팔로잉/공개 티어표 수 표시, 로그인 사용자의 팔로우/언팔로우 전환, 해당 작성자의 공개 티어표 목록 조회와 상세 이동
|
||||
- 연동 API: `GET /api/users/:userId`, `GET /api/users/:userId/tierlists`, `POST /api/users/:userId/follow`, `DELETE /api/users/:userId/follow`
|
||||
|
||||
## `/search`
|
||||
- 화면 파일: `frontend/src/views/SearchResultsView.vue`
|
||||
- 역할: 좌측 전역 검색 입력에서 넘긴 키워드로 공개 티어표 전체를 검색하고, 자체 검색 툴바 없이 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 목록으로 표시
|
||||
@@ -37,17 +47,17 @@
|
||||
|
||||
## `/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`
|
||||
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
|
||||
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, `preview=1` 공유 프리뷰에서도 같은 좌우 레일과 중앙 헤더 유지, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
|
||||
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker`와 서비스 설명이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
|
||||
|
||||
## 백엔드 진입점
|
||||
@@ -56,6 +66,8 @@
|
||||
- 로컬 DB 실행 설정: `docker-compose.yml`
|
||||
- 로컬 MariaDB 가이드: `docs/local-mariadb.md`
|
||||
- 인증 라우트: `backend/src/routes/auth.js`
|
||||
- 메일 발송 유틸: `backend/src/lib/mailer.js`
|
||||
- 주제 라우트: `backend/src/routes/topics.js`
|
||||
- 티어표 라우트: `backend/src/routes/tierlists.js`
|
||||
- 사용자/팔로우 라우트: `backend/src/routes/users.js`
|
||||
- 관리자 라우트: `backend/src/routes/admin.js`
|
||||
|
||||
93
docs/spec.md
93
docs/spec.md
@@ -9,7 +9,7 @@
|
||||
- 운영 배포: `frontend(Nginx 정적 서빙 + /api,/uploads 프록시) + backend + mariadb` Docker Compose 구조
|
||||
- NAS HTTPS 리버스 프록시 운영 시 프런트 Nginx는 백엔드로 `X-Forwarded-Proto: https`를 전달하고, Express 세션은 프록시 환경에서 `secure` 쿠키를 허용하도록 설정한다.
|
||||
- 프런트 파비콘은 운영 정적 파일 차단 영향을 줄이기 위해 `index.html`의 인라인 데이터 URL로 제공한다.
|
||||
- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, preview 모드에서는 이 셸을 숨기고 콘텐츠만 렌더링한다.
|
||||
- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, `preview=1` 모드에서도 같은 셸을 유지한 채 중앙 본문만 완성본 프리뷰로 렌더링한다.
|
||||
- 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 우측 패널은 상단 토글 버튼으로 접고 펼칠 수 있다.
|
||||
- 좌측 패널은 필요 시 축소형 레일로 접을 수 있으며, 접힌 상태에서는 아이콘 중심 내비게이션과 축약된 바로가기만 유지한다.
|
||||
- 이 3단 셸 구조는 홈, 게임 허브, 에디터, 관리자 등 일반 페이지 전반의 공통 뼈대로 유지하고, 페이지별 차이는 중앙/우측에 어떤 콘텐츠를 넣는지만 달라지도록 관리한다.
|
||||
@@ -45,6 +45,7 @@
|
||||
- 좌우 레일의 주요 CTA는 스크롤되는 본문과 분리된 하단 `56px` 액션 영역에 배치한다.
|
||||
- 하단 액션은 화면 바닥에 바로 붙지 않도록 푸터 내부에 추가 하단 여백을 둔다.
|
||||
- 홈 화면 기준 우측 패널은 임시 정보 카드 여러 개보다 핵심 CTA 하나만 남겨, 시안처럼 단순한 보조 레일 역할을 우선 유지한다.
|
||||
- 광고 영역은 상단 헤더와 시각적으로 너무 붙지 않도록 `78px` 상단 여백을 두고, 하단 카피라이트는 중앙 정렬된 공통 footer로 표시한다. 카피라이트 링크는 다크/라이트 테마 모두에서 읽히도록 고정 민트색 대신 테마 텍스트 색과 굵기를 사용한다.
|
||||
- 티어표 편집 화면
|
||||
- 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다.
|
||||
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 보드 영역은 메인 컬럼에, 우측 `320px` 편집 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
|
||||
@@ -54,7 +55,7 @@
|
||||
- 관리자 화면
|
||||
- 공통 우측 패널 대신 전용 로컬 운영 패널을 사용한다.
|
||||
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 관리 목록은 메인 컬럼에, 우측 운영 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
|
||||
- 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다. 템플릿 요청 카드는 전체 티어표 카드와 같은 썸네일 좌측/정보 우측 구조를 따르며, 요청 미리보기는 일반 티어표 완성본과 같은 행·열 보드 문법으로 검수한다.
|
||||
- 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다. 템플릿 요청 카드는 전체 티어표 카드와 같은 썸네일 좌측/정보 우측 구조를 따르며, 요청 미리보기와 `preview=1` 공유 프리뷰는 공통 앱 셸 안에서 일반 티어표 완성본과 같은 행·열 보드 문법으로 검수한다.
|
||||
- 상단 헤더에는 현재 탭 기준 요약 통계 카드를 배치해 운영 상태를 먼저 읽고, 각 관리 카드는 공통 대시보드 카드 문법(두꺼운 반경, 얕은 레이어 배경, 강조된 액션 버튼)을 공유한다.
|
||||
|
||||
## DB 스키마
|
||||
@@ -63,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
|
||||
@@ -93,6 +109,9 @@
|
||||
- 사용자가 직접 지정하지 않으면 저장 시 티어표 대표 아이템 이미지로 자동 채운다.
|
||||
- `description`: string
|
||||
- `isPublic`: boolean
|
||||
- `isFeatured`: boolean
|
||||
- `featuredAt`: number
|
||||
- `featuredBy`: string
|
||||
- `groups`: `{ id, name, itemIds[] }[]`
|
||||
- `pool`: `{ id, src, label, origin }[]`
|
||||
- `createdAt`: number
|
||||
@@ -101,6 +120,10 @@
|
||||
- `userId`: string
|
||||
- `tierListId`: string
|
||||
- `createdAt`: number
|
||||
- `userFollows`
|
||||
- `followerId`: string
|
||||
- `followingId`: string
|
||||
- `createdAt`: number
|
||||
- `gameSuggestions`
|
||||
- `id`: string
|
||||
- `name`: string
|
||||
@@ -109,16 +132,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`
|
||||
@@ -130,13 +166,24 @@
|
||||
- `POST /api/tierlists/thumbnail`
|
||||
- `POST /api/tierlists/custom-items`
|
||||
- `POST /api/tierlists`
|
||||
- 사용자/팔로우
|
||||
- `GET /api/users/following-feed`
|
||||
- 로그인한 사용자가 팔로우한 작성자의 공개 티어표를 최신 업데이트순으로 조회한다.
|
||||
- `GET /api/users/:userId`
|
||||
- 작성자 공개 프로필, 공개 티어표 수, 팔로워/팔로잉 수, 현재 로그인 사용자의 팔로우 여부를 반환한다.
|
||||
- `GET /api/users/:userId/tierlists`
|
||||
- 해당 작성자의 공개 티어표 목록을 반환한다.
|
||||
- `POST /api/users/:userId/follow`
|
||||
- `DELETE /api/users/:userId/follow`
|
||||
- 관리자
|
||||
- `POST /api/admin/templates`
|
||||
- `POST /api/admin/templates/:templateId/thumbnail`
|
||||
- `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`
|
||||
@@ -166,26 +213,42 @@
|
||||
- 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다.
|
||||
- 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다.
|
||||
- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 내부에서 `템플릿 요청 관리 / 전체 티어표 관리`를 분리해 볼 수 있고, 확인용 완성본은 탐색 UI 없는 preview 전용 모달로 연다.
|
||||
- `전체 티어표 관리`에서는 공개 티어표를 `추천 지정 / 추천 해제`할 수 있고, 추천 지정된 티어표는 주제별 공개 목록 상단의 `추천 티어표` 섹션에 먼저 노출된다. 비공개 티어표는 추천 지정할 수 없고, 추천글을 비공개로 바꾸면 추천 상태도 함께 해제된다.
|
||||
- `티어표 관리` 탭의 추가 아이템은 작은 그리드 카드로 표시하고, 클릭 시 `기존 템플릿에 추가 / 새 템플릿 만들기` 모달을 통해 목적지를 선택한다.
|
||||
- `티어표 관리` 탭에서는 티어표 안의 커스텀 아이템을 개별 또는 일괄로 기존 게임 템플릿에 복제할 수 있다.
|
||||
- `freeform` 티어표는 관리자 화면에서 새 게임 ID/이름을 입력해 새로운 게임 템플릿으로 복제 생성할 수 있다.
|
||||
- 관리자 티어표 관리 상단에는 사용자가 보낸 템플릿 등록/업데이트 요청 목록이 별도로 표시되며, 여기서 승인/반려를 바로 처리할 수 있다.
|
||||
- 관리자 템플릿 요청 목록에서 `반려 후 숨김`을 누르면 해당 요청은 pending 목록에서 즉시 제외된다.
|
||||
- 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다.
|
||||
- 단, 일반 운영자는 최고 관리자 계정의 프로필 이미지/회원 정보/비밀번호/삭제 버튼을 사용할 수 없고, 최고 관리자만 다른 관리자 권한을 변경할 수 있다.
|
||||
- 관리자 회원 정보 수정은 운영상 필요한 경우 예약어 닉네임도 저장할 수 있지만, 일반 회원가입과 개인 프로필 수정에서는 운영자 사칭성 예약어 닉네임을 계속 차단한다.
|
||||
- 회원 관리 카드에는 아바타, 작성 티어표 수, 최근 활동 시각을 함께 표시한다.
|
||||
|
||||
## 티어표 접근 메모
|
||||
- `new` 작성 경로는 로그인한 사용자만 진입할 수 있다.
|
||||
- 비로그인 사용자는 공개된 티어표를 열람만 할 수 있고, 편집 UI와 저장 동작은 비활성화된다.
|
||||
- 공유 링크로 여는 `preview=1` 화면은 `뷰어 모드`로 취급하며, 드래그/행열 편집/저장 같은 편집 UI 없이 완성본만 렌더링한다.
|
||||
- 비로그인 사용자나 작성자 본인이 아닌 로그인 사용자는 저장된 티어표를 기본적으로 뷰어 모드로 열람하며, 일반 편집 URL로 직접 진입해도 소유자가 아니면 `preview=1` 주소로 자동 전환된다.
|
||||
- 비로그인 사용자도 뷰어 모드 우측 레일의 `공유하기` 버튼으로 현재 공유 링크를 복사할 수 있다.
|
||||
- 로그인한 사용자는 뷰어 모드 우측 레일에서 저장된 티어표를 복사할 수 있고, 타인 티어표면 `내 티어표로 복사`, 본인 티어표면 `복사본 만들기` 문구를 사용한다. 작성자 본인은 `수정 모드로 전환`도 사용할 수 있다.
|
||||
- 작성자 본인이 일반 편집 화면에서 저장된 본인 티어표를 보고 있을 때는 우측 패널의 `뷰어 모드로 보기`로 공유 화면 형태를 바로 확인할 수 있다.
|
||||
- 편집/뷰어 우측 패널의 `작성자 프로필 보기`로 해당 작성자의 공개 프로필과 공개 티어표 목록을 열 수 있고, 로그인 상태에서는 작성자 프로필에서 팔로우/언팔로우를 전환할 수 있다.
|
||||
- 같은 `TierEditorView` 안에서 `topicId / tierListId / preview` 라우트 값만 바뀌어도 화면이 이전 티어표 데이터에 남지 않도록, 라우트 변경마다 에디터 상태를 다시 로드한다.
|
||||
- 복사한 티어표 상단의 `원본` 링크를 누르면 원본 티어표로 이동하며, 편집 모드에서 저장하지 않은 변경이 있으면 이동 전에 `저장 없이 이동` 확인 모달을 먼저 띄운다.
|
||||
- 본인 티어표도 저장된 상태라면 편집/뷰어 우측 패널에서 복사본을 만들 수 있고, 편집 중 저장하지 않은 수정이 남아 있으면 복사 직전에 현재 수정본을 먼저 저장해 최신 상태 기준 복사본을 만든다.
|
||||
- 비공개 티어표라도 관리자는 편집 화면에서 완성본을 열람할 수 있다.
|
||||
- 공개 티어표는 목록과 상세 화면에서 즐겨찾기 토글 및 개수를 함께 표시한다.
|
||||
- 카드형 목록에서는 즐겨찾기 수/상태만 표시하고, 실제 토글은 상세 화면에서 처리한다.
|
||||
- 공개 티어표 목록은 현재 게임 기준으로 제목/작성자 검색을 지원한다.
|
||||
- 주제별 공개 티어표 화면은 관리자 추천글을 상단 `추천 티어표` 섹션으로 먼저 보여주고, 일반 공개 목록은 아래 `전체 공개 티어표` 섹션으로 분리해 중복 없이 렌더링한다. 추천 섹션은 최대 16개까지 표시한다.
|
||||
- `내 즐겨찾기` 화면에서는 즐겨찾기한 순, 최신 업데이트순, 인기순 정렬을 제공한다.
|
||||
- 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다.
|
||||
- 사용자가 직접 추가한 커스텀 아이템 이름은 편집 화면 우측 목록에서 정리할 수 있고, 저장 시 원본 커스텀 아이템 라벨과 함께 동기화된다.
|
||||
- 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다.
|
||||
- 티어 행에 넣은 아이템은 작은 제거 버튼으로 다시 우측 아이템 풀로 되돌릴 수 있다.
|
||||
- 편집 가능한 티어표에서는 아이템을 드래그로 옮길 수 있고, 아이템을 클릭해 선택한 뒤 원하는 셀이나 아이템 풀 빈 영역을 클릭하는 방식으로도 위치를 바꿀 수 있다.
|
||||
- 클릭 배치 모드에서 선택된 아이템은 파란 포커스 테두리로 표시하며, 드래그를 시작하면 선택 상태를 해제하고 드래그 직후 짧은 클릭 입력은 무시해 드래그/클릭 조작이 섞이지 않게 한다.
|
||||
- 기존 저장 티어표/복사본을 수정 가능한 상태로 다시 열 때는 저장본의 그룹/풀을 먼저 복원하고, 이후 현재 템플릿에 새로 추가된 기본 아이템만 미사용 풀 끝에 병합한다.
|
||||
- 관리자 템플릿에서 삭제된 기본 아이템이라도 이미 저장된 티어표의 그룹/풀에 포함되어 있던 항목은 자동 제거하지 않고 그대로 보존한다.
|
||||
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
|
||||
- 제목이 비어 있는 상태로 저장하면 내부 제목은 현재 게임명을 기본값으로 사용한다.
|
||||
- 제목 입력이 비어 있는 동안에는 무분별한 도배 방지를 위한 관리자 임의 삭제 가능성 안내 문구를 표시한다.
|
||||
@@ -197,6 +260,7 @@
|
||||
- 티어표에 썸네일을 직접 지정하지 않으면 저장 시 대표 아이템 이미지 하나를 기본 썸네일로 자동 선택한다.
|
||||
- 편집 화면 상단 헤더는 좌측 제목/설명, 우측 썸네일 카드 구조를 사용하며 모바일에서는 한 열로 접힌다.
|
||||
- 티어표 편집 화면의 우측 패널은 공통 `rightRail`의 `localRightRailRoot`에 직접 section들을 쌓는 구조이며, 별도 외곽 래퍼 카드 없이 공통 오른쪽 컬럼 문법을 그대로 따른다.
|
||||
- 뷰어 모드 우측 패널도 같은 `localRightRailRoot`를 사용하며, 상단에는 광고 블록을, 하단에는 공유/복사/수정 전환 액션 카드를 배치한다.
|
||||
- 공통 앱 셸은 좌측/중앙/우측 컬럼마다 높이 `56px`의 상단 헤더 블록을 유지하며, 중앙 헤더에는 사이트 타이틀 `Tier Maker`와 현재 서비스 설명을 표시한다.
|
||||
- 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다.
|
||||
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
|
||||
@@ -206,11 +270,14 @@
|
||||
- 저장/삭제/가져오기 같은 사용자 행동 피드백은 전역 우측 상단 토스트로 표시한다.
|
||||
- 전역 토스트는 동일 메시지/타입이 연속 발생하면 하나로 합쳐 카운트를 올리고, 종료 시 짧은 페이드아웃 애니메이션을 사용한다.
|
||||
- 홈 게임 목록은 관리자 상단 고정 순서가 있으면 그 순서를 먼저 적용하고, 그 외 게임은 최근 생성순으로 뒤에 이어진다.
|
||||
- 홈 주제 템플릿 목록의 실제 정렬 우선순위는 `즐겨찾기 여부 → 관리자 수동 순서(displayRank) → 최신 생성순(createdAt DESC) → 이름순`이다.
|
||||
- `커스텀 티어표 만들기`는 카드가 아니라 홈 우측 상단 버튼으로 진입한다.
|
||||
|
||||
## 업로드 제한 메모
|
||||
- 프로필 아바타 업로드는 파일당 최대 `3MB`다.
|
||||
- 관리자 게임 썸네일/기본 아이템 업로드와 사용자 커스텀 이미지 업로드는 파일당 최대 `6MB`다.
|
||||
- 관리자 템플릿 썸네일/기본 아이템 업로드는 파일당 최대 `20MB`다.
|
||||
- 사용자 커스텀 이미지 업로드는 파일당 최대 `6MB`다.
|
||||
- 운영 프런트 Nginx는 다중 이미지 업로드 한 번의 요청 본문을 최대 `1024MB`까지 허용한다.
|
||||
- 현재는 업로드 전에 이미지 리사이즈/압축을 하지 않고 원본 파일을 그대로 저장한다.
|
||||
|
||||
## 운영 환경 변수
|
||||
@@ -228,6 +295,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)이다.
|
||||
|
||||
45
docs/todo.md
45
docs/todo.md
@@ -1,6 +1,44 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 단기 확인
|
||||
- `v1.4.53`에서 본인 티어표 복사 버튼을 다시 열었으므로, 작성자 본인 편집 모드와 뷰어 모드 모두에서 `복사본 만들기`가 보이고, 복사 후 새 복사본 화면으로 실제 이동하는지 확인한다.
|
||||
- 본인 티어표를 수정한 뒤 저장하지 않은 상태로 `복사본 만들기`를 누르면 복사 직전에 원본이 먼저 저장되고, 새 복사본이 방금 수정한 최신 내용 기준으로 생성되는지 QA한다.
|
||||
- `/users/:userId` 작성자 프로필에서 비로그인 사용자는 팔로우 버튼이 안 보이고, 로그인 사용자는 타인 프로필에서 `팔로우 / 팔로잉` 전환과 팔로워 수 갱신이 정상이며, 자기 프로필에서는 팔로우 버튼이 숨겨지는지 확인한다.
|
||||
- `/following` 팔로우 피드는 팔로우한 작성자의 공개 티어표만 최신 업데이트순으로 보이고, 비로그인 진입 시 `/login?redirect=/following`으로 이동하며, 검색어로 제목/주제/작성자를 필터링할 수 있는지 확인한다.
|
||||
- 티어표 편집/뷰어 우측 패널의 `작성자 프로필 보기`가 현재 티어표 작성자 프로필로 정확히 이동하고, 복사본에서는 복사본 작성자 자신 프로필로, 원본 링크는 기존처럼 원본 티어표로 이동하는지 함께 QA한다.
|
||||
- `v1.4.51`에서 주제별 공개 목록을 `추천 티어표 / 전체 공개 티어표`로 분리했으므로, 추천 지정된 티어표가 상단 강조 섹션에만 나오고 아래 일반 목록에는 중복되지 않는지, 추천 해제 즉시 아래 일반 목록으로 내려가는지 확인한다.
|
||||
- 관리자 `전체 티어표 관리`에서 공개 글은 `추천 지정 / 추천 해제`가 정상 동작하고, 비공개 글은 추천 지정 버튼이 비활성화되며, 추천글을 비공개로 바꾸면 추천 상태가 자동 해제되는지 QA한다.
|
||||
- 추천 섹션은 최대 16개까지만 보여주도록 잘라두었으므로, 17개 이상 추천 지정 시 최근 지정순과 좋아요 수 보조 정렬이 기대대로 적용되는지 한 번 더 확인한다.
|
||||
- `v1.4.50`에서 설정 화면을 좌우 2열 카드형으로 나눴으므로, 데스크톱 폭에서는 프로필 정보가 왼쪽, 비밀번호 변경이 오른쪽에 나란히 보이고, 모바일/좁은 폭에서는 두 카드가 자연스럽게 위아래로 쌓이는지 확인한다.
|
||||
- `v1.4.49`에서 설정 화면에 비밀번호 변경 섹션을 추가했으므로, 현재 비밀번호가 틀린 경우 `현재 비밀번호가 일치하지 않아요.`, 새 비밀번호 확인이 다른 경우 `비밀번호 확인이 일치하지 않아요.`, 성공 시 `비밀번호를 변경했어요.` 토스트가 각각 정확히 뜨는지 확인한다.
|
||||
- 설정 화면 닉네임 저장도 중복/예약어 에러를 구체적으로 보여주도록 바꿨으므로, 이미 사용 중인 닉네임과 예약어 닉네임을 각각 넣었을 때 서버 문제처럼 보이지 않고 원인 문구가 정확히 뜨는지 QA한다.
|
||||
- 로그인한 상태로 비밀번호 재설정 메일의 `login?resetToken=...` 링크를 눌렀을 때도 바로 내 티어표 화면으로 튕기지 않고 `새 비밀번호 설정` 화면이 먼저 뜨는지 확인한다.
|
||||
- `v1.4.48`에서 로컬 `APP_ORIGIN`을 `localhost:5173`으로 먼저 주입하도록 바꿨으므로, 백엔드를 다시 띄운 뒤 새 회원가입 인증 메일과 비밀번호 재설정 메일 링크가 운영 도메인이 아니라 로컬 주소로 열리는지 확인한다.
|
||||
- `v1.4.47`에서 로컬 백엔드가 루트 `.env.production`을 읽도록 바꿨으므로, `SMTP_PASS` 교체 후 백엔드를 다시 띄우고 로컬 회원가입이 더 이상 `mail_not_configured` 503으로 떨어지지 않는지 확인한다.
|
||||
- `.env.production`의 `SMTP_PASS=여기에_Gmail_앱_비밀번호_입력` placeholder를 실제 Gmail 앱 비밀번호로 교체한 뒤, 운영 컨테이너를 재기동해서 회원가입 인증 메일과 비밀번호 재설정 메일이 실제로 발송되는지 확인한다.
|
||||
- `v1.4.45`에서 이메일 인증/비밀번호 재설정 메일 발송을 Gmail SMTP로 붙였으므로, 운영 `.env`에 `SMTP_USER`, `SMTP_PASS`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_FROM`, `APP_ORIGIN`을 넣은 뒤 실제 회원가입 인증 메일과 비밀번호 재설정 메일이 도착하는지 확인한다.
|
||||
- 일반 회원가입 직후에는 자동 로그인되지 않고 인증 안내 문구가 떠야 하며, 메일의 `login?verifyToken=...` 링크를 누르면 이메일 인증과 로그인 세션 생성이 함께 끝나는지 QA한다.
|
||||
- 미인증 계정으로 로그인하면 `email_unverified` 상태 안내가 뜨고, `인증 메일 재전송` 버튼으로 같은 메일 주소에 인증 링크를 다시 보낼 수 있는지 확인한다.
|
||||
- `비밀번호를 잊으셨나요?`에서 재설정 메일을 요청한 뒤 `login?resetToken=...` 링크로 들어가면 새 비밀번호 입력 화면이 열리고, 저장 후 바로 로그인 상태로 내 티어표 화면으로 이동하는지 확인한다.
|
||||
- Gmail 주소 그대로 발송하는 1차 단계에서는 도메인 DNS 인증을 당장 쓰지 않지만, 이후 `noreply@sori.studio` 같은 도메인 발신 주소로 바꿀 경우 Cloudflare DNS에 SPF/DKIM/DMARC를 설정하는 후속 작업이 필요하다.
|
||||
- `v1.4.44`에서 공통 카피라이트 `zenn` 링크를 테마 텍스트 색으로 바꿨으므로, 다크/라이트 모드 양쪽에서 하단 링크가 배경에 묻히지 않고 hover 상태도 자연스러운지 확인한다.
|
||||
- `v1.4.43`에서 같은 `TierEditorView` 라우트 안에서도 `topicId / tierListId / preview`가 바뀌면 상태를 다시 불러오게 했으므로, 타인 티어표 복사 직후 화면이 내 복사본으로 바뀌는지와 상단 원본 링크 클릭 시 실제 원본 티어표 내용으로 전환되는지 확인한다.
|
||||
- 편집 중 미저장 변경이 있는 상태에서 상단 원본 링크를 눌렀을 때는 경고 모달이 뜨고, `계속 편집`은 현재 화면 유지, `저장 없이 이동`은 원본으로 이동하면서 변경분을 버리는지 QA한다.
|
||||
- `v1.4.42`에서 홈 템플릿 정렬을 `즐겨찾기 → 수동 순서 → 최신 생성순 → 이름순`으로 바꿨으므로, 관리자에서 아무 수동 정렬을 하지 않은 신규 템플릿이 가장 앞쪽에 보이고, 즐겨찾기/수동 고정 항목은 기존 우선순위를 유지하는지 확인한다.
|
||||
- 티어표 편집기의 클릭 배치를 추가했으므로, 풀 아이템 클릭→빈 셀 클릭, 셀 아이템 클릭→다른 셀 클릭, 셀 아이템 클릭→풀 빈 영역 클릭, 같은 아이템 재클릭 선택 해제, 드래그 직후 의도치 않은 재선택 방지까지 한 번씩 QA한다.
|
||||
- 클릭 배치에서 이미 아이템이 들어 있는 셀 안의 빈 영역을 눌렀을 때는 해당 셀 끝에 추가되고, 같은 셀을 다시 누르면 선택만 해제되는지 확인한다.
|
||||
- `v1.4.41`에서 관리자 템플릿 기본 아이템 다중 업로드를 `100개/파일당 20MB`와 Nginx `client_max_body_size 1024m`으로 올렸으므로, 운영 NAS 앞단 리버스 프록시에도 별도 본문 크기 제한이 있으면 같은 수준으로 맞춰야 하는지 확인한다.
|
||||
- 실제 QA에서는 10개 이상, 50개 이상, 100개 근처의 이미지 묶음을 한 번에 올렸을 때 브라우저/프런트 Nginx/백엔드 중 어느 단계에서도 `413`이나 업로드 실패가 나지 않는지 확인한다.
|
||||
- `v1.4.40`에서 `preview=1` 공유 화면을 뷰어 모드로 정리했으므로, 비로그인/로그인한 타인/작성자 본인 세 경우에 드래그 편집이 막히고 오른쪽 레일 버튼이 각각 `공유하기`, `내 티어표로 복사`, `수정 모드로 전환` 조건대로 노출되는지 확인한다. 특히 비로그인/타인이 일반 편집 URL로 직접 들어왔을 때도 자동으로 `preview=1`로 바뀌는지 본다.
|
||||
- 작성자 본인 편집 화면에는 `뷰어 모드로 보기`가 추가됐으므로, 저장된 본인 티어표에서 뷰어 모드로 진입한 뒤 다시 `수정 모드로 전환`으로 돌아오는 왕복 라우팅이 자연스러운지 QA한다.
|
||||
- 뷰어 모드 오른쪽 레일이 공통 로컬 레일 마운트를 다시 사용하게 바뀌었으므로, 데스크톱/태블릿 폭에서 광고가 상단에 나오고 액션 카드가 하단으로 내려가며, 우측 레일 접기/펼치기 시 콘텐츠가 깨지지 않는지 확인한다.
|
||||
- `v1.4.39`에서 기존 저장 티어표/복사본을 다시 열 때 최신 템플릿 기본 아이템이 미사용 풀로 합류하도록 바꿨으므로, `12345`로 저장한 티어표를 만든 뒤 관리자 템플릿에 `6789`를 추가하고 다시 열거나 복사했을 때 `6789`만 미사용 상태로 나타나는지 확인한다.
|
||||
- 같은 정책에서 관리자 템플릿에서 삭제한 기존 아이템은 과거 저장 티어표의 그룹/풀 안에 남아 있으면 계속 보존되어야 하므로, `5`번을 삭제해도 이미 사용한 옛 티어표에서 사라지지 않는지 다시 확인한다.
|
||||
- `v1.4.39`에서 주제별 공개 티어표 화면을 `pageWrap`으로 감쌌으므로, 좁은 브라우저 폭에서 검색창이 아래 줄로 내려온 상태에서도 검색창과 카드 목록 사이 간격이 홈/즐겨찾기 화면과 비슷하게 유지되는지 확인한다.
|
||||
- `v1.4.38`에서 운영자 계정의 최고 관리자 관리 버튼을 프런트에서 비활성화했으므로, 최고 관리자/일반 운영자/일반 회원 세 계정 조합으로 회원 정보 수정, 썸네일 변경, 비밀번호 초기화, 삭제 버튼 활성 상태와 서버 차단 응답이 기대대로 맞는지 확인한다.
|
||||
- 관리자 회원 정보 수정에서는 예약어 닉네임을 허용하도록 예외를 열었으므로, 일반 회원가입/개인 프로필 수정은 여전히 예약어가 막히고 관리자 수정만 저장되는지 한 번 더 QA한다.
|
||||
- `preview=1` 화면이 공통 앱 셸을 그대로 쓰도록 바뀌었으므로, 좌측 레일 접기/펼치기, 우측 레일 닫기/열기, 중앙 헤더 타이틀, 메인 배경색, 오른쪽 광고와 카피라이트 정렬이 홈 화면과 같은 문법으로 보이는지 비교 QA한다.
|
||||
- 프리뷰 우측 광고에 `padding-top: 78px`를 넣었으므로, 데스크톱/태블릿 폭에서 광고 시작 위치가 너무 내려가거나 모바일 오버레이 레이아웃에서 어색해지지 않는지 확인한다.
|
||||
- `v1.4.33`에서 회원가입에 닉네임 입력과 중복/예약어 검사를 붙였으므로, 실제 QA에서는 이메일 중복, 닉네임 중복, 예약 닉네임, 프로필 닉네임 변경, 관리자 회원 수정 흐름이 같은 규칙으로 막히는지 확인한다.
|
||||
- 테마는 저장값이 없을 때 무조건 다크로 시작하게 바꿨고 설정 화면 토글도 다시 열었으므로, 첫 접속/새 브라우저/다른 운영체제에서 기본 다크 시작과 수동 토글 저장이 그대로 정상인지 확인한다.
|
||||
- 관리자 템플릿 썸네일 드롭존 빈 상태 아이콘 제거와 아이템 상세 모달 썸네일 프리뷰가 들어갔으므로, 관리자 화면에서 썸네일 교체와 아이템 선택 모달 가독성을 한 번 더 QA한다.
|
||||
@@ -102,6 +140,10 @@
|
||||
- 아이템 관리의 같은 이미지 참조 요약과 상세 모달은 붙였으므로, 실제 운영 데이터에서 카드 수치와 모달 목록이 기대한 참조 관계와 맞는지 한 번 더 QA한다.
|
||||
|
||||
## 중기 개선
|
||||
- 목록 카드의 작성자 메타를 카드 전체 열기 버튼과 충돌 없이 직접 프로필 링크로 분리하는 후속 UX를 검토한다.
|
||||
- 추천 티어표는 이번에 관리자 수동 지정부터 붙였으므로, 다음 단계에서는 최근 N일 좋아요 수 기준 추천 후보 필터와 추천 섹션 노출 개수 설정을 관리자 화면에 추가할지 검토한다.
|
||||
- 이메일 인증/비밀번호 재설정 1차 구현이 들어갔으므로, 다음 단계에서는 Gmail 발신 기반이 실제 운영에서 스팸함으로 얼마나 가는지 보고 필요하면 Cloudflare DNS의 SPF/DKIM/DMARC와 도메인 발신 주소 전환을 정리한다.
|
||||
- 구글 계정 로그인은 아직 붙이지 않았으므로, 이메일 인증 안정화 후 Google OAuth 클라이언트를 만들고 일반 이메일 계정과 같은 이메일의 구글 계정을 자동 연결할지 정책을 먼저 결정한다.
|
||||
- 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다.
|
||||
- 관리자 템플릿 요청은 동일 `src` 중복 생성 방지와 `연결된 게임/이미 반영 n개` 표시까지 붙였으므로, 이후에는 요청별 반영 이력(예: 어떤 아이템을 언제 반영했는지)까지 별도로 남길지 검토한다.
|
||||
- 관리자 URL 분리는 시작했으므로, 다음 단계에서는 `AdminView.vue` 단일 대형 파일을 섹션별 뷰/컴포저블로 쪼개고 직접 진입 시 선택 게임/요청 작업 상태 복원 범위도 함께 정리한다.
|
||||
@@ -113,6 +155,7 @@
|
||||
- 관리자 게임 아이템 순서 저장은 추가됐으므로, 다음 단계에서는 새 아이템 추가 직후 `자동 맨 앞 배치`와 `관리자 수동 고정 순서`의 우선순위를 실제 운영 흐름 기준으로 한 번 더 QA한다.
|
||||
- 관리자 템플릿 요청 미리보기는 실제 완성본 iframe 방식과의 체감 차이를 마지막으로 한 번 더 QA한다.
|
||||
- 라이트모드/다크모드 2차 보정까지 반영했으므로, 남은 작업은 전체 화면을 실제 사용 흐름으로 돌려 보며 대비·명도·아이콘 가독성을 미세하게 QA하는 최종 테마 점검 단계로 가져간다.
|
||||
- 라이트모드 공통 토큰 재정비와 카드/아바타/즐겨찾기 버튼 보정까지 반영했으므로, 다음 QA에서는 로그인/홈/주제 허브/에디터/관리자 순으로 실제 플로우를 돌리며 남은 하드코딩 색과 과한 대비가 없는지 확인한다.
|
||||
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
|
||||
- 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다.
|
||||
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
|
||||
@@ -123,6 +166,8 @@
|
||||
- 책 아이콘 기반 사용법 모달은 제작 흐름뿐 아니라 복사, 템플릿 업데이트 요청, 새 템플릿 요청까지 확장했으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다.
|
||||
- 관리자 아이템 라이브러리에서 동일 이미지(src)를 여러 템플릿이 공유하는 경우, 필요하면 묶어서 보거나 대표 카드로 합쳐 보는 후속 정리 옵션을 검토한다.
|
||||
- 라이트모드 최종 QA 시 홈/설정/관리자/에디터를 실제 사용 흐름으로 돌리며, 남아 있는 하드코딩 텍스트 색과 플레이스홀더 배경을 한 번 더 점검한다.
|
||||
- 템플릿 기본 아이템 다중 업로드는 8개까지 성공, 9개 이상 한 번에 전송 시 실패하는 사례가 있었으므로 NAS/리버스 프록시의 업로드 body 제한(`client_max_body_size` 등)과 실제 응답 코드를 운영 환경에서 확인한다.
|
||||
- 프리뷰 우측 광고 레일을 붙였으므로, 실제 운영 환경에서 광고가 로드될 때 프리뷰 본문 폭이 과하게 줄지 않는지 데스크톱 기준으로 한 번 더 확인한다.
|
||||
- 관리자 아이템 라이브러리는 보관 자산까지 노출되므로, 이후에는 `활성 템플릿 / 보관 자산` 분리 필터나 그룹 보기까지 검토한다.
|
||||
- 가이드 모달과 관리자 아이템 모달은 현재 같은 톤의 큰 셸을 쓰므로, 이후 공통 모달 레이아웃 컴포넌트로 분리할지 검토한다.
|
||||
- 관리자 아이템 라이브러리 이름 변경은 템플릿·사용자 업로드·보관 자산까지 모두 가능하므로, 이후에는 일괄 이름 정리나 중복 이름 감지 보조 기능까지 검토한다.
|
||||
|
||||
100
docs/update.md
100
docs/update.md
@@ -1,5 +1,83 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-03 v1.4.53
|
||||
- 티어표 복사 버튼이 타인 티어표에서만 보이도록 묶여 있어 본인 티어표에서는 숨겨지던 문제를 고쳐, 저장된 본인 티어표도 `복사본 만들기`로 새 복사본을 만들 수 있게 복구했다.
|
||||
- 본인 티어표를 편집 중 저장하지 않은 변경이 있는 상태로 복사본을 만들면 화면에 보이는 최신 수정 내용이 빠질 수 있었으므로, 복사 실행 직전에 현재 수정본을 먼저 저장한 뒤 복사본을 생성하도록 보정했다.
|
||||
- 작성자 프로필 화면(`/users/:userId`)과 팔로우 피드 화면(`/following`)을 추가하고, 백엔드에 `user_follows` 테이블과 팔로우/언팔로우/작성자 공개 티어표/팔로잉 피드 API를 붙였다.
|
||||
- 티어표 편집/뷰어 우측 패널에 `작성자 프로필 보기` 진입점을 추가하고, 왼쪽 내비게이션에도 `팔로우 피드` 메뉴를 노출해 팔로우한 작성자의 공개 티어표를 따로 모아 볼 수 있게 했다.
|
||||
- 프런트 HTML 메타 제목/설명에서도 `게임 템플릿` 표현을 `템플릿` 기준 문구로 맞춰, 실제 서비스가 특정 게임만 다루는 것처럼 보이지 않도록 한 번 더 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.52
|
||||
- 관리자 전체 티어표 카드 컴포넌트의 `추천 지정 / 추천 해제` 버튼과 `추천 노출중` 배지, 추천 개수 통계 표시가 실제 릴리스 커밋에 함께 포함되도록 누락 파일을 다시 묶었다.
|
||||
|
||||
## 2026-04-03 v1.4.51
|
||||
- 주제별 공개 티어표 목록을 `추천 티어표`와 `전체 공개 티어표`로 분리해, 관리자가 추천 지정한 글은 상단 강조 섹션에 먼저 보여주고 아래 일반 목록에서는 중복 노출되지 않도록 정리했다.
|
||||
- 관리자 `전체 티어표 관리` 카드에 `추천 지정 / 추천 해제` 버튼과 `추천 노출중` 배지를 추가하고, 상단 통계에도 추천 개수를 함께 표시하도록 보강했다.
|
||||
- 백엔드 `tierlists`에 `is_featured`, `featured_at`, `featured_by`를 추가하고, 공개 목록 API가 추천 티어표 최대 16개와 일반 공개 티어표 목록을 분리해서 내려주도록 확장했다.
|
||||
- 비공개 티어표를 추천으로 지정하려는 경우는 서버에서 `public_tierlist_required`로 차단하고, 이미 추천된 글을 비공개로 전환하면 추천 상태도 자동 해제되도록 맞췄다.
|
||||
|
||||
## 2026-04-03 v1.4.50
|
||||
- 설정 화면 메인 영역이 `max-width: 620px` 단일 컬럼으로 고정되어 넓은 화면에서 오른쪽 공간이 많이 비어 보였으므로, 프로필 정보 카드와 비밀번호 변경 카드를 좌우 2열 그리드로 나누고 좁은 화면에서만 1열로 내려가도록 레이아웃을 재정리했다.
|
||||
- 왼쪽 카드는 아바타/닉네임/이메일/로그아웃/프로필 저장을, 오른쪽 카드는 현재 비밀번호 확인과 새 비밀번호 저장을 담당하게 분리해, 설정 화면의 정보 묶음이 더 명확하게 읽히도록 맞췄다.
|
||||
|
||||
## 2026-04-03 v1.4.49
|
||||
- 설정 화면에 현재 비밀번호 확인 후 새 비밀번호를 직접 저장하는 `비밀번호 변경` 섹션을 추가하고, 백엔드에는 로그인 사용자용 `POST /api/auth/password` API를 붙였다.
|
||||
- 프로필 닉네임 저장 실패가 모두 `프로필 저장에 실패했어요.`로 뭉뚱그려 보이던 부분을 고쳐, 중복 닉네임은 `닉네임이 이미 사용 중이에요.`, 예약어 닉네임은 `사용할 수 없는 닉네임이에요.`처럼 회원가입 화면과 같은 맥락의 원인 안내로 분리했다.
|
||||
- 로그인한 상태로 `login?resetToken=...` 비밀번호 재설정 링크를 열면 기존 로그인 감시가 바로 내 티어표 화면으로 보내버릴 수 있었으므로, 인증/재설정 토큰이 있는 동안에는 자동 리다이렉트를 멈추고 재설정 입력 화면을 우선 보여주도록 보정했다.
|
||||
|
||||
## 2026-04-03 v1.4.48
|
||||
- 로컬 백엔드도 `.env.production`을 읽는 구조가 되면서 이메일 인증/비밀번호 재설정 링크의 `APP_ORIGIN`이 운영 도메인으로 잡히던 문제를 막기 위해, `backend`의 `dev/start` 스크립트에서 로컬 실행 시 `APP_ORIGIN=http://localhost:5173`을 먼저 주입하도록 분리했다.
|
||||
- 이로써 로컬 개발에서는 인증 메일 링크가 `localhost:5173`으로 열리고, 상용 Docker 배포에서는 `docker-compose.prod.yml`의 `APP_ORIGIN=https://tmaker.sori.studio`를 그대로 사용하도록 환경이 구분된다.
|
||||
|
||||
## 2026-04-03 v1.4.47
|
||||
- 로컬 개발 서버를 `npm run dev:backend`로 띄울 때 루트 `.env.production`의 `SMTP_*` 값이 자동으로 들어가지 않아 일반 회원가입이 `mail_not_configured` 503으로 실패할 수 있었으므로, 백엔드 엔트리에서 `dotenv`로 루트 `.env.production`을 먼저 로드하도록 보강했다.
|
||||
- 이 변경으로 Docker Compose 운영 환경은 기존 컨테이너 환경변수를 그대로 쓰면서, 로컬 개발 서버도 같은 `.env.production`의 Gmail SMTP 설정을 읽어 이메일 인증/비밀번호 재설정 메일 발송을 테스트할 수 있게 됐다.
|
||||
|
||||
## 2026-04-03 v1.4.46
|
||||
- 운영용 `.env.production`에는 Git에 올리지 않는 로컬 비밀값을 유지한 채, Gmail SMTP 발송에 필요한 `APP_ORIGIN`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM` 항목을 추가했다.
|
||||
- Git에 추적되는 `.env.production.example`에도 같은 SMTP 환경변수 예시를 추가해, 실제 배포 설정에서 어떤 키를 채워야 하는지 파일만 보고도 바로 알 수 있게 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.45
|
||||
- Gmail SMTP를 사용하는 이메일 인증/비밀번호 재설정 1차 흐름을 추가했다. 첫 관리자 계정은 기존처럼 바로 활성화되지만, 일반 회원은 가입 직후 인증 메일을 받고 `login?verifyToken=...` 링크로 인증을 마쳐야 로그인할 수 있게 바꿨다.
|
||||
- 로그인 화면에 `인증 메일 재전송`, `비밀번호를 잊으셨나요?`, `login?resetToken=...` 기반 새 비밀번호 설정 UI를 추가해, 메일 링크를 받은 사용자가 같은 `/login` 화면에서 인증 완료와 비밀번호 재설정을 이어서 처리할 수 있게 했다.
|
||||
- 백엔드 `users`에 `email_verified`를 추가하고, 이메일 인증/비밀번호 재설정 토큰을 해시로 저장하는 전용 테이블과 API를 추가했다. 운영 배포용 `docker-compose.prod.yml`에는 `APP_ORIGIN`, `SMTP_*` 환경변수 자리를 열어 Gmail 앱 비밀번호를 코드에 넣지 않고 주입할 수 있게 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.44
|
||||
- 오른쪽 레일 공통 카피라이트의 `zenn` 링크가 민트 단색이라 라이트 모드에서 배경과 충분히 분리되지 않을 수 있었으므로, 테마 텍스트 색 기반의 굵은 링크 스타일로 바꿔 다크/라이트 양쪽에서 읽히도록 조정했다.
|
||||
|
||||
## 2026-04-03 v1.4.43
|
||||
- 다른 사람 티어표를 복사한 직후 URL은 복사본 ID로 바뀌었는데 화면 데이터가 기존 원본에 남아 있을 수 있었던 문제를 고치기 위해, `TierEditorView`가 같은 컴포넌트 안에서 `topicId / tierListId / preview` 라우트 값이 바뀔 때마다 편집기 상태를 다시 로드하도록 바꿨다.
|
||||
- 복사한 티어표 상단의 원본 링크를 클릭했을 때도 주소만 바뀌고 화면이 그대로 남지 않도록, 원본 이동 버튼이 같은 재로딩 흐름을 타게 정리했다.
|
||||
- 작성자 본인 편집 모드에서 저장하지 않은 수정 내용이 있는 상태로 원본 링크를 누르면, 현재 변경 내용이 사라진다는 확인 모달을 먼저 띄우고 `저장 없이 이동`을 선택한 경우에만 원본 티어표로 이동하도록 보강했다.
|
||||
|
||||
## 2026-04-03 v1.4.42
|
||||
- 홈 주제 템플릿 목록 정렬에서 수동 고정 순서가 같은 항목끼리 이름순으로 다시 정렬되던 부분을 바꿔, 즐겨찾기 우선과 관리자 수동 순서를 유지하되 수동 순서가 없는 템플릿은 최신 생성순으로 먼저 보이도록 맞췄다.
|
||||
- 티어표 편집기에서 아이템을 클릭으로도 옮길 수 있게 해, 아이템을 한 번 클릭하면 선택 포커스가 표시되고 원하는 티어 셀이나 아이템 풀 빈 영역을 클릭하면 해당 위치로 이동하도록 보강했다.
|
||||
- 클릭 배치와 기존 드래그 배치가 충돌하지 않도록 드래그 시작 시 선택 상태를 해제하고, 드래그 직후 짧은 시간 동안 아이템 클릭 선택을 무시하는 보호를 추가했다.
|
||||
|
||||
## 2026-04-03 v1.4.41
|
||||
- 관리자 템플릿 기본 아이템 다중 업로드 제한을 한 번에 `100개`, 파일당 `20MB`까지 받을 수 있도록 백엔드 `multer` 설정과 업로드 라우트 배열 제한을 함께 상향했다.
|
||||
- 프런트 Nginx 프록시에도 `client_max_body_size 1024m`을 추가해, 여러 이미지를 한 번의 `FormData` 요청으로 올릴 때 합산 본문 크기 제한 때문에 먼저 `413`으로 막히는 상황을 줄였다.
|
||||
|
||||
## 2026-04-03 v1.4.40
|
||||
- 공유 링크로 여는 `preview=1` 화면을 `뷰어 모드`로 정의하고, 드래그/편집 없이 완성본만 보이는 상태에서 오른쪽 레일 상단에는 광고, 하단에는 공유·복사·수정 전환 액션을 노출하도록 정리했다.
|
||||
- 비로그인 사용자나 작성자 본인이 아닌 사용자가 일반 편집 URL로 저장된 티어표를 직접 열어도 자동으로 `preview=1` 뷰어 모드 주소로 전환되도록 로딩 후 라우팅을 보정했다.
|
||||
- 비로그인 사용자도 뷰어 모드에서 `공유하기` 버튼을 사용할 수 있고, 로그인한 타인 티어표는 `내 티어표로 복사`, 작성자 본인 티어표는 `수정 모드로 전환`을 사용할 수 있게 권한별 액션을 분기했다.
|
||||
- 작성자 본인이 수정 화면에 있을 때는 우측 패널에 `뷰어 모드로 보기`를 추가해, 본인도 공유 화면과 같은 뷰어 모드를 바로 확인할 수 있게 했다.
|
||||
- 뷰어 모드에서도 에디터 로컬 오른쪽 레일을 마운트하도록 공통 앱 셸 조건을 보정해, 광고와 액션 카드가 실제 우측 패널에 안정적으로 렌더링되게 맞췄다.
|
||||
|
||||
## 2026-04-03 v1.4.39
|
||||
- 기존 저장 티어표/복사본을 수정 가능한 상태로 다시 열 때, 저장본에 없던 최신 템플릿 기본 아이템만 미사용 풀 맨 뒤에 자동 합류하도록 병합 로딩을 추가했다.
|
||||
- 관리자 템플릿에서 삭제된 기본 아이템이라도 이미 저장된 티어표의 그룹/풀에 남아 있는 항목은 그대로 보존해, 과거 결과물이 템플릿 정리 때문에 깨지지 않도록 유지했다.
|
||||
- 주제별 공개 티어표 목록 화면은 좁은 브라우저 폭에서 상단 검색 툴바가 아래 줄로 내려오면 카드 목록과 간격이 붙어 보일 수 있었으므로, 홈/즐겨찾기 화면과 같은 `pageWrap` 구조로 감싸 상단 영역과 목록 사이 여백을 유지하도록 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.38
|
||||
- 관리자 회원 관리에서 운영자 계정으로는 최고 관리자 계정의 썸네일 변경, 비밀번호 초기화, 회원 삭제, 회원 정보 수정 버튼이 비활성화되도록 프런트 보호를 추가했고, 자기 자신 삭제 버튼도 함께 막았다.
|
||||
- 관리자 회원 정보 수정에서는 운영자/관리자 예약어가 들어간 닉네임도 저장할 수 있도록 서버 검증 예외를 분리했고, 일반 회원가입과 개인 프로필 수정의 예약어 차단은 그대로 유지했다.
|
||||
- `preview=1` 티어표 화면은 별도 프리뷰 셸을 걷어내고 공통 좌측 레일·중앙 헤더·우측 레일을 그대로 쓰도록 바꿨으며, 프리뷰 본문 제목도 홈 화면과 같은 `pageHead` 문법으로 맞췄다.
|
||||
- 프리뷰/일반 화면의 오른쪽 광고 레일은 같은 공통 레일 footer를 사용하게 되어 카피라이트 중앙 정렬이 맞춰졌고, 애드센스 영역 상단에는 `padding-top: 78px`를 적용해 시각적 시작점을 조금 아래로 내렸다.
|
||||
- 로컬에서 생성되는 `backend/uploads/assets/` 최적화 이미지가 Git 변경분에 섞이지 않도록 `.gitignore` 제외 경로에 추가했다.
|
||||
|
||||
## 2026-04-02 v1.4.33
|
||||
- 회원가입 시 닉네임 입력을 함께 받도록 바꾸고, 이메일 중복과 닉네임 중복을 서버에서 명확히 차단하도록 정리했다.
|
||||
- `admin`, `운영자`, `관리자`, `official`, `zenn`처럼 운영자·공식 계정으로 오해될 수 있는 닉네임은 예약어로 막고, 프로필 수정/관리자 회원 수정에서도 같은 규칙을 공유하도록 맞췄다.
|
||||
@@ -1104,6 +1182,28 @@
|
||||
- **티어표 데이터 정규화**: 게임 이미지 경로가 절대 로컬 URL로 저장되지 않도록 저장/조회 시 `/uploads/...` 상대 경로로 정규화
|
||||
- **프로젝트 점검 결과 문서화**: DB 구조, 화면-파일 매핑, 코딩 규칙, 기술 명세, 남은 위험 요소를 `docs/`에 신규 정리
|
||||
|
||||
## 2026-04-03 v1.4.37
|
||||
- **썸네일 업로드 UX 보정**: 티어표 편집기 우측 `대표 썸네일` 프레임을 클릭/엔터/스페이스로 바로 파일 선택할 수 있게 바꾸고, 중복이던 `파일 업로드` 버튼은 제거
|
||||
|
||||
## 2026-04-03 v1.4.36
|
||||
- **자기 티어표 복사 허용**: 기존에는 타인의 저장본만 복사할 수 있었지만, 이제는 본인 티어표도 저장본이면 복사해서 일부만 수정한 새 버전으로 다시 작업할 수 있게 변경
|
||||
- **프리뷰 우측 레일 추가**: 공유 프리뷰 화면도 본 사이트 문법을 더 닮도록 우측에 300×600 광고 레일과 카피라이트를 붙이고, 모바일 폭에서는 자동으로 숨기도록 정리
|
||||
|
||||
## 2026-04-03 v1.4.35
|
||||
- **에디터 아이템 검색 추가**: 미배치 아이템이 많아졌을 때 바로 찾을 수 있도록 사이드바에 `아이템 이름 검색` 입력과 `표시 개수 / 전체 개수`를 추가
|
||||
- **검색 중 드래그 유지**: 아이템 풀 검색은 목록 순서를 바꾸지 않고 일치하지 않는 항목만 숨기는 방식으로 넣어, 검색 중에도 바로 드래그 배치할 수 있게 유지
|
||||
- **공유 프리뷰 유입선 보강**: 공유 링크 프리뷰 좌상단에 `Tier Maker` 로고 링크를 추가해, 미리보기에서 메인 화면으로 자연스럽게 돌아올 수 있게 함
|
||||
- **작성 시각 노출 축소**: 프리뷰와 이미지 저장 하단 메타 정보의 시간 표시를 제거하고 날짜까지만 남겨 개인 생활 패턴 노출을 줄임
|
||||
- **업로드 추적 로그 보강**: 관리자 템플릿 기본 아이템 업로드는 프런트/백엔드 양쪽에서 파일 수·총 용량·응답 상태를 콘솔에 남기도록 해, 다중 업로드 실패 원인을 다음 재현 때 바로 좁힐 수 있게 보강
|
||||
- **카피라이트 링크 변경**: 우측 레일 하단 카피라이트의 `zenn` 링크를 `https://x.com/zennbox`로 변경
|
||||
|
||||
## 2026-04-02 v1.4.34
|
||||
- **라이트모드 팔레트 재정비**: 공통 라이트 테마 색상을 회색 위주에서 더 정돈된 청회색 계열로 다시 잡고, 셸/레일/메인/카드 표면 대비를 처음부터 재조정
|
||||
- **공통 토큰 확장**: 강조색 강도, 강조 배경, 오버레이 스크림, 아바타 테두리, 즐겨찾기 버튼 상태색을 공통 변수로 분리해 화면별 하드코딩을 줄임
|
||||
- **홈 카드 보정**: 주제 카드 즐겨찾기 버튼이 라이트모드에서 검은 플로팅 버튼처럼 뜨던 문제를 테마 변수 기반으로 수정
|
||||
- **목록 카드 통일**: 주제 허브/나의 티어표/즐겨찾기/검색 결과 카드의 아바타 테두리를 공통 토큰으로 맞춰 라이트모드에서 카드 밀도가 덜 어색하게 보이도록 정리
|
||||
- **전역 셸 보정**: 백엔드 점검 안내 버튼과 가이드 모달 오버레이도 라이트모드에 맞는 공통 색상 체계로 통일
|
||||
|
||||
## 2026-03-19 v0.1.2
|
||||
- **로그인 UI 개선**: 로그인 카드 중앙 배치, 중복 타이틀 제거, 입력 overflow 수정, 엔터로 로그인/회원가입 제출
|
||||
- **안내문 조건화**: “첫 회원가입 계정은 admin” 문구는 유저가 0명일 때만 표시(`/api/auth/meta`)
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tier Maker | 게임 템플릿으로 만드는 티어표</title>
|
||||
<title>Tier Maker | 템플릿으로 쉽게 만드는 티어표</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="게임 템플릿과 커스텀 이미지로 티어표를 만들고 저장하고 공유하세요. 관리자 요청과 템플릿 업데이트 흐름까지 한 번에 관리할 수 있습니다."
|
||||
content="템플릿과 커스텀 이미지로 티어표를 만들고 저장하고 공유하세요. 관리자 요청과 템플릿 업데이트 흐름까지 한 번에 관리할 수 있습니다."
|
||||
/>
|
||||
<meta name="theme-color" content="#090d16" />
|
||||
<meta name="application-name" content="Tier Maker" />
|
||||
@@ -20,10 +20,10 @@
|
||||
<meta property="og:locale" content="ko_KR" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://tmaker.sori.studio/" />
|
||||
<meta property="og:title" content="Tier Maker | 게임 템플릿으로 만드는 티어표" />
|
||||
<meta property="og:title" content="Tier Maker | 템플릿으로 쉽게 만드는 티어표" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="게임 템플릿과 커스텀 이미지로 티어표를 만들고 저장하고 공유하세요. 관리자 요청과 템플릿 업데이트 흐름까지 한 번에 관리할 수 있습니다."
|
||||
content="템플릿과 커스텀 이미지로 티어표를 만들고 저장하고 공유하세요. 관리자 요청과 템플릿 업데이트 흐름까지 한 번에 관리할 수 있습니다."
|
||||
/>
|
||||
<meta property="og:image" content="https://tmaker.sori.studio/og-card.png" />
|
||||
<meta property="og:image:alt" content="Tier Maker 공유 썸네일" />
|
||||
@@ -31,10 +31,10 @@
|
||||
<meta property="og:image:height" content="630" />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Tier Maker | 게임 템플릿으로 만드는 티어표" />
|
||||
<meta name="twitter:title" content="Tier Maker | 템플릿으로 쉽게 만드는 티어표" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="게임 템플릿과 커스텀 이미지로 티어표를 만들고 저장하고 공유하세요."
|
||||
content="템플릿과 커스텀 이미지로 티어표를 만들고 저장하고 공유하세요."
|
||||
/>
|
||||
<meta name="twitter:image" content="https://tmaker.sori.studio/og-card.png" />
|
||||
</head>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
client_max_body_size 1024m;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { editorNewPath, favoritesPath, homePath, loginPath, mePath } from './lib/paths'
|
||||
import { editorNewPath, favoritesPath, followingFeedPath, homePath, loginPath, mePath } from './lib/paths'
|
||||
import { toApiUrl } from './lib/runtime'
|
||||
import { useToast } from './composables/useToast'
|
||||
import iconDockToLeft from './assets/icons/dock_to_left.svg'
|
||||
@@ -14,6 +14,7 @@ import iconAddNotes from './assets/icons/add_notes.svg'
|
||||
import iconDashboardCustomize from './assets/icons/dashboard_customize.svg'
|
||||
import iconSearch from './assets/icons/search.svg'
|
||||
import iconSettings from './assets/icons/settings.svg'
|
||||
import iconKidStar from './assets/icons/kid_star.svg'
|
||||
import iconMenuBook from './assets/icons/menu_book.svg'
|
||||
import RightRailAd from './components/RightRailAd.vue'
|
||||
import SvgIcon from './components/SvgIcon.vue'
|
||||
@@ -22,7 +23,7 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const { toasts, dismissToast } = useToast()
|
||||
const RIGHT_RAIL_COPYRIGHT_URL = 'https://zenn.town/@murabito'
|
||||
const RIGHT_RAIL_COPYRIGHT_URL = 'https://x.com/zennbox'
|
||||
const currentTopicId = computed(() => route.params.topicId || '')
|
||||
|
||||
const leftRailCollapsed = ref(false)
|
||||
@@ -43,7 +44,9 @@ const authReady = computed(() => auth.hydrated)
|
||||
const isAdmin = computed(() => authReady.value && !!auth.user?.isAdmin)
|
||||
const isPreviewMode = computed(() => route.query.preview === '1')
|
||||
const isAdminRoute = computed(() => String(route.name || '').startsWith('admin'))
|
||||
const usesLocalRightRail = computed(() => ['editEditor', 'newEditor'].includes(String(route.name || '')) || isAdminRoute.value)
|
||||
const usesLocalRightRail = computed(
|
||||
() => ['editEditor', 'newEditor'].includes(String(route.name || '')) || isAdminRoute.value
|
||||
)
|
||||
const isRightRailOverlay = computed(() => viewportWidth.value <= 1200)
|
||||
const isMobileLayout = computed(() => viewportWidth.value <= 860)
|
||||
const avatarUrl = computed(() => (auth.user?.avatarSrc ? toApiUrl(auth.user.avatarSrc) : ''))
|
||||
@@ -67,6 +70,7 @@ const leftNavItems = computed(() => {
|
||||
{ key: 'home', label: '주제 선택', path: '/', iconSrc: iconGridView },
|
||||
{ key: 'me', label: '나의 티어표', path: '/me', iconSrc: iconLists, requiresAuth: true },
|
||||
{ key: 'favorites', label: '즐겨찾기', path: '/favorites', iconSrc: iconFavorite, requiresAuth: true },
|
||||
{ key: 'followingFeed', label: '팔로우 피드', path: '/following', iconSrc: iconKidStar, requiresAuth: true },
|
||||
{ key: 'profile', label: '설정', path: '/profile', iconSrc: iconSettings, requiresAuth: true },
|
||||
]
|
||||
return items.filter((item) => !item.requiresAuth || (authReady.value && auth.user))
|
||||
@@ -219,6 +223,26 @@ const routeMeta = computed(() => {
|
||||
action: () => router.push(mePath()),
|
||||
}
|
||||
}
|
||||
if (route.name === 'followingFeed') {
|
||||
return {
|
||||
title: '팔로우 피드',
|
||||
subtitle: '팔로우한 작성자의 새 티어표',
|
||||
contextTitle: '구독 목록',
|
||||
contextText: '작성자 프로필에서 팔로우한 사람의 공개 티어표를 한곳에서 볼 수 있어요.',
|
||||
actionLabel: '즐겨찾기 보기',
|
||||
action: () => router.push(favoritesPath()),
|
||||
}
|
||||
}
|
||||
if (route.name === 'userProfile') {
|
||||
return {
|
||||
title: '작성자 프로필',
|
||||
subtitle: '공개 티어표와 팔로우',
|
||||
contextTitle: '작성자 탐색',
|
||||
contextText: auth.user ? '마음에 드는 작성자를 팔로우하고 새 공개 티어표를 피드에서 이어서 볼 수 있어요.' : '로그인하면 작성자를 팔로우할 수 있어요.',
|
||||
actionLabel: auth.user ? '팔로우 피드 보기' : '로그인하러 가기',
|
||||
action: () => router.push(auth.user ? followingFeedPath() : loginPath(route.fullPath)),
|
||||
}
|
||||
}
|
||||
if (route.name === 'profile') {
|
||||
return {
|
||||
title: '설정',
|
||||
@@ -424,7 +448,6 @@ function reloadApp() {
|
||||
<div
|
||||
class="appShell"
|
||||
:class="{
|
||||
'appShell--preview': isPreviewMode,
|
||||
'appShell--leftCollapsed': leftRailCollapsed,
|
||||
'appShell--rightClosed': !rightRailOpen,
|
||||
'appShell--rightOverlay': isRightRailOverlay,
|
||||
@@ -450,11 +473,6 @@ function reloadApp() {
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
<template v-else-if="isPreviewMode">
|
||||
<main class="appMain appMain--preview">
|
||||
<RouterView />
|
||||
</main>
|
||||
</template>
|
||||
<template v-else>
|
||||
<aside class="leftRail">
|
||||
<div class="leftRail__top railHeader">
|
||||
@@ -697,6 +715,7 @@ function reloadApp() {
|
||||
}
|
||||
|
||||
.backendFallback {
|
||||
min-width: 100dvw;
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -748,17 +767,13 @@ function reloadApp() {
|
||||
min-width: 128px;
|
||||
padding: 12px 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(98, 170, 255, 0.32);
|
||||
background: rgba(98, 170, 255, 0.18);
|
||||
border: 1px solid var(--theme-accent-soft-strong);
|
||||
background: var(--theme-accent-soft);
|
||||
color: var(--theme-text-strong);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.appShell--preview {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.leftRail,
|
||||
.rightRail {
|
||||
min-height: 100dvh;
|
||||
@@ -929,7 +944,7 @@ function reloadApp() {
|
||||
border-radius: 999px;
|
||||
object-fit: cover;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border: 1px solid var(--theme-avatar-border);
|
||||
background: var(--theme-surface-soft-3);
|
||||
}
|
||||
|
||||
@@ -1220,10 +1235,6 @@ function reloadApp() {
|
||||
border-right: 1px solid var(--theme-border);
|
||||
}
|
||||
|
||||
.appMain--preview {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-rows: 56px minmax(0, 1fr);
|
||||
@@ -1329,12 +1340,13 @@ function reloadApp() {
|
||||
}
|
||||
|
||||
.rightRail__footer a {
|
||||
color: #00ffff;
|
||||
color: var(--theme-text-strong);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.rightRail__footer a:hover {
|
||||
color: #00ffff;
|
||||
color: var(--theme-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -1453,7 +1465,7 @@ function reloadApp() {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 20px;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
background: var(--theme-overlay-scrim);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ onMounted(async () => {
|
||||
.rightRailAd {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding-top: 78px;
|
||||
}
|
||||
|
||||
.rightRailAd__eyebrow {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -15,6 +15,10 @@ const props = defineProps({
|
||||
userDisplayName: { type: Function, required: true },
|
||||
userAvatarFallback: { type: Function, required: true },
|
||||
removeUserAvatar: { type: Function, required: true },
|
||||
canEditUserAvatar: { type: Function, required: true },
|
||||
canEditUserInfo: { type: Function, required: true },
|
||||
canResetUserPassword: { type: Function, required: true },
|
||||
canDeleteUser: { type: Function, required: true },
|
||||
roleLabelOf: { type: Function, required: true },
|
||||
fmt: { type: Function, required: true },
|
||||
openUserPasswordModal: { type: Function, required: true },
|
||||
@@ -76,7 +80,12 @@ const userSortDirectionModel = computed({
|
||||
@change="props.onUserAvatarChange(user, $event)"
|
||||
/>
|
||||
<div class="userAvatarWrap">
|
||||
<button class="userAvatar userAvatarButton" type="button" :disabled="user.isAvatarBusy" @click="props.openUserAvatarPicker(user)">
|
||||
<button
|
||||
class="userAvatar userAvatarButton"
|
||||
type="button"
|
||||
:disabled="user.isAvatarBusy || !props.canEditUserAvatar(user)"
|
||||
@click="props.openUserAvatarPicker(user)"
|
||||
>
|
||||
<img v-if="props.userAvatarUrl(user)" class="userAvatar__image" :src="props.userAvatarUrl(user)" :alt="props.userDisplayName(user)" />
|
||||
<span v-else class="userAvatar__fallback">{{ props.userAvatarFallback(user) }}</span>
|
||||
<span class="userAvatarButton__overlay">{{ user.isAvatarBusy ? '업데이트중...' : '수정' }}</span>
|
||||
@@ -86,7 +95,7 @@ const userSortDirectionModel = computed({
|
||||
class="userAvatarRemoveButton"
|
||||
type="button"
|
||||
title="회원 썸네일 삭제"
|
||||
:disabled="user.isAvatarBusy"
|
||||
:disabled="user.isAvatarBusy || !props.canEditUserAvatar(user)"
|
||||
@click.stop="props.removeUserAvatar(user)"
|
||||
>
|
||||
<SvgIcon class="userAvatarRemoveIcon" :src="props.deleteIcon" :size="12" />
|
||||
@@ -111,13 +120,32 @@ const userSortDirectionModel = computed({
|
||||
</div>
|
||||
|
||||
<div class="userCard__actions userCard__actions--compact">
|
||||
<button class="iconActionButton" type="button" title="비밀번호 초기화" @click="props.openUserPasswordModal(user)">
|
||||
<button
|
||||
class="iconActionButton"
|
||||
type="button"
|
||||
title="비밀번호 초기화"
|
||||
:disabled="!props.canResetUserPassword(user)"
|
||||
@click="props.openUserPasswordModal(user)"
|
||||
>
|
||||
<SvgIcon class="iconActionButton__icon" :src="props.lockResetIcon" :size="18" />
|
||||
</button>
|
||||
<button class="iconActionButton iconActionButton--danger" type="button" title="회원 삭제" @click="props.openUserDeleteModal(user)">
|
||||
<button
|
||||
class="iconActionButton iconActionButton--danger"
|
||||
type="button"
|
||||
title="회원 삭제"
|
||||
:disabled="!props.canDeleteUser(user)"
|
||||
@click="props.openUserDeleteModal(user)"
|
||||
>
|
||||
<SvgIcon class="iconActionButton__icon" :src="props.deleteIcon" :size="18" />
|
||||
</button>
|
||||
<button class="btn btn--ghost userSaveButton" type="button" @click="props.openUserEditModal(user)">회원 정보 수정</button>
|
||||
<button
|
||||
class="btn btn--ghost userSaveButton"
|
||||
type="button"
|
||||
:disabled="!props.canEditUserInfo(user)"
|
||||
@click="props.openUserEditModal(user)"
|
||||
>
|
||||
회원 정보 수정
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -278,9 +278,16 @@ export function useAdminTemplateManager({
|
||||
|
||||
const fileDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'file')
|
||||
const requestDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'request')
|
||||
const totalUploadBytes = fileDrafts.reduce((sum, entry) => sum + Number(entry.file?.size || 0), 0)
|
||||
let uploadCount = 0
|
||||
|
||||
if (fileDrafts.length) {
|
||||
console.info('[admin] template item upload start', {
|
||||
topicId: selectedTemplateId.value,
|
||||
fileCount: fileDrafts.length,
|
||||
totalBytes: totalUploadBytes,
|
||||
labels: fileDrafts.map((entry) => entry.label.trim()),
|
||||
})
|
||||
const fd = new FormData()
|
||||
fileDrafts.forEach((entry) => {
|
||||
fd.append('images', entry.file)
|
||||
@@ -291,7 +298,25 @@ export function useAdminTemplateManager({
|
||||
credentials: 'include',
|
||||
body: fd,
|
||||
})
|
||||
if (!res.ok) throw new Error('failed')
|
||||
if (!res.ok) {
|
||||
const responseText = await res.text().catch(() => '')
|
||||
console.error('[admin] template item upload failed', {
|
||||
topicId: selectedTemplateId.value,
|
||||
fileCount: fileDrafts.length,
|
||||
totalBytes: totalUploadBytes,
|
||||
status: res.status,
|
||||
body: responseText,
|
||||
})
|
||||
const uploadError = new Error('failed')
|
||||
uploadError.status = res.status
|
||||
uploadError.body = responseText
|
||||
throw uploadError
|
||||
}
|
||||
console.info('[admin] template item upload success', {
|
||||
topicId: selectedTemplateId.value,
|
||||
fileCount: fileDrafts.length,
|
||||
totalBytes: totalUploadBytes,
|
||||
})
|
||||
uploadCount += fileDrafts.length
|
||||
}
|
||||
|
||||
@@ -316,6 +341,12 @@ export function useAdminTemplateManager({
|
||||
await loadTemplate()
|
||||
success.value = `템플릿 기본 아이템 ${uploadCount}개 추가를 완료했어요.`
|
||||
} catch (e) {
|
||||
console.error('[admin] uploadItem error', {
|
||||
message: e?.message || '',
|
||||
status: e?.status || 0,
|
||||
body: e?.body || '',
|
||||
data: e?.data || null,
|
||||
})
|
||||
const apiError = e?.data?.error || ''
|
||||
if (apiError === 'no_items_selected') {
|
||||
error.value = '추가할 요청 아이템이 없어요.'
|
||||
@@ -330,6 +361,10 @@ export function useAdminTemplateManager({
|
||||
error.value = '선택한 템플릿을 찾지 못했어요.'
|
||||
return
|
||||
}
|
||||
if (e?.status === 413) {
|
||||
error.value = '한 번에 업로드한 파일 용량이 너무 커서 실패했어요.'
|
||||
return
|
||||
}
|
||||
error.value = '아이템 추가에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,29 @@ export function useAdminUsers({
|
||||
return !modalTargetUser.value.isPrimaryAdmin
|
||||
})
|
||||
|
||||
function canManageUser(user) {
|
||||
if (!user?.isPrimaryAdmin) return true
|
||||
return !!auth.user?.isPrimaryAdmin
|
||||
}
|
||||
|
||||
function canEditUserAvatar(user) {
|
||||
return canManageUser(user)
|
||||
}
|
||||
|
||||
function canEditUserInfo(user) {
|
||||
return canManageUser(user)
|
||||
}
|
||||
|
||||
function canResetUserPassword(user) {
|
||||
return canManageUser(user)
|
||||
}
|
||||
|
||||
function canDeleteUser(user) {
|
||||
if (!canManageUser(user)) return false
|
||||
if (user?.isAdmin && !auth.user?.isPrimaryAdmin) return false
|
||||
return user?.id !== auth.user?.id
|
||||
}
|
||||
|
||||
const isUserEditDirty = computed(() => {
|
||||
if (!modalTargetUser.value) return false
|
||||
return (
|
||||
@@ -54,6 +77,7 @@ export function useAdminUsers({
|
||||
}
|
||||
|
||||
function openUserAvatarPicker(user) {
|
||||
if (!canEditUserAvatar(user)) return
|
||||
userAvatarInputs.value[user?.id]?.click()
|
||||
}
|
||||
|
||||
@@ -96,11 +120,13 @@ export function useAdminUsers({
|
||||
}
|
||||
|
||||
async function removeUserAvatar(user) {
|
||||
if (!canEditUserAvatar(user)) return
|
||||
if (!user?.avatarSrc) return
|
||||
await uploadUserAvatar(user, null, { remove: true })
|
||||
}
|
||||
|
||||
function openUserEditModal(user) {
|
||||
if (!canEditUserInfo(user)) return
|
||||
resetMessages()
|
||||
modalTargetUser.value = user ? { ...user } : null
|
||||
modalUserDraftEmail.value = user?.email || ''
|
||||
@@ -147,6 +173,7 @@ export function useAdminUsers({
|
||||
}
|
||||
|
||||
function openUserPasswordModal(user) {
|
||||
if (!canResetUserPassword(user)) return
|
||||
resetMessages()
|
||||
modalTargetUser.value = user ? { ...user } : null
|
||||
modalPasswordDraft.value = ''
|
||||
@@ -183,6 +210,7 @@ export function useAdminUsers({
|
||||
}
|
||||
|
||||
function openUserDeleteModal(user) {
|
||||
if (!canDeleteUser(user)) return
|
||||
resetMessages()
|
||||
modalTargetUser.value = user ? { ...user } : null
|
||||
userDeleteModalOpen.value = true
|
||||
@@ -242,6 +270,10 @@ export function useAdminUsers({
|
||||
return {
|
||||
setUserAvatarInput,
|
||||
canManageModalRole,
|
||||
canEditUserAvatar,
|
||||
canEditUserInfo,
|
||||
canResetUserPassword,
|
||||
canDeleteUser,
|
||||
isUserEditDirty,
|
||||
roleLabelOf,
|
||||
openUserAvatarPicker,
|
||||
|
||||
@@ -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'),
|
||||
@@ -146,6 +155,13 @@ export const api = {
|
||||
listMyTierLists: () => request('/api/tierlists/me'),
|
||||
listMyFavoriteTierLists: ({ q = '', sort = 'favorited' } = {}) =>
|
||||
request(`/api/tierlists/favorites/me?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}`),
|
||||
getUserProfile: (userId) => request(`/api/users/${encodeURIComponent(userId)}`),
|
||||
listUserPublicTierLists: (userId, { q = '' } = {}) =>
|
||||
request(`/api/users/${encodeURIComponent(userId)}/tierlists?q=${encodeURIComponent(q || '')}`),
|
||||
listFollowingFeed: ({ q = '' } = {}) =>
|
||||
request(`/api/users/following-feed?q=${encodeURIComponent(q || '')}`),
|
||||
followUser: (userId) => request(`/api/users/${encodeURIComponent(userId)}/follow`, { method: 'POST', body: {} }),
|
||||
unfollowUser: (userId) => request(`/api/users/${encodeURIComponent(userId)}/follow`, { method: 'DELETE' }),
|
||||
getTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`),
|
||||
favoriteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/favorite`, { method: 'POST' }),
|
||||
unfavoriteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/favorite`, { method: 'DELETE' }),
|
||||
|
||||
@@ -33,6 +33,14 @@ export function favoritesPath() {
|
||||
return '/favorites'
|
||||
}
|
||||
|
||||
export function followingFeedPath() {
|
||||
return '/following'
|
||||
}
|
||||
|
||||
export function profilePath() {
|
||||
return '/profile'
|
||||
}
|
||||
|
||||
export function userProfilePath(userId) {
|
||||
return `/users/${encodeSegment(userId)}`
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import TierEditorView from '../views/TierEditorView.vue'
|
||||
import LoginView from '../views/LoginView.vue'
|
||||
import MyTierListsView from '../views/MyTierListsView.vue'
|
||||
import FavoriteTierListsView from '../views/FavoriteTierListsView.vue'
|
||||
import FollowingFeedView from '../views/FollowingFeedView.vue'
|
||||
import UserProfileView from '../views/UserProfileView.vue'
|
||||
import AdminView from '../views/AdminView.vue'
|
||||
import ProfileView from '../views/ProfileView.vue'
|
||||
import SearchResultsView from '../views/SearchResultsView.vue'
|
||||
@@ -22,6 +24,7 @@ export function createRouter() {
|
||||
{ path: '/login', name: 'login', component: LoginView },
|
||||
{ path: '/me', name: 'me', component: MyTierListsView },
|
||||
{ path: '/favorites', name: 'favorites', component: FavoriteTierListsView },
|
||||
{ path: '/following', name: 'followingFeed', component: FollowingFeedView },
|
||||
{ path: '/search', name: 'search', component: SearchResultsView },
|
||||
{ path: '/admin', redirect: '/admin/featured' },
|
||||
{ path: '/admin/featured', name: 'adminFeatured', component: AdminView },
|
||||
@@ -30,6 +33,7 @@ export function createRouter() {
|
||||
{ path: '/admin/tierlists', name: 'adminTierlists', component: AdminView },
|
||||
{ path: '/admin/users', name: 'adminUsers', component: AdminView },
|
||||
{ path: '/profile', name: 'profile', component: ProfileView },
|
||||
{ path: '/users/:userId', name: 'userProfile', component: UserProfileView },
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -33,37 +33,59 @@
|
||||
--theme-danger-bg: rgba(239, 68, 68, 0.1);
|
||||
--theme-danger-border: rgba(239, 68, 68, 0.18);
|
||||
--theme-accent-bg: rgba(76, 133, 245, 0.92);
|
||||
--theme-accent-strong: rgba(137, 183, 255, 0.96);
|
||||
--theme-accent-soft: rgba(76, 133, 245, 0.18);
|
||||
--theme-accent-soft-strong: rgba(76, 133, 245, 0.3);
|
||||
--theme-accent-text: #fff;
|
||||
--theme-overlay-scrim: rgba(0, 0, 0, 0.62);
|
||||
--theme-avatar-border: rgba(255, 255, 255, 0.14);
|
||||
--theme-favorite-bg: rgba(12, 14, 18, 0.72);
|
||||
--theme-favorite-border: rgba(255, 255, 255, 0.14);
|
||||
--theme-favorite-icon: rgba(255, 255, 255, 0.94);
|
||||
--theme-favorite-active-bg: rgba(54, 45, 10, 0.92);
|
||||
--theme-favorite-active-border: rgba(255, 216, 107, 0.28);
|
||||
--theme-favorite-active-icon: #ffd86b;
|
||||
--theme-icon-filter: brightness(0) saturate(100%) invert(94%) sepia(6%) saturate(207%) hue-rotate(186deg) brightness(96%) contrast(92%);
|
||||
}
|
||||
|
||||
:root[data-theme='light'] {
|
||||
--theme-body-bg: #e7ebf2;
|
||||
--theme-shell-bg: rgba(237, 241, 247, 0.98);
|
||||
--theme-rail-bg: rgba(243, 246, 251, 0.97);
|
||||
--theme-main-bg: rgba(232, 236, 243, 0.98);
|
||||
--theme-workspace-bg: rgba(247, 249, 252, 0.96);
|
||||
--theme-card-bg: rgba(252, 253, 255, 0.98);
|
||||
--theme-card-bg-hover: rgba(244, 247, 251, 0.98);
|
||||
--theme-card-border: rgba(31, 41, 55, 0.11);
|
||||
--theme-card-shadow: 0 18px 34px rgba(31, 41, 55, 0.07);
|
||||
--theme-surface-soft: rgba(30, 41, 59, 0.055);
|
||||
--theme-surface-soft-2: rgba(30, 41, 59, 0.075);
|
||||
--theme-surface-soft-3: rgba(30, 41, 59, 0.105);
|
||||
--theme-pill-bg: rgba(30, 41, 59, 0.045);
|
||||
--theme-border: rgba(30, 41, 59, 0.11);
|
||||
--theme-border-strong: rgba(30, 41, 59, 0.16);
|
||||
--theme-text: rgba(20, 27, 40, 0.92);
|
||||
--theme-text-strong: rgba(10, 15, 28, 0.98);
|
||||
--theme-text-muted: rgba(55, 65, 81, 0.76);
|
||||
--theme-text-soft: rgba(75, 85, 99, 0.72);
|
||||
--theme-text-faint: rgba(100, 116, 139, 0.88);
|
||||
--theme-thumb-fallback-bg: #f6f8fb;
|
||||
--theme-select-arrow: rgba(55, 65, 81, 0.74);
|
||||
--theme-body-bg: #eef2f8;
|
||||
--theme-shell-bg: rgba(241, 245, 251, 0.98);
|
||||
--theme-rail-bg: rgba(248, 250, 253, 0.98);
|
||||
--theme-main-bg: rgba(234, 239, 247, 0.98);
|
||||
--theme-workspace-bg: rgba(250, 252, 255, 0.97);
|
||||
--theme-card-bg: rgba(255, 255, 255, 0.96);
|
||||
--theme-card-bg-hover: rgba(246, 249, 253, 0.98);
|
||||
--theme-card-border: rgba(71, 85, 105, 0.12);
|
||||
--theme-card-shadow: 0 14px 30px rgba(57, 72, 92, 0.08);
|
||||
--theme-surface-soft: rgba(75, 85, 99, 0.052);
|
||||
--theme-surface-soft-2: rgba(75, 85, 99, 0.078);
|
||||
--theme-surface-soft-3: rgba(75, 85, 99, 0.11);
|
||||
--theme-pill-bg: rgba(75, 85, 99, 0.048);
|
||||
--theme-border: rgba(71, 85, 105, 0.12);
|
||||
--theme-border-strong: rgba(71, 85, 105, 0.18);
|
||||
--theme-text: rgba(24, 33, 48, 0.93);
|
||||
--theme-text-strong: rgba(11, 18, 32, 0.98);
|
||||
--theme-text-muted: rgba(51, 65, 85, 0.78);
|
||||
--theme-text-soft: rgba(71, 85, 105, 0.76);
|
||||
--theme-text-faint: rgba(100, 116, 139, 0.9);
|
||||
--theme-thumb-fallback-bg: #f3f6fb;
|
||||
--theme-select-arrow: rgba(51, 65, 85, 0.72);
|
||||
--theme-danger-bg: rgba(239, 68, 68, 0.1);
|
||||
--theme-danger-border: rgba(239, 68, 68, 0.22);
|
||||
--theme-accent-bg: rgba(64, 110, 226, 0.94);
|
||||
--theme-accent-bg: rgba(56, 105, 226, 0.94);
|
||||
--theme-accent-strong: rgba(47, 87, 194, 0.96);
|
||||
--theme-accent-soft: rgba(56, 105, 226, 0.12);
|
||||
--theme-accent-soft-strong: rgba(56, 105, 226, 0.22);
|
||||
--theme-accent-text: #fff;
|
||||
--theme-overlay-scrim: rgba(17, 24, 39, 0.28);
|
||||
--theme-avatar-border: rgba(71, 85, 105, 0.16);
|
||||
--theme-favorite-bg: rgba(255, 255, 255, 0.9);
|
||||
--theme-favorite-border: rgba(71, 85, 105, 0.16);
|
||||
--theme-favorite-icon: rgba(51, 65, 85, 0.92);
|
||||
--theme-favorite-active-bg: rgba(255, 243, 199, 0.96);
|
||||
--theme-favorite-active-border: rgba(217, 119, 6, 0.22);
|
||||
--theme-favorite-active-icon: #b45309;
|
||||
--theme-icon-filter: brightness(0) saturate(100%) invert(14%) sepia(14%) saturate(652%) hue-rotate(182deg) brightness(95%) contrast(91%);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1039,6 +1041,10 @@ const {
|
||||
const {
|
||||
setUserAvatarInput,
|
||||
canManageModalRole,
|
||||
canEditUserAvatar,
|
||||
canEditUserInfo,
|
||||
canResetUserPassword,
|
||||
canDeleteUser,
|
||||
isUserEditDirty,
|
||||
roleLabelOf,
|
||||
openUserAvatarPicker,
|
||||
@@ -1468,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
|
||||
@@ -1778,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"
|
||||
/>
|
||||
|
||||
@@ -1795,6 +1823,10 @@ function userAvatarFallback(user) {
|
||||
:user-display-name="userDisplayName"
|
||||
:user-avatar-fallback="userAvatarFallback"
|
||||
:remove-user-avatar="removeUserAvatar"
|
||||
:can-edit-user-avatar="canEditUserAvatar"
|
||||
:can-edit-user-info="canEditUserInfo"
|
||||
:can-reset-user-password="canResetUserPassword"
|
||||
:can-delete-user="canDeleteUser"
|
||||
:role-label-of="roleLabelOf"
|
||||
:fmt="fmt"
|
||||
:open-user-password-modal="openUserPasswordModal"
|
||||
|
||||
@@ -223,6 +223,7 @@ onMounted(loadFavorites)
|
||||
height: 22px;
|
||||
border-radius: 9999px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--theme-avatar-border);
|
||||
background: var(--theme-border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
328
frontend/src/views/FollowingFeedView.vue
Normal file
328
frontend/src/views/FollowingFeedView.vue
Normal file
@@ -0,0 +1,328 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { editorPath, loginPath, userProfilePath } from '../lib/paths'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const tierLists = ref([])
|
||||
const query = ref('')
|
||||
const isLoading = ref(false)
|
||||
const error = ref('')
|
||||
const brokenThumbnailIds = ref({})
|
||||
|
||||
watch(error, (message) => {
|
||||
if (!message) return
|
||||
toast.error(message)
|
||||
error.value = ''
|
||||
})
|
||||
|
||||
function fmt(ts) {
|
||||
return new Date(ts).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function displayNameOf(tierList) {
|
||||
return tierList.authorName || '알 수 없음'
|
||||
}
|
||||
|
||||
function avatarSrcOf(tierList) {
|
||||
return tierList.authorAvatarSrc ? toApiUrl(tierList.authorAvatarSrc) : ''
|
||||
}
|
||||
|
||||
function avatarFallbackOf(tierList) {
|
||||
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
|
||||
}
|
||||
|
||||
function tierListThumbnailUrl(tierList) {
|
||||
if (!tierList?.id || brokenThumbnailIds.value[tierList.id]) return ''
|
||||
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
|
||||
}
|
||||
|
||||
function handleThumbnailError(tierListId) {
|
||||
if (!tierListId || brokenThumbnailIds.value[tierListId]) return
|
||||
brokenThumbnailIds.value = { ...brokenThumbnailIds.value, [tierListId]: true }
|
||||
}
|
||||
|
||||
async function loadFollowingFeed() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const data = await api.listFollowingFeed({ q: query.value })
|
||||
brokenThumbnailIds.value = {}
|
||||
tierLists.value = data.tierLists || []
|
||||
} catch (e) {
|
||||
toast.error('로그인이 필요해요.')
|
||||
router.push(loginPath('/following'))
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openTierList(tierList) {
|
||||
router.push(editorPath(tierList.topicId, tierList.id))
|
||||
}
|
||||
|
||||
function openAuthorProfile(tierList) {
|
||||
if (!tierList?.authorId) return
|
||||
router.push(userProfilePath(tierList.authorId))
|
||||
}
|
||||
|
||||
onMounted(loadFollowingFeed)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pageWrap">
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Following</div>
|
||||
<h2 class="pageHead__title">팔로우 피드</h2>
|
||||
<div class="pageHead__desc">팔로우한 작성자가 공개한 티어표를 최신 업데이트순으로 모아봅니다.</div>
|
||||
</div>
|
||||
<div class="pageHead__aside toolbar">
|
||||
<input v-model="query" class="input" placeholder="제목, 주제, 작성자 검색" @keydown.enter.prevent="loadFollowingFeed" />
|
||||
<button class="btn" :disabled="isLoading" @click="loadFollowingFeed">{{ isLoading ? '검색중...' : '검색' }}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div v-if="isLoading" class="empty">팔로우 피드를 불러오고 있어요.</div>
|
||||
<div v-else-if="tierLists.length === 0" class="empty">아직 팔로우한 작성자의 공개 티어표가 없어요.</div>
|
||||
<div v-else class="list">
|
||||
<article v-for="tierList in tierLists" :key="tierList.id" class="boardCard">
|
||||
<button class="boardCard__body" type="button" @click="openTierList(tierList)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img
|
||||
v-if="tierListThumbnailUrl(tierList)"
|
||||
class="boardCard__thumb"
|
||||
:src="tierListThumbnailUrl(tierList)"
|
||||
:alt="tierList.title"
|
||||
draggable="false"
|
||||
@error="handleThumbnailError(tierList.id)"
|
||||
/>
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="boardCard__head">
|
||||
<div class="boardCard__titleRow">
|
||||
<div class="boardCard__title">{{ tierList.title }}</div>
|
||||
<div class="favoriteStat">{{ tierList.isFavorited ? '♥' : '♡' }} {{ tierList.favoriteCount || 0 }}</div>
|
||||
</div>
|
||||
<div class="boardCard__topic">{{ tierList.topicName || tierList.topicId }}</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button class="authorLink" type="button" @click="openAuthorProfile(tierList)">
|
||||
<div class="authorLink__main">
|
||||
<img
|
||||
v-if="avatarSrcOf(tierList)"
|
||||
class="boardCard__avatar"
|
||||
:src="avatarSrcOf(tierList)"
|
||||
:alt="displayNameOf(tierList)"
|
||||
draggable="false"
|
||||
/>
|
||||
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
|
||||
<span class="authorLink__name">{{ displayNameOf(tierList) }}</span>
|
||||
</div>
|
||||
<span class="authorLink__date">{{ fmt(tierList.updatedAt) }}</span>
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.input {
|
||||
min-width: 240px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-surface-soft);
|
||||
color: var(--theme-text);
|
||||
}
|
||||
.btn {
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-surface-soft-2);
|
||||
color: var(--theme-text);
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.empty {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.boardCard {
|
||||
min-width: 0;
|
||||
border-radius: 22px;
|
||||
border: 1px solid var(--theme-card-border);
|
||||
background: var(--theme-card-bg);
|
||||
color: var(--theme-text);
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
background 0.16s ease;
|
||||
}
|
||||
.boardCard:hover {
|
||||
background: var(--theme-card-bg-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.boardCard__body {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
}
|
||||
.boardCard__thumbWrap {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
padding: 14px 14px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.boardCard__thumb,
|
||||
.boardCard__thumbPlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 18px;
|
||||
display: block;
|
||||
}
|
||||
.boardCard__thumb {
|
||||
object-fit: cover;
|
||||
}
|
||||
.boardCard__thumbPlaceholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--theme-thumb-fallback-bg);
|
||||
color: var(--theme-text-faint);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.boardCard__head {
|
||||
min-width: 0;
|
||||
padding: 16px 18px 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.boardCard__titleRow {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.boardCard__title {
|
||||
min-width: 0;
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
line-height: 1.35;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-word;
|
||||
}
|
||||
.boardCard__topic,
|
||||
.favoriteStat {
|
||||
font-size: 13px;
|
||||
color: var(--theme-text-faint);
|
||||
}
|
||||
.favoriteStat {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.authorLink {
|
||||
width: calc(100% - 28px);
|
||||
margin: 14px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-surface-soft);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.authorLink__main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.authorLink__name {
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.authorLink__date {
|
||||
flex: 0 0 auto;
|
||||
font-size: 10px;
|
||||
color: var(--theme-text-faint);
|
||||
}
|
||||
.boardCard__avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 9999px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--theme-avatar-border);
|
||||
background: var(--theme-border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.boardCard__avatar--fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
@media (max-width: 1400px) {
|
||||
.list {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -165,9 +168,9 @@ function templateThumbUrl(template) {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(15, 15, 15, 0.72);
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
border: 1px solid var(--theme-favorite-border);
|
||||
background: var(--theme-favorite-bg);
|
||||
color: var(--theme-favorite-icon);
|
||||
font-size: 17px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
@@ -177,16 +180,16 @@ function templateThumbUrl(template) {
|
||||
justify-content: center;
|
||||
}
|
||||
.libraryCard__favorite--active {
|
||||
background: rgba(54, 45, 10, 0.92);
|
||||
border-color: rgba(255, 216, 107, 0.28);
|
||||
background: var(--theme-favorite-active-bg);
|
||||
border-color: var(--theme-favorite-active-border);
|
||||
}
|
||||
.libraryCard__favoriteIcon {
|
||||
opacity: 0.76;
|
||||
color: rgba(255, 255, 255, 0.94);
|
||||
color: var(--theme-favorite-icon);
|
||||
}
|
||||
.libraryCard__favorite--active .libraryCard__favoriteIcon {
|
||||
opacity: 1;
|
||||
color: #ffd86b;
|
||||
color: var(--theme-favorite-active-icon);
|
||||
}
|
||||
.libraryCard__thumbWrap {
|
||||
width: 100%;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -229,7 +229,7 @@ function openList(t) {
|
||||
height: 22px;
|
||||
border-radius: 9999px;
|
||||
object-fit: cover;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid var(--theme-avatar-border);
|
||||
background: var(--theme-border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -215,6 +222,7 @@ watch(
|
||||
height: 22px;
|
||||
border-radius: 9999px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--theme-avatar-border);
|
||||
background: var(--theme-border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -8,8 +8,9 @@ import addColumnRightIcon from '../assets/icons/add_column_right.svg'
|
||||
import addRowBelowIcon from '../assets/icons/add_row_below.svg'
|
||||
import addPhotoAlternateIcon from '../assets/icons/add_photo_alternate.svg'
|
||||
import shareIcon from '../assets/icons/share.svg'
|
||||
import RightRailAd from '../components/RightRailAd.vue'
|
||||
import { api } from '../lib/api'
|
||||
import { editorNewPath, editorPath, loginPath, mePath, topicPath } from '../lib/paths'
|
||||
import { editorNewPath, editorPath, loginPath, mePath, topicPath, userProfilePath } from '../lib/paths'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useToast } from '../composables/useToast'
|
||||
@@ -55,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('')
|
||||
@@ -72,6 +75,11 @@ const favoriteCount = ref(0)
|
||||
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)
|
||||
@@ -85,7 +93,8 @@ const poolSortable = ref(null)
|
||||
const dropSortables = ref([])
|
||||
|
||||
const isNewTierList = computed(() => tierListId.value === 'new')
|
||||
const canEdit = computed(() => !!auth.user && (!ownerId.value || ownerId.value === auth.user.id))
|
||||
const isOwnTierList = computed(() => !!auth.user && !!ownerId.value && ownerId.value === auth.user.id)
|
||||
const canEdit = computed(() => !!auth.user && !previewMode.value && (!ownerId.value || ownerId.value === auth.user.id))
|
||||
const iconSizeOptions = [48, 64, 80, 96, 112]
|
||||
const hasCustomTitle = computed(() => !!(title.value || '').trim())
|
||||
const fallbackTimestamp = computed(() => (updatedAt.value ? updatedAt.value : Date.now()))
|
||||
@@ -113,15 +122,20 @@ const untitledWarning = computed(
|
||||
'제목 없이 저장된 티어표는 무분별한 도배 방지를 위해 관리자에 의해 임의 삭제될 수 있어요.'
|
||||
)
|
||||
const canFavorite = computed(() => !!auth.user && !isNewTierList.value && !canEdit.value)
|
||||
const canDuplicate = computed(() => !!auth.user && !isNewTierList.value && !canEdit.value)
|
||||
const canDuplicate = computed(() => !!auth.user && hasSavedTierList.value)
|
||||
const canSwitchToViewerMode = computed(() => isOwnTierList.value && hasSavedTierList.value && !previewMode.value)
|
||||
const canSwitchToEditMode = computed(() => isOwnTierList.value && hasSavedTierList.value && previewMode.value)
|
||||
const duplicateActionLabel = computed(() => (isOwnTierList.value ? '복사본 만들기' : '내 티어표로 복사'))
|
||||
const canOpenAuthorProfile = computed(() => !!ownerId.value && hasSavedTierList.value)
|
||||
const copiedFromLabel = computed(() => {
|
||||
if (!sourceTierListId.value) return ''
|
||||
const parts = []
|
||||
if (sourceSnapshotTitle.value) parts.push(`원본 ${sourceSnapshotTitle.value}`)
|
||||
if (sourceSnapshotTitle.value) parts.push(sourceSnapshotTitle.value)
|
||||
if (sourceSnapshotAuthor.value) parts.push(sourceSnapshotAuthor.value)
|
||||
return parts.join(' · ') || '복사해 온 티어표'
|
||||
})
|
||||
const customItems = computed(() => getOrderedItems().filter((item) => item?.origin === 'custom'))
|
||||
const normalizedPoolSearchQuery = computed(() => poolSearchQuery.value.trim().toLowerCase())
|
||||
const hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new')))
|
||||
const canRequestTemplateCreate = computed(
|
||||
() => canEdit.value && hasSavedTierList.value && templateId.value === 'freeform' && customItems.value.length > 0
|
||||
@@ -138,7 +152,6 @@ const shareTierListUrl = computed(() => {
|
||||
if (typeof window === 'undefined') return editorPath(templateId.value, savedTierListId, { preview: true })
|
||||
return new URL(editorPath(templateId.value, savedTierListId, { preview: true }), window.location.origin).toString()
|
||||
})
|
||||
|
||||
watch(error, (message) => {
|
||||
if (!message) return
|
||||
toast.error(message)
|
||||
@@ -166,8 +179,6 @@ function formatExportDate(ts) {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -194,6 +205,30 @@ function getOrderedItems() {
|
||||
return getOrderedItemIds().map((itemId) => itemsById.value[itemId]).filter(Boolean)
|
||||
}
|
||||
|
||||
function mergeLatestTemplateItemsIntoPool(savedItemsMap, savedPoolIds, currentTemplateItems, groupedIds, editable) {
|
||||
const nextMap = { ...(savedItemsMap || {}) }
|
||||
const nextPoolIds = Array.isArray(savedPoolIds) ? [...savedPoolIds] : []
|
||||
if (!editable) return { nextMap, nextPoolIds }
|
||||
|
||||
;(currentTemplateItems || []).forEach((item) => {
|
||||
if (!item?.id || nextMap[item.id]) return
|
||||
nextMap[item.id] = item
|
||||
if (!groupedIds?.has(item.id)) nextPoolIds.push(item.id)
|
||||
})
|
||||
|
||||
return { nextMap, nextPoolIds }
|
||||
}
|
||||
|
||||
function isPoolItemVisible(itemId) {
|
||||
const query = normalizedPoolSearchQuery.value
|
||||
if (!query) return true
|
||||
const item = itemsById.value[itemId]
|
||||
const label = String(item?.label || itemId || '').toLowerCase()
|
||||
return label.includes(query)
|
||||
}
|
||||
|
||||
const visiblePoolCount = computed(() => pool.value.filter((itemId) => isPoolItemVisible(itemId)).length)
|
||||
|
||||
function setIconSize(nextSize) {
|
||||
iconSize.value = nextSize
|
||||
}
|
||||
@@ -251,6 +286,33 @@ function buildGroupPayload() {
|
||||
}))
|
||||
}
|
||||
|
||||
function createEditorSnapshot() {
|
||||
return JSON.stringify({
|
||||
title: (title.value || '').trim(),
|
||||
description: description.value || '',
|
||||
isPublic: !!isPublic.value,
|
||||
showCharacterNames: !!showCharacterNames.value,
|
||||
iconSize: Number(iconSize.value || 80),
|
||||
columns: columns.value.map((column) => ({ id: column.id, name: column.name || '' })),
|
||||
groups: buildGroupPayload(),
|
||||
pool: pool.value.map((itemId) => {
|
||||
const item = itemsById.value[itemId]
|
||||
return {
|
||||
id: item?.id || itemId,
|
||||
src: item?.src || '',
|
||||
label: item?.label || '',
|
||||
origin: item?.origin || 'template',
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function syncSavedEditorSnapshot() {
|
||||
savedEditorSnapshot.value = createEditorSnapshot()
|
||||
}
|
||||
|
||||
const hasUnsavedChanges = computed(() => canEdit.value && savedEditorSnapshot.value && createEditorSnapshot() !== savedEditorSnapshot.value)
|
||||
|
||||
function removeItemFromGroup(groupId, columnIndex, itemId) {
|
||||
if (!canEdit.value || !groupId || columnIndex == null || !itemId) return
|
||||
const targetGroup = groups.value.find((group) => group.id === groupId)
|
||||
@@ -260,6 +322,87 @@ function removeItemFromGroup(groupId, columnIndex, itemId) {
|
||||
targetGroup.cells = nextCells
|
||||
syncGroupItemIds(targetGroup)
|
||||
pool.value = [itemId, ...pool.value.filter((id) => id !== itemId)]
|
||||
if (selectedItemId.value === itemId) selectedItemId.value = ''
|
||||
}
|
||||
|
||||
function shouldIgnoreItemClick() {
|
||||
return Date.now() - recentDragFinishedAt.value < 180
|
||||
}
|
||||
|
||||
function getItemLocation(itemId) {
|
||||
if (!itemId) return { type: null, groupId: '', columnIndex: -1, index: -1 }
|
||||
|
||||
const poolIndex = pool.value.findIndex((id) => id === itemId)
|
||||
if (poolIndex >= 0) {
|
||||
return { type: 'pool', groupId: '', columnIndex: -1, index: poolIndex }
|
||||
}
|
||||
|
||||
for (const group of groups.value) {
|
||||
for (let columnIndex = 0; columnIndex < columns.value.length; columnIndex += 1) {
|
||||
const index = getGroupCellIds(group, columnIndex).findIndex((id) => id === itemId)
|
||||
if (index >= 0) {
|
||||
return { type: 'group', groupId: group.id, columnIndex, index }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { type: null, groupId: '', columnIndex: -1, index: -1 }
|
||||
}
|
||||
|
||||
function detachItemById(itemId) {
|
||||
if (!itemId) return
|
||||
pool.value = pool.value.filter((id) => id !== itemId)
|
||||
groups.value.forEach((group) => {
|
||||
group.cells = (group.cells || []).map((cell) => (cell || []).filter((id) => id !== itemId))
|
||||
syncGroupItemIds(group)
|
||||
})
|
||||
}
|
||||
|
||||
function selectItemByClick(itemId) {
|
||||
if (!canEdit.value || !itemId || shouldIgnoreItemClick()) return
|
||||
selectedItemId.value = selectedItemId.value === itemId ? '' : itemId
|
||||
}
|
||||
|
||||
function placeSelectedItemInGroup(groupId, columnIndex) {
|
||||
if (!canEdit.value || !selectedItemId.value || !groupId || !Number.isInteger(columnIndex)) return
|
||||
if (shouldIgnoreItemClick()) return
|
||||
|
||||
const targetGroup = groups.value.find((group) => group.id === groupId)
|
||||
if (!targetGroup) return
|
||||
|
||||
const selectedId = selectedItemId.value
|
||||
const currentLocation = getItemLocation(selectedId)
|
||||
const sameTarget =
|
||||
currentLocation.type === 'group' &&
|
||||
currentLocation.groupId === groupId &&
|
||||
currentLocation.columnIndex === columnIndex
|
||||
|
||||
if (sameTarget) {
|
||||
selectedItemId.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
detachItemById(selectedId)
|
||||
const nextCells = [...targetGroup.cells]
|
||||
nextCells[columnIndex] = [...getGroupCellIds(targetGroup, columnIndex), selectedId]
|
||||
targetGroup.cells = nextCells
|
||||
syncGroupItemIds(targetGroup)
|
||||
selectedItemId.value = ''
|
||||
}
|
||||
|
||||
function moveSelectedItemToPool() {
|
||||
if (!canEdit.value || !selectedItemId.value || shouldIgnoreItemClick()) return
|
||||
|
||||
const selectedId = selectedItemId.value
|
||||
const currentLocation = getItemLocation(selectedId)
|
||||
if (currentLocation.type === 'pool') {
|
||||
selectedItemId.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
detachItemById(selectedId)
|
||||
pool.value = [selectedId, ...pool.value]
|
||||
selectedItemId.value = ''
|
||||
}
|
||||
|
||||
function setGroupDropEl(groupId, columnIndex, el) {
|
||||
@@ -333,6 +476,12 @@ async function initSortables() {
|
||||
draggable: '[data-item-id]',
|
||||
ghostClass: 'ghost',
|
||||
chosenClass: 'chosen',
|
||||
onStart: () => {
|
||||
selectedItemId.value = ''
|
||||
},
|
||||
onEnd: () => {
|
||||
recentDragFinishedAt.value = Date.now()
|
||||
},
|
||||
onSort: () => normalizeSort(poolEl.value),
|
||||
onAdd: () => normalizeSort(poolEl.value),
|
||||
})
|
||||
@@ -344,6 +493,12 @@ async function initSortables() {
|
||||
draggable: '[data-item-id]',
|
||||
ghostClass: 'ghost',
|
||||
chosenClass: 'chosen',
|
||||
onStart: () => {
|
||||
selectedItemId.value = ''
|
||||
},
|
||||
onEnd: () => {
|
||||
recentDragFinishedAt.value = Date.now()
|
||||
},
|
||||
onSort: () => normalizeSort(el),
|
||||
onAdd: () => normalizeSort(el),
|
||||
})
|
||||
@@ -705,6 +860,8 @@ async function persistTierList({ showModal = false } = {}) {
|
||||
authorAccountName.value = res.tierList?.authorAccountName || authorAccountName.value
|
||||
favoriteCount.value = Number(res.tierList?.favoriteCount || favoriteCount.value || 0)
|
||||
isFavorited.value = !!res.tierList?.isFavorited
|
||||
await nextTick()
|
||||
syncSavedEditorSnapshot()
|
||||
if (showModal) isSaveModalOpen.value = true
|
||||
return { ...res, savedTierListId }
|
||||
}
|
||||
@@ -747,6 +904,49 @@ async function copyShareUrl() {
|
||||
}
|
||||
}
|
||||
|
||||
function openViewerMode() {
|
||||
if (!canSwitchToViewerMode.value) return
|
||||
router.push(editorPath(templateId.value, persistedTierListId.value || tierListId.value, { preview: true }))
|
||||
}
|
||||
|
||||
function openEditMode() {
|
||||
if (!canSwitchToEditMode.value) return
|
||||
router.push(editorPath(templateId.value, persistedTierListId.value || tierListId.value))
|
||||
}
|
||||
|
||||
function closeNavigationConfirmModal() {
|
||||
isNavigationConfirmModalOpen.value = false
|
||||
pendingNavigationPath.value = ''
|
||||
}
|
||||
|
||||
function requestEditorNavigation(path) {
|
||||
if (!path) return
|
||||
if (hasUnsavedChanges.value) {
|
||||
pendingNavigationPath.value = path
|
||||
isNavigationConfirmModalOpen.value = true
|
||||
return
|
||||
}
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
function confirmNavigationDiscard() {
|
||||
const nextPath = pendingNavigationPath.value
|
||||
closeNavigationConfirmModal()
|
||||
if (!nextPath) return
|
||||
savedEditorSnapshot.value = ''
|
||||
router.push(nextPath)
|
||||
}
|
||||
|
||||
function openSourceTierList() {
|
||||
if (!sourceTierListId.value) return
|
||||
requestEditorNavigation(editorPath(templateId.value, sourceTierListId.value))
|
||||
}
|
||||
|
||||
function openAuthorProfile() {
|
||||
if (!canOpenAuthorProfile.value) return
|
||||
router.push(userProfilePath(ownerId.value))
|
||||
}
|
||||
|
||||
function closeSaveModal() {
|
||||
isSaveModalOpen.value = false
|
||||
}
|
||||
@@ -805,6 +1005,9 @@ async function confirmDeleteTierList() {
|
||||
async function duplicateCurrentTierList() {
|
||||
if (!canDuplicate.value) return
|
||||
try {
|
||||
if (canEdit.value && hasUnsavedChanges.value) {
|
||||
await persistTierList({ showModal: false })
|
||||
}
|
||||
const data = await api.duplicateTierList(tierListId.value)
|
||||
const duplicatedId = data.tierList?.id
|
||||
if (!duplicatedId) throw new Error('duplicate_failed')
|
||||
@@ -886,73 +1089,156 @@ async function requestTemplate(type) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
;(async () => {
|
||||
await auth.refresh()
|
||||
authorName.value = (auth.user?.nickname || '').trim()
|
||||
authorAccountName.value = ((auth.user?.email || '').trim().split('@')[0] || '').trim()
|
||||
function resetEditorStateForRoute() {
|
||||
destroySortables()
|
||||
if (thumbnailPreviewUrl.value) {
|
||||
URL.revokeObjectURL(thumbnailPreviewUrl.value)
|
||||
thumbnailPreviewUrl.value = ''
|
||||
}
|
||||
columns.value = [{ id: 'col-1', name: '' }]
|
||||
groups.value = normalizeLoadedGroups([], columns.value)
|
||||
pool.value = []
|
||||
itemsById.value = {}
|
||||
title.value = ''
|
||||
persistedTierListId.value = ''
|
||||
thumbnailSrc.value = ''
|
||||
pendingThumbnailFile.value = null
|
||||
description.value = ''
|
||||
isPublic.value = true
|
||||
showCharacterNames.value = false
|
||||
isSaveModalOpen.value = false
|
||||
isTemplateRequestModalOpen.value = false
|
||||
isTemplateUpdateModalOpen.value = false
|
||||
isDeleteModalOpen.value = false
|
||||
isGroupDeleteModalOpen.value = false
|
||||
isColumnDeleteModalOpen.value = false
|
||||
isNavigationConfirmModalOpen.value = false
|
||||
pendingRemoveGroupId.value = ''
|
||||
pendingRemoveColumnIndex.value = -1
|
||||
pendingNavigationPath.value = ''
|
||||
ownerId.value = ''
|
||||
authorName.value = ''
|
||||
authorAccountName.value = ''
|
||||
updatedAt.value = 0
|
||||
sourceTierListId.value = ''
|
||||
sourceSnapshotTitle.value = ''
|
||||
sourceSnapshotAuthor.value = ''
|
||||
isDragActive.value = false
|
||||
isThumbnailDragActive.value = false
|
||||
iconSize.value = 80
|
||||
isFavoriteBusy.value = false
|
||||
favoriteCount.value = 0
|
||||
isFavorited.value = false
|
||||
isRequestingTemplate.value = false
|
||||
isDeleting.value = false
|
||||
poolSearchQuery.value = ''
|
||||
selectedItemId.value = ''
|
||||
recentDragFinishedAt.value = 0
|
||||
savedEditorSnapshot.value = ''
|
||||
resetTemplateRequestDrafts()
|
||||
}
|
||||
|
||||
if (isNewTierList.value && !auth.user) {
|
||||
router.replace(loginPath(editorNewPath(templateId.value)))
|
||||
return
|
||||
}
|
||||
async function loadEditorState() {
|
||||
const loadToken = ++editorLoadToken
|
||||
resetEditorStateForRoute()
|
||||
await auth.refresh()
|
||||
if (loadToken !== editorLoadToken) return
|
||||
|
||||
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',
|
||||
}))
|
||||
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
|
||||
columns.value = normalizeLoadedColumns(t.groups)
|
||||
groups.value = normalizeLoadedGroups(t.groups, columns.value)
|
||||
const map = {}
|
||||
;(t.pool || []).forEach((it) => (map[it.id] = it))
|
||||
itemsById.value = map
|
||||
const grouped = new Set()
|
||||
groups.value.forEach((group) => group.itemIds.forEach((id) => grouped.add(id)))
|
||||
pool.value = Object.keys(itemsById.value).filter((id) => !grouped.has(id))
|
||||
} catch (e) {
|
||||
error.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
|
||||
|
||||
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)
|
||||
@@ -962,9 +1248,14 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<section v-if="previewMode" class="previewOnly" :style="{ '--thumb-size': `${iconSize}px` }">
|
||||
<header class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Preview</div>
|
||||
<h1 class="pageHead__title">{{ effectiveTitle }}</h1>
|
||||
<p v-if="description" class="pageHead__desc">{{ description }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="previewOnly__sheet">
|
||||
<div class="previewOnly__title">{{ effectiveTitle }}</div>
|
||||
<div v-if="description" class="previewOnly__description">{{ description }}</div>
|
||||
<div v-if="columns.length > 1" class="previewOnly__columns">
|
||||
<div class="previewOnly__columnsSpacer" aria-hidden="true"></div>
|
||||
<div class="previewOnly__columnsGrid" :style="{ '--column-count': columns.length }">
|
||||
@@ -1000,6 +1291,34 @@ onUnmounted(() => {
|
||||
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport :to="localRightRailTarget">
|
||||
<template v-if="globalRightRailOpen">
|
||||
<RightRailAd />
|
||||
|
||||
<div class="viewerSidebar__section">
|
||||
<div class="viewerSidebar__eyebrow">Viewer Mode</div>
|
||||
<div class="viewerSidebar__title">공유 티어표 보기</div>
|
||||
<p class="viewerSidebar__desc">
|
||||
{{ canSwitchToEditMode ? '지금은 드래그 없는 뷰어 모드입니다. 원하면 바로 수정 모드로 돌아갈 수 있어요.' : '드래그나 편집 없이 완성본만 보는 뷰어 모드입니다.' }}
|
||||
</p>
|
||||
<div class="viewerSidebar__actions">
|
||||
<button v-if="hasSavedTierList" class="btn btn--ghost viewerSidebar__button" type="button" @click="copyShareUrl">
|
||||
공유하기
|
||||
</button>
|
||||
<button v-if="canDuplicate" class="btn btn--save viewerSidebar__button" type="button" @click="duplicateCurrentTierList">
|
||||
{{ duplicateActionLabel }}
|
||||
</button>
|
||||
<button v-if="canSwitchToEditMode" class="btn btn--save viewerSidebar__button" type="button" @click="openEditMode">
|
||||
수정 모드로 전환
|
||||
</button>
|
||||
<button v-if="canOpenAuthorProfile" class="btn btn--ghost viewerSidebar__button" type="button" @click="openAuthorProfile">
|
||||
작성자 프로필 보기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Teleport>
|
||||
</section>
|
||||
|
||||
<template v-else>
|
||||
@@ -1014,6 +1333,19 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isNavigationConfirmModalOpen" class="modalOverlay" @click.self="closeNavigationConfirmModal">
|
||||
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="navigationConfirmTitle">
|
||||
<div id="navigationConfirmTitle" class="modalCard__title">원본 티어표로 이동</div>
|
||||
<div class="modalCard__desc">
|
||||
아직 저장하지 않은 수정 내용이 있어요. 이대로 이동하면 현재 변경 내용은 사라집니다.
|
||||
</div>
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--ghost" type="button" @click="closeNavigationConfirmModal">계속 편집</button>
|
||||
<button class="btn btn--danger" type="button" @click="confirmNavigationDiscard">저장 없이 이동</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isTemplateRequestModalOpen" class="modalOverlay" @click.self="closeTemplateRequestModal">
|
||||
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="templateRequestTitle">
|
||||
<div id="templateRequestTitle" class="modalCard__title">템플릿 등록 요청</div>
|
||||
@@ -1131,8 +1463,8 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="sourceTierListId" class="editorMain__sourceNote">
|
||||
<span>복사본</span>
|
||||
<button class="editorMain__sourceLink" type="button" @click="router.push(editorPath(templateId, sourceTierListId))">{{ copiedFromLabel }}</button>
|
||||
<span>원본</span>
|
||||
<button class="editorMain__sourceLink" type="button" @click="openSourceTierList">{{ copiedFromLabel }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1212,10 +1544,19 @@ onUnmounted(() => {
|
||||
:data-group-id="g.id"
|
||||
:data-column-index="columnIndex"
|
||||
:ref="(el) => setGroupDropEl(g.id, columnIndex, el)"
|
||||
:class="{ 'row__drop--clickTarget': canEdit && !!selectedItemId }"
|
||||
@click="placeSelectedItemInGroup(g.id, columnIndex)"
|
||||
>
|
||||
<div v-if="columns.length > 1" class="row__columnBadge">{{ column.name || '열 ' + (columnIndex + 1) }}</div>
|
||||
<div v-if="!isExporting" class="row__empty" v-show="getGroupCellIds(g, columnIndex).length === 0">여기로 드래그해서 배치</div>
|
||||
<div v-for="id in getGroupCellIds(g, columnIndex)" :key="id" class="cell" :data-item-id="id">
|
||||
<div
|
||||
v-for="id in getGroupCellIds(g, columnIndex)"
|
||||
:key="id"
|
||||
class="cell"
|
||||
:class="{ 'cell--selected': selectedItemId === id }"
|
||||
:data-item-id="id"
|
||||
@click.stop="selectItemByClick(id)"
|
||||
>
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
|
||||
<button
|
||||
@@ -1264,12 +1605,39 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="sidebar">
|
||||
<div class="sidebar__title">아이템</div>
|
||||
<div class="sidebar__hint">
|
||||
{{ canEdit ? '등록된 아이템 리스트입니다. 드래그해서 표에 넣을 수 있습니다.' : '공개 티어표는 보기 전용입니다.' }}
|
||||
<div class="sidebar__titleRow">
|
||||
<div class="sidebar__title">아이템</div>
|
||||
<div class="sidebar__count">{{ visiblePoolCount }} / {{ pool.length }}</div>
|
||||
</div>
|
||||
<div ref="poolEl" class="pool" data-list-type="pool">
|
||||
<div v-for="id in pool" :key="id" class="poolItem" :class="{ 'poolItem--readonly': !canEdit }" :data-item-id="id">
|
||||
<div class="sidebar__hint">
|
||||
{{ canEdit ? '아이템을 드래그하거나, 클릭으로 선택한 뒤 원하는 셀/풀을 클릭해서 옮길 수 있어요.' : '공개 티어표는 보기 전용입니다.' }}
|
||||
</div>
|
||||
<input
|
||||
v-model="poolSearchQuery"
|
||||
class="sidebar__search"
|
||||
type="text"
|
||||
maxlength="60"
|
||||
placeholder="아이템 이름 검색"
|
||||
/>
|
||||
<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),
|
||||
'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>
|
||||
<div v-if="!canEdit" class="poolItem__state">미배치</div>
|
||||
@@ -1309,6 +1677,11 @@ onUnmounted(() => {
|
||||
<div
|
||||
class="editorSidebar__thumbFrame"
|
||||
:class="{ 'editorSidebar__thumbFrame--active': isThumbnailDragActive }"
|
||||
:role="canEdit ? 'button' : undefined"
|
||||
:tabindex="canEdit ? 0 : undefined"
|
||||
@click="canEdit ? openThumbnailFile() : null"
|
||||
@keydown.enter.prevent="canEdit ? openThumbnailFile() : null"
|
||||
@keydown.space.prevent="canEdit ? openThumbnailFile() : null"
|
||||
@dragenter.prevent="onThumbnailDragEnter"
|
||||
@dragover.prevent="onThumbnailDragEnter"
|
||||
@dragleave="onThumbnailDragLeave"
|
||||
@@ -1318,7 +1691,6 @@ onUnmounted(() => {
|
||||
<div v-else class="editorSidebar__thumbEmpty">대표 썸네일</div>
|
||||
<div class="editorSidebar__thumbOverlay">드래그 또는 클릭으로 썸네일 추가</div>
|
||||
</div>
|
||||
<button v-if="canEdit" class="btn btn--ghost editorSidebar__button" @click="openThumbnailFile">파일 업로드</button>
|
||||
<div v-if="pendingThumbnailFile" class="editorSidebar__fileName">{{ pendingThumbnailFile.name }}</div>
|
||||
</div>
|
||||
|
||||
@@ -1366,12 +1738,14 @@ onUnmounted(() => {
|
||||
<button v-if="canEdit" class="btn btn--save editorSidebar__button" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
|
||||
</div>
|
||||
<div class="editorSidebar__utilityLinks">
|
||||
<button v-if="canSwitchToViewerMode" class="editorSidebar__utilityLink" @click="openViewerMode">뷰어 모드로 보기</button>
|
||||
<button v-if="hasSavedTierList" class="editorSidebar__utilityLink editorSidebar__utilityLink--share" @click="copyShareUrl">
|
||||
<SvgIcon :src="shareIcon" :size="16" />
|
||||
<span>공유하기</span>
|
||||
</button>
|
||||
<button v-if="canOpenAuthorProfile" class="editorSidebar__utilityLink" @click="openAuthorProfile">작성자 프로필 보기</button>
|
||||
<button v-if="canEdit && hasSavedTierList" class="editorSidebar__utilityLink editorSidebar__utilityLink--danger" @click="openDeleteModal">삭제하기</button>
|
||||
<button v-if="canDuplicate" class="editorSidebar__utilityLink" @click="duplicateCurrentTierList">복사해서 내 티어표로 가져오기</button>
|
||||
<button v-if="canDuplicate" class="editorSidebar__utilityLink" @click="duplicateCurrentTierList">{{ duplicateActionLabel }}</button>
|
||||
<button
|
||||
v-if="canRequestTemplateCreate"
|
||||
class="editorSidebar__utilityLink"
|
||||
@@ -1440,30 +1814,18 @@ onUnmounted(() => {
|
||||
cursor: pointer;
|
||||
}
|
||||
.previewOnly {
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(96, 165, 250, 0.14), transparent 38%),
|
||||
var(--theme-shell-bg);
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.previewOnly__sheet {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.previewOnly__title {
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.previewOnly__description {
|
||||
margin-top: -8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
opacity: 0.76;
|
||||
padding: 18px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--theme-card-border);
|
||||
background: var(--theme-card-bg);
|
||||
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
|
||||
}
|
||||
.previewOnly__columns {
|
||||
display: grid;
|
||||
@@ -1562,6 +1924,48 @@ onUnmounted(() => {
|
||||
color: var(--theme-text-soft);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.viewerSidebar__section {
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 18px;
|
||||
border-radius: 22px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-pill-bg);
|
||||
}
|
||||
|
||||
.viewerSidebar__eyebrow {
|
||||
color: var(--theme-text-faint);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.viewerSidebar__title {
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--theme-text-strong);
|
||||
}
|
||||
|
||||
.viewerSidebar__desc {
|
||||
margin: 0;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.viewerSidebar__actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.viewerSidebar__button {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
margin-top: 0;
|
||||
}
|
||||
.toggleSwitch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -2081,6 +2485,11 @@ onUnmounted(() => {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.row__drop--clickTarget {
|
||||
cursor: copy;
|
||||
border-color: rgba(96, 165, 250, 0.42);
|
||||
box-shadow: inset 0 0 0 1px rgba(96, 165, 250, 0.12);
|
||||
}
|
||||
.row__empty {
|
||||
opacity: 0.6;
|
||||
font-size: 13px;
|
||||
@@ -2094,6 +2503,11 @@ onUnmounted(() => {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.cell--selected {
|
||||
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.92), 0 0 0 6px rgba(96, 165, 250, 0.18);
|
||||
}
|
||||
.itemNameOverlay {
|
||||
position: absolute;
|
||||
@@ -2443,12 +2857,39 @@ onUnmounted(() => {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.sidebar__titleRow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.sidebar__count {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--theme-text-soft);
|
||||
}
|
||||
.sidebar__search {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding: 11px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-card-bg);
|
||||
color: var(--theme-text);
|
||||
}
|
||||
.sidebar__search::placeholder {
|
||||
color: var(--theme-text-faint);
|
||||
}
|
||||
.pool {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
}
|
||||
.pool--clickTarget {
|
||||
cursor: copy;
|
||||
}
|
||||
.poolItem {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
@@ -2460,11 +2901,17 @@ onUnmounted(() => {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||
background: var(--theme-pill-bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.poolItem--readonly {
|
||||
cursor: default;
|
||||
opacity: 0.58;
|
||||
filter: grayscale(0.25) brightness(0.78);
|
||||
}
|
||||
.poolItem--selected {
|
||||
border-color: rgba(96, 165, 250, 0.58);
|
||||
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.92), 0 0 0 6px rgba(96, 165, 250, 0.18);
|
||||
}
|
||||
.poolItem .thumb {
|
||||
width: 100%;
|
||||
max-width: var(--thumb-size, 80px);
|
||||
@@ -2490,6 +2937,9 @@ onUnmounted(() => {
|
||||
text-transform: uppercase;
|
||||
color: var(--theme-text-soft);
|
||||
}
|
||||
.poolItem--hidden {
|
||||
display: none;
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -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 = '주제 정보를 불러오지 못했어요.'
|
||||
@@ -96,7 +99,8 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pageHead">
|
||||
<section class="pageWrap">
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Collection</div>
|
||||
<h2 class="pageHead__title">{{ topicTitle }}</h2>
|
||||
@@ -106,13 +110,68 @@ watch(
|
||||
<input v-model="query" class="input" placeholder="제목 또는 작성자 검색" @keydown.enter.prevent="submitSearch" />
|
||||
<button class="btn" @click="submitSearch">검색</button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<section class="panel">
|
||||
<div v-if="tierLists.length === 0" class="empty">아직 공개 티어표가 없어요.</div>
|
||||
<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 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)" />
|
||||
@@ -137,6 +196,7 @@ watch(
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -146,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;
|
||||
@@ -204,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;
|
||||
@@ -319,7 +423,7 @@ watch(
|
||||
height: 22px;
|
||||
border-radius: 9999px;
|
||||
object-fit: cover;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid var(--theme-avatar-border);
|
||||
background: var(--theme-border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
@@ -359,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;
|
||||
}
|
||||
|
||||
471
frontend/src/views/UserProfileView.vue
Normal file
471
frontend/src/views/UserProfileView.vue
Normal file
@@ -0,0 +1,471 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { editorPath, followingFeedPath, loginPath } from '../lib/paths'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const userId = computed(() => route.params.userId || '')
|
||||
const profile = ref(null)
|
||||
const tierLists = ref([])
|
||||
const query = ref('')
|
||||
const isLoading = ref(false)
|
||||
const isFollowBusy = ref(false)
|
||||
const error = ref('')
|
||||
const brokenThumbnailIds = ref({})
|
||||
|
||||
const profileAvatarUrl = computed(() => (profile.value?.avatarSrc ? toApiUrl(profile.value.avatarSrc) : ''))
|
||||
const profileDisplayName = computed(() => profile.value?.nickname || profile.value?.accountName || '알 수 없음')
|
||||
const profileFallback = computed(() => (profile.value?.accountName || 'u').trim().charAt(0).toUpperCase() || '?')
|
||||
const canFollow = computed(() => !!auth.user && !!profile.value && !profile.value.isSelf)
|
||||
|
||||
watch(error, (message) => {
|
||||
if (!message) return
|
||||
toast.error(message)
|
||||
error.value = ''
|
||||
})
|
||||
|
||||
function fmt(ts) {
|
||||
return new Date(ts).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function displayNameOf(tierList) {
|
||||
return tierList.authorName || profileDisplayName.value
|
||||
}
|
||||
|
||||
function avatarSrcOf(tierList) {
|
||||
return tierList.authorAvatarSrc ? toApiUrl(tierList.authorAvatarSrc) : profileAvatarUrl.value
|
||||
}
|
||||
|
||||
function avatarFallbackOf(tierList) {
|
||||
return (tierList.authorAccountName || profile.value?.accountName || 'u').trim().charAt(0).toUpperCase() || '?'
|
||||
}
|
||||
|
||||
function tierListThumbnailUrl(tierList) {
|
||||
if (!tierList?.id || brokenThumbnailIds.value[tierList.id]) return ''
|
||||
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
|
||||
}
|
||||
|
||||
function handleThumbnailError(tierListId) {
|
||||
if (!tierListId || brokenThumbnailIds.value[tierListId]) return
|
||||
brokenThumbnailIds.value = { ...brokenThumbnailIds.value, [tierListId]: true }
|
||||
}
|
||||
|
||||
async function loadProfile() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
if (!auth.hydrated) await auth.refresh()
|
||||
const [profileRes, tierListsRes] = await Promise.all([
|
||||
api.getUserProfile(userId.value),
|
||||
api.listUserPublicTierLists(userId.value, { q: query.value }),
|
||||
])
|
||||
profile.value = profileRes.user || null
|
||||
tierLists.value = tierListsRes.tierLists || []
|
||||
brokenThumbnailIds.value = {}
|
||||
} catch (e) {
|
||||
error.value = '작성자 프로필을 불러오지 못했어요.'
|
||||
profile.value = null
|
||||
tierLists.value = []
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFollow() {
|
||||
if (!canFollow.value || !profile.value?.id || isFollowBusy.value) return
|
||||
try {
|
||||
isFollowBusy.value = true
|
||||
const data = profile.value.isFollowing
|
||||
? await api.unfollowUser(profile.value.id)
|
||||
: await api.followUser(profile.value.id)
|
||||
profile.value = data.user || profile.value
|
||||
toast.success(profile.value.isFollowing ? '팔로우했어요.' : '팔로우를 해제했어요.')
|
||||
} catch (e) {
|
||||
if (e?.status === 401) {
|
||||
router.push(loginPath(route.fullPath))
|
||||
return
|
||||
}
|
||||
error.value = '팔로우 상태를 변경하지 못했어요.'
|
||||
} finally {
|
||||
isFollowBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openTierList(tierList) {
|
||||
router.push(editorPath(tierList.topicId, tierList.id))
|
||||
}
|
||||
|
||||
watch(userId, loadProfile, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pageWrap">
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Author</div>
|
||||
<h2 class="pageHead__title">{{ profileDisplayName }}</h2>
|
||||
<div class="pageHead__desc">
|
||||
{{ profile?.accountName ? `@${profile.accountName}` : '작성자 프로필' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pageHead__aside profileActions">
|
||||
<button v-if="canFollow" class="btn btn--primary" :disabled="isFollowBusy" type="button" @click="toggleFollow">
|
||||
{{ profile?.isFollowing ? '팔로잉' : '팔로우' }}
|
||||
</button>
|
||||
<button v-if="auth.user" class="btn" type="button" @click="router.push(followingFeedPath())">팔로우 피드</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="profileHero">
|
||||
<div class="profileCard">
|
||||
<img v-if="profileAvatarUrl" class="profileAvatar" :src="profileAvatarUrl" :alt="profileDisplayName" draggable="false" />
|
||||
<div v-else class="profileAvatar profileAvatar--fallback">{{ profileFallback }}</div>
|
||||
<div class="profileMeta">
|
||||
<div class="profileMeta__name">{{ profileDisplayName }}</div>
|
||||
<div class="profileMeta__handle">{{ profile?.accountName ? `@${profile.accountName}` : '작성자 프로필' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profileStats">
|
||||
<article class="profileStat">
|
||||
<span class="profileStat__label">공개 티어표</span>
|
||||
<strong class="profileStat__value">{{ profile?.publicTierListCount || 0 }}</strong>
|
||||
</article>
|
||||
<article class="profileStat">
|
||||
<span class="profileStat__label">팔로워</span>
|
||||
<strong class="profileStat__value">{{ profile?.followerCount || 0 }}</strong>
|
||||
</article>
|
||||
<article class="profileStat">
|
||||
<span class="profileStat__label">팔로잉</span>
|
||||
<strong class="profileStat__value">{{ profile?.followingCount || 0 }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="listToolbar">
|
||||
<input v-model="query" class="input" placeholder="이 작성자의 공개 티어표 검색" @keydown.enter.prevent="loadProfile" />
|
||||
<button class="btn" :disabled="isLoading" type="button" @click="loadProfile">{{ isLoading ? '검색중...' : '검색' }}</button>
|
||||
</section>
|
||||
|
||||
<div v-if="isLoading" class="empty">작성자 티어표를 불러오고 있어요.</div>
|
||||
<div v-else-if="!tierLists.length" class="empty">아직 공개 티어표가 없어요.</div>
|
||||
<div v-else class="list">
|
||||
<article v-for="tierList in tierLists" :key="tierList.id" class="boardCard">
|
||||
<button class="boardCard__body" type="button" @click="openTierList(tierList)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img
|
||||
v-if="tierListThumbnailUrl(tierList)"
|
||||
class="boardCard__thumb"
|
||||
:src="tierListThumbnailUrl(tierList)"
|
||||
:alt="tierList.title"
|
||||
draggable="false"
|
||||
@error="handleThumbnailError(tierList.id)"
|
||||
/>
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="boardCard__head">
|
||||
<div class="boardCard__titleRow">
|
||||
<div class="boardCard__title">{{ tierList.title }}</div>
|
||||
<div class="favoriteStat">{{ tierList.isFavorited ? '♥' : '♡' }} {{ tierList.favoriteCount || 0 }}</div>
|
||||
</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<div class="boardCard__author">
|
||||
<img
|
||||
v-if="avatarSrcOf(tierList)"
|
||||
class="boardCard__avatar"
|
||||
:src="avatarSrcOf(tierList)"
|
||||
:alt="displayNameOf(tierList)"
|
||||
draggable="false"
|
||||
/>
|
||||
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
|
||||
<span class="boardCard__authorName">{{ displayNameOf(tierList) }}</span>
|
||||
</div>
|
||||
<div class="boardCard__date">{{ fmt(tierList.updatedAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profileActions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.btn {
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-surface-soft-2);
|
||||
color: var(--theme-text);
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn--primary {
|
||||
border: 0;
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
color: #fff;
|
||||
}
|
||||
.profileHero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(320px, 420px);
|
||||
gap: 18px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.profileCard,
|
||||
.profileStat {
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--theme-card-border);
|
||||
background: var(--theme-card-bg);
|
||||
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
|
||||
}
|
||||
.profileCard {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
}
|
||||
.profileAvatar {
|
||||
width: 82px;
|
||||
height: 82px;
|
||||
border-radius: 9999px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--theme-avatar-border);
|
||||
background: var(--theme-border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.profileAvatar--fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.profileMeta {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.profileMeta__name {
|
||||
font-size: 24px;
|
||||
font-weight: 900;
|
||||
color: var(--theme-text);
|
||||
word-break: break-word;
|
||||
}
|
||||
.profileMeta__handle {
|
||||
font-size: 14px;
|
||||
color: var(--theme-text-faint);
|
||||
}
|
||||
.profileStats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.profileStat {
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
align-content: center;
|
||||
}
|
||||
.profileStat__label {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: var(--theme-text-faint);
|
||||
}
|
||||
.profileStat__value {
|
||||
font-size: 26px;
|
||||
font-weight: 900;
|
||||
color: var(--theme-text);
|
||||
}
|
||||
.listToolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.input {
|
||||
min-width: 260px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-surface-soft);
|
||||
color: var(--theme-text);
|
||||
}
|
||||
.empty {
|
||||
opacity: 0.76;
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.boardCard {
|
||||
min-width: 0;
|
||||
border-radius: 22px;
|
||||
border: 1px solid var(--theme-card-border);
|
||||
background: var(--theme-card-bg);
|
||||
color: var(--theme-text);
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
background 0.16s ease;
|
||||
}
|
||||
.boardCard:hover {
|
||||
background: var(--theme-card-bg-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.boardCard__body {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
}
|
||||
.boardCard__thumbWrap {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
padding: 14px 14px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.boardCard__thumb,
|
||||
.boardCard__thumbPlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 18px;
|
||||
display: block;
|
||||
}
|
||||
.boardCard__thumb {
|
||||
object-fit: cover;
|
||||
}
|
||||
.boardCard__thumbPlaceholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--theme-thumb-fallback-bg);
|
||||
color: var(--theme-text-faint);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.boardCard__head {
|
||||
min-width: 0;
|
||||
padding: 16px 18px 18px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.boardCard__titleRow,
|
||||
.boardCard__metaRow {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
}
|
||||
.boardCard__titleRow {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.boardCard__metaRow {
|
||||
align-items: flex-end;
|
||||
}
|
||||
.boardCard__title {
|
||||
min-width: 0;
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
line-height: 1.35;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-word;
|
||||
}
|
||||
.boardCard__author {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
opacity: 0.86;
|
||||
overflow: hidden;
|
||||
}
|
||||
.boardCard__authorName {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.boardCard__avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 9999px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--theme-avatar-border);
|
||||
background: var(--theme-border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.boardCard__avatar--fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.boardCard__date,
|
||||
.favoriteStat {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-size: 13px;
|
||||
color: var(--theme-text-faint);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.boardCard__date {
|
||||
font-size: 10px;
|
||||
}
|
||||
@media (max-width: 1400px) {
|
||||
.list {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.profileHero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.profileCard {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.profileStats,
|
||||
.list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user