Compare commits
115 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 907ea75182 | |||
| 4285883e28 | |||
| b16aa046e7 | |||
| 958c75d4fb | |||
| d5d4974751 | |||
| 46fcb0e2cf | |||
| f506e31549 | |||
| 30ec2e55b0 | |||
| dddb57333c | |||
| b758823537 | |||
| 66408aaa1b | |||
| 426e7de177 | |||
| 953837137a | |||
| f1756a4ff1 | |||
| f9767624d1 | |||
| 9847b4dd8f | |||
| 8a43a2dd2c | |||
| 3b9f5f18e0 | |||
| 8922c62f58 | |||
| cd41a6caa1 | |||
| a60c3c9896 | |||
| eb3fb71f24 | |||
| 2a39ee03e5 | |||
| 050ad04bc8 | |||
| fcf4228bff | |||
| 04e9a0420a | |||
| bf726b6161 | |||
| 73a269d61d | |||
| 713b07a1de | |||
| 764e18c16b | |||
| 2107223634 | |||
| 5eb08e1757 | |||
| 15c03835ef | |||
| 672d17849b | |||
| 85863b1b36 | |||
| 60cc5a72c5 | |||
| 8898ac24f9 | |||
| 9f6cb33bbd | |||
| b3575d59a6 | |||
| d0ebc97bc3 | |||
| 139f78bb89 | |||
| 932b4e35a7 | |||
| 257d50f9c5 | |||
| 6a8d4ddabd | |||
| 75a3822502 | |||
| 337bee8900 | |||
| 28fa7bb37d | |||
| d089ba99e9 | |||
| 2923237813 | |||
| fd3e983cdc | |||
| 04ac5c6ede | |||
| 79a187d120 | |||
| 0a87e3b1ec | |||
| 136db137ec | |||
| 1fabf66f04 | |||
| 9b97a7c23b | |||
| 9b0a6d8f15 | |||
| 5af5202455 | |||
| 6b6676ceec | |||
| de640de4a1 | |||
| 20955e277c | |||
| 1ed08d1e34 | |||
| a733c97991 | |||
| 31613e4613 | |||
| d5621362f1 | |||
| caaddb8448 | |||
| 20186f7fe2 | |||
| 77605791fb | |||
| 9bb64b52f3 | |||
| c4d896ce36 | |||
| 1957f30341 | |||
| 8d257e21ff | |||
| 19fdf85dcc | |||
| 2626fe2335 | |||
| 074d028f04 | |||
| 208e9709f8 | |||
| 4ed7f275ba | |||
| 88ce413c31 | |||
| 7f7475fb20 | |||
| 8a44b51cce | |||
| 9d63ed2e76 | |||
| 99eb79f2c3 | |||
| 6b8abea203 | |||
| d692798358 | |||
| 49d4946735 | |||
| bd53cf96dc | |||
| 66c3b1e7b7 | |||
| 494f04d9a7 | |||
| 5aae278fd3 | |||
| 14dfe0ad75 | |||
| a7cfb97131 | |||
| badf250967 | |||
| a16b1e1025 | |||
| c1dfea41a5 | |||
| 188576f8ac | |||
| 5db1e57f13 | |||
| 2918a0423c | |||
| b542b963d2 | |||
| b98a3d5a6d | |||
| d3c5eeae6a | |||
| e3559f4a84 | |||
| aa114a170e | |||
| 4f300e7dbc | |||
| 717e3b97f0 | |||
| 4fc7bcb29a | |||
| 0a3fce2130 | |||
| 1d8e8581b8 | |||
| 036fc84fa6 | |||
| 472b511b89 | |||
| 6f8de5adf3 | |||
| 147ff963ab | |||
| 66d408dca8 | |||
| d5b4de1629 | |||
| 6828b868bc | |||
| 397461b7c0 |
@@ -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
@@ -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 gamesRoutes = require('./src/routes/games')
|
||||
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()
|
||||
@@ -24,7 +28,7 @@ const allowedOrigins = (process.env.CORS_ORIGINS || '')
|
||||
|
||||
const FileStore = FileStoreFactory(session)
|
||||
|
||||
;['uploads/avatars', 'uploads/games', 'uploads/custom', 'uploads/tierlists', '.sessions'].forEach((relativePath) => {
|
||||
;['uploads/avatars', 'uploads/topics', 'uploads/custom', 'uploads/tierlists', '.sessions'].forEach((relativePath) => {
|
||||
fs.mkdirSync(path.join(__dirname, relativePath), { recursive: true })
|
||||
})
|
||||
|
||||
@@ -74,13 +78,15 @@ app.use(async (req, res, next) => {
|
||||
await ensureData()
|
||||
next()
|
||||
} catch (e) {
|
||||
console.error('[backend] db init failed', e)
|
||||
res.status(500).json({ error: 'db_init_failed' })
|
||||
}
|
||||
})
|
||||
|
||||
app.use('/api/auth', authRoutes)
|
||||
app.use('/api/games', gamesRoutes)
|
||||
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
@@ -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,10 +4,11 @@
|
||||
"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",
|
||||
"images:shard-assets": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/migrate-flat-assets-to-sharded.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"
|
||||
},
|
||||
"keywords": [],
|
||||
@@ -17,11 +18,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"
|
||||
|
||||
@@ -7,7 +7,7 @@ const {
|
||||
} = require('../src/db')
|
||||
|
||||
const BACKEND_ROOT = path.join(__dirname, '..')
|
||||
const TARGET_DIRS = ['avatars', 'custom', 'games', 'tierlists']
|
||||
const TARGET_DIRS = ['avatars', 'custom', 'topics', 'tierlists']
|
||||
|
||||
async function main() {
|
||||
await ensureData()
|
||||
|
||||
102
backend/scripts/migrate-flat-assets-to-sharded.js
Normal file
@@ -0,0 +1,102 @@
|
||||
const fs = require('fs/promises')
|
||||
const path = require('path')
|
||||
const {
|
||||
ensureData,
|
||||
closePool,
|
||||
updateImageAssetSrc,
|
||||
replaceUploadSourceReferences,
|
||||
} = require('../src/db')
|
||||
|
||||
const BACKEND_ROOT = path.join(__dirname, '..')
|
||||
const ASSETS_ROOT = path.join(BACKEND_ROOT, 'uploads', 'assets')
|
||||
const FLAT_ASSET_PATTERN = /^\/uploads\/assets\/[^/]+$/
|
||||
|
||||
function getShardedAssetSrc(src) {
|
||||
const filename = path.basename(src || '')
|
||||
const shardDirectory = filename.slice(0, 2)
|
||||
if (!filename || shardDirectory.length < 2) return ''
|
||||
return `/uploads/assets/${shardDirectory}/${filename}`
|
||||
}
|
||||
|
||||
async function moveAssetFile(fromSrc, toSrc) {
|
||||
const fromPath = path.join(BACKEND_ROOT, fromSrc.replace(/^\//, ''))
|
||||
const toPath = path.join(BACKEND_ROOT, toSrc.replace(/^\//, ''))
|
||||
await fs.mkdir(path.dirname(toPath), { recursive: true })
|
||||
|
||||
try {
|
||||
await fs.rename(fromPath, toPath)
|
||||
return 'moved'
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(toPath)
|
||||
return 'already_moved'
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return 'missing'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await ensureData()
|
||||
|
||||
let dirEntries = []
|
||||
try {
|
||||
dirEntries = await fs.readdir(ASSETS_ROOT, { withFileTypes: true })
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error
|
||||
}
|
||||
|
||||
const flatAssets = dirEntries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => ({ src: `/uploads/assets/${entry.name}` }))
|
||||
.filter((asset) => FLAT_ASSET_PATTERN.test(asset.src || ''))
|
||||
const summary = {
|
||||
scanned: flatAssets.length,
|
||||
migrated: 0,
|
||||
alreadyMoved: 0,
|
||||
skipped: 0,
|
||||
missingFiles: 0,
|
||||
failed: 0,
|
||||
updatedRows: 0,
|
||||
}
|
||||
|
||||
for (const asset of flatAssets) {
|
||||
const nextSrc = getShardedAssetSrc(asset.src)
|
||||
if (!nextSrc) {
|
||||
summary.skipped += 1
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const moveStatus = await moveAssetFile(asset.src, nextSrc)
|
||||
if (moveStatus === 'missing') {
|
||||
summary.missingFiles += 1
|
||||
continue
|
||||
}
|
||||
|
||||
await updateImageAssetSrc({ fromSrc: asset.src, toSrc: nextSrc })
|
||||
const replaced = await replaceUploadSourceReferences({ fromSrc: asset.src, toSrc: nextSrc })
|
||||
summary.updatedRows += Number(replaced.updatedRows || 0)
|
||||
|
||||
if (moveStatus === 'already_moved') summary.alreadyMoved += 1
|
||||
else summary.migrated += 1
|
||||
} catch (error) {
|
||||
summary.failed += 1
|
||||
console.error('[migrate-flat-assets-to-sharded] failed:', asset.src, error?.message || error)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2))
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(async () => {
|
||||
await closePool()
|
||||
})
|
||||
@@ -35,7 +35,7 @@ function getOptimizationConfig(roles) {
|
||||
if (roleSet.has('avatar')) {
|
||||
return { directory: 'avatars', width: 512, height: 512, fit: 'cover', quality: 82 }
|
||||
}
|
||||
if (roleSet.has('game-thumbnail') || roleSet.has('tierlist-thumbnail') || roleSet.has('template-thumbnail')) {
|
||||
if (roleSet.has('topic-thumbnail') || roleSet.has('tierlist-thumbnail') || roleSet.has('template-thumbnail')) {
|
||||
return { directory: 'legacy-thumbnails', width: 1280, height: 1280, fit: 'inside', quality: 84 }
|
||||
}
|
||||
return { directory: 'legacy-items', width: 512, height: 512, fit: 'inside', quality: 84 }
|
||||
|
||||
1672
backend/src/db.js
@@ -75,10 +75,13 @@ async function optimizeAndPersist({ file, width, height, fit, quality }) {
|
||||
}
|
||||
}
|
||||
|
||||
const filename = nanoid() + '.webp'
|
||||
const absoluteDir = path.join(UPLOAD_ROOT, OPTIMIZED_DIR)
|
||||
const basename = nanoid()
|
||||
const shardDirectory = basename.slice(0, 2)
|
||||
const filename = basename + '.webp'
|
||||
const relativeDir = path.join(OPTIMIZED_DIR, shardDirectory)
|
||||
const absoluteDir = path.join(UPLOAD_ROOT, relativeDir)
|
||||
const absolutePath = path.join(absoluteDir, filename)
|
||||
const src = '/uploads/' + OPTIMIZED_DIR + '/' + filename
|
||||
const src = '/uploads/' + relativeDir.split(path.sep).join('/') + '/' + filename
|
||||
|
||||
await fs.mkdir(absoluteDir, { recursive: true })
|
||||
await fs.writeFile(absolutePath, data)
|
||||
|
||||
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,
|
||||
}
|
||||
48
backend/src/lib/user-validation.js
Normal file
@@ -0,0 +1,48 @@
|
||||
const RESERVED_NICKNAME_KEYWORDS = [
|
||||
'admin',
|
||||
'administrator',
|
||||
'operator',
|
||||
'owner',
|
||||
'master',
|
||||
'staff',
|
||||
'system',
|
||||
'root',
|
||||
'support',
|
||||
'manager',
|
||||
'mod',
|
||||
'moderator',
|
||||
'official',
|
||||
'service',
|
||||
'team',
|
||||
'zenn',
|
||||
'운영자',
|
||||
'관리자',
|
||||
'오너',
|
||||
'마스터',
|
||||
'스태프',
|
||||
'시스템',
|
||||
'루트',
|
||||
'서포트',
|
||||
'매니저',
|
||||
'모더레이터',
|
||||
'공식',
|
||||
]
|
||||
|
||||
function normalizeNickname(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
function isReservedNickname(value) {
|
||||
const normalized = normalizeNickname(value)
|
||||
if (!normalized) return false
|
||||
return RESERVED_NICKNAME_KEYWORDS.some((keyword) => normalized.includes(normalizeNickname(keyword)))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RESERVED_NICKNAME_KEYWORDS,
|
||||
normalizeNickname,
|
||||
isReservedNickname,
|
||||
}
|
||||
@@ -7,19 +7,27 @@ const { z } = require('zod')
|
||||
const { nanoid } = require('nanoid')
|
||||
const {
|
||||
findUserById,
|
||||
findGameById,
|
||||
findGameItemById,
|
||||
findUserByEmail,
|
||||
findUserByNickname,
|
||||
findTopicById,
|
||||
findTopicBySlug,
|
||||
findTopicItemById,
|
||||
listTopicItems,
|
||||
findImageAssetById,
|
||||
createGame,
|
||||
listGames,
|
||||
updateGameThumbnail,
|
||||
createGameItem,
|
||||
updateGameItemLabel,
|
||||
createTopic,
|
||||
listTopics,
|
||||
updateTopicMeta,
|
||||
updateTopicThumbnail,
|
||||
createTopicItem,
|
||||
updateTopicItemLabel,
|
||||
updateTopicItemDisplayOrder,
|
||||
countTierListsUsingTopicItem,
|
||||
updateCustomItemLabel,
|
||||
updateImageAssetLabel,
|
||||
deleteGameItem,
|
||||
deleteGame,
|
||||
updateGameDisplayOrder,
|
||||
deleteTopicItem,
|
||||
deleteTopic,
|
||||
deleteTierList,
|
||||
updateTopicDisplayOrder,
|
||||
listCustomItems,
|
||||
findCustomItemById,
|
||||
findUnusedCustomItems,
|
||||
@@ -28,10 +36,14 @@ const {
|
||||
listUsers,
|
||||
findPrimaryAdminUser,
|
||||
listAdminTierLists,
|
||||
summarizeAdminTierLists,
|
||||
findTierListById,
|
||||
updateAdminTierListMeta,
|
||||
updateTierListFeaturedStatus,
|
||||
listAdminTemplateRequests,
|
||||
findTemplateRequestById,
|
||||
updateTemplateRequestStatus,
|
||||
updateTemplateRequestTargetTopic,
|
||||
adminUpdateUser,
|
||||
adminUpdateUserPassword,
|
||||
adminDeleteUser,
|
||||
@@ -40,12 +52,16 @@ const {
|
||||
getImageAssetStats,
|
||||
listRecentImageOptimizationJobs,
|
||||
clearImageOptimizationJobs,
|
||||
cleanupMissingUploadReferences,
|
||||
} = require('../db')
|
||||
const { requireAdmin } = require('../middleware/auth')
|
||||
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
function getTemplateIdFromParams(req) {
|
||||
return req.params.templateId || ''
|
||||
}
|
||||
|
||||
function buildUploadFilename(file) {
|
||||
const ext = path.extname(file.originalname || '').toLowerCase()
|
||||
const safeExt = ext && /^[.a-z0-9]+$/.test(ext) ? ext : ''
|
||||
@@ -64,7 +80,18 @@ function buildItemLabelFromFilename(file) {
|
||||
return normalized || 'item'
|
||||
}
|
||||
|
||||
const upload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024, maxCount: 50 })
|
||||
function buildItemLabelFromSrc(src) {
|
||||
const raw = typeof src === 'string' ? src : ''
|
||||
const base = path.basename(raw.split('?')[0] || '', path.extname(raw.split('?')[0] || ''))
|
||||
const normalized = base
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 60)
|
||||
return normalized || 'item'
|
||||
}
|
||||
|
||||
const upload = createMemoryUpload(multer, { fileSize: 20 * 1024 * 1024, maxCount: 100 })
|
||||
const avatarUpload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 })
|
||||
|
||||
function decorateAdminUser(user, primaryAdmin) {
|
||||
@@ -91,113 +118,216 @@ function canManageAdminRole(actingUser, primaryAdmin) {
|
||||
return !!actingUser?.isAdmin && primaryAdmin?.id === actingUser.id
|
||||
}
|
||||
|
||||
router.post('/games', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({ id: z.string().min(1), name: z.string().min(1).max(60) })
|
||||
router.post('/templates', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
slug: z.string().trim().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
||||
name: z.string().min(1).max(60),
|
||||
isPublic: z.boolean().optional().default(false),
|
||||
thumbnailSrc: z.string().max(255).optional().default(''),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
const exists = await findGameById(parsed.data.id)
|
||||
if (exists) return res.status(409).json({ error: 'game_id_taken' })
|
||||
const game = await createGame({ id: parsed.data.id, name: parsed.data.name })
|
||||
res.json({ game })
|
||||
const exists = await findTopicBySlug(parsed.data.slug)
|
||||
if (exists) return res.status(409).json({ error: 'topic_slug_taken' })
|
||||
let template
|
||||
try {
|
||||
template = await createTopic({ slug: parsed.data.slug, name: parsed.data.name, isPublic: parsed.data.isPublic })
|
||||
} catch (error) {
|
||||
if (error?.code === 'TOPIC_SLUG_INVALID') return res.status(400).json({ error: 'topic_slug_invalid' })
|
||||
if (error?.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'topic_slug_taken' })
|
||||
throw error
|
||||
}
|
||||
if (parsed.data.thumbnailSrc) {
|
||||
const copiedThumb = await copyUploadIntoTopicAsset(parsed.data.thumbnailSrc)
|
||||
await updateTopicThumbnail(template.id, copiedThumb)
|
||||
}
|
||||
const savedTemplate = await findTopicById(template.id)
|
||||
res.json({ template: savedTemplate })
|
||||
})
|
||||
|
||||
router.patch('/games/display-order', requireAdmin, async (req, res) => {
|
||||
router.patch('/templates/:templateId', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
gameIds: z.array(z.string().min(1)).max(50),
|
||||
slug: z.string().trim().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(),
|
||||
name: z.string().trim().min(1).max(60).optional(),
|
||||
isPublic: z.boolean().optional(),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const games = await listGames()
|
||||
const validGameIds = new Set(games.map((game) => game.id))
|
||||
const filteredIds = parsed.data.gameIds.filter((gameId) => validGameIds.has(gameId))
|
||||
const updatedGames = await updateGameDisplayOrder(filteredIds)
|
||||
res.json({ games: updatedGames })
|
||||
const templateId = getTemplateIdFromParams(req)
|
||||
const template = await findTopicById(templateId)
|
||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
try {
|
||||
const updated =
|
||||
typeof parsed.data.name === 'string' || typeof parsed.data.slug === 'string' || typeof parsed.data.isPublic === 'boolean'
|
||||
? await updateTopicMeta(template.id, {
|
||||
slug: parsed.data.slug || template.slug,
|
||||
name: parsed.data.name || template.name,
|
||||
isPublic: typeof parsed.data.isPublic === 'boolean' ? parsed.data.isPublic : template.isPublic,
|
||||
})
|
||||
: await findTopicById(template.id)
|
||||
return res.json({ template: updated })
|
||||
} catch (error) {
|
||||
if (error?.code === 'TOPIC_SLUG_INVALID') return res.status(400).json({ error: 'topic_slug_invalid' })
|
||||
if (error?.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'topic_slug_taken' })
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/games/:gameId/thumbnail', requireAdmin, upload.single('thumbnail'), async (req, res) => {
|
||||
router.patch('/templates/display-order', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
topicIds: z.array(z.string().min(1)).max(50),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const templates = await listTopics('', { includePrivate: true })
|
||||
const validTopicIds = new Set(templates.map((template) => template.id))
|
||||
const filteredIds = parsed.data.topicIds.filter((topicId) => validTopicIds.has(topicId))
|
||||
const updatedTemplates = await updateTopicDisplayOrder(filteredIds)
|
||||
res.json({ templates: updatedTemplates })
|
||||
})
|
||||
|
||||
router.patch('/templates/:templateId/items/display-order', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
itemIds: z.array(z.string().min(1)).min(1),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const templateId = getTemplateIdFromParams(req)
|
||||
const template = await findTopicById(templateId)
|
||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const items = await updateTopicItemDisplayOrder(template.id, parsed.data.itemIds)
|
||||
res.json({ items })
|
||||
})
|
||||
|
||||
router.post('/templates/:templateId/thumbnail', requireAdmin, upload.single('thumbnail'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'file_required' })
|
||||
const game = await findGameById(req.params.gameId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
const templateId = getTemplateIdFromParams(req)
|
||||
const template = await findTopicById(templateId)
|
||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const optimized = await writeOptimizedImage({
|
||||
file: req.file,
|
||||
directory: 'games',
|
||||
directory: 'topics',
|
||||
width: 1280,
|
||||
height: 1280,
|
||||
fit: 'inside',
|
||||
quality: 84,
|
||||
})
|
||||
|
||||
const updated = await updateGameThumbnail(req.params.gameId, optimized.src)
|
||||
res.json({ game: updated })
|
||||
const updated = await updateTopicThumbnail(templateId, optimized.src)
|
||||
res.json({ template: updated })
|
||||
})
|
||||
|
||||
router.post('/games/:gameId/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 game = await findGameById(req.params.gameId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
const templateId = getTemplateIdFromParams(req)
|
||||
const template = await findTopicById(templateId)
|
||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const labelsRaw = req.body?.labels
|
||||
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: 'games',
|
||||
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 createGameItem({
|
||||
id: nanoid(),
|
||||
gameId: game.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('/games/:gameId/items/:itemId', requireAdmin, async (req, res) => {
|
||||
const game = await findGameById(req.params.gameId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
await deleteGameItem(req.params.itemId)
|
||||
router.delete('/templates/:templateId/items/:itemId', requireAdmin, async (req, res) => {
|
||||
const template = await findTopicById(getTemplateIdFromParams(req))
|
||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||
await deleteTopicItem(req.params.itemId)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
router.patch('/games/:gameId/items/:itemId', requireAdmin, async (req, res) => {
|
||||
router.get('/templates/:templateId/items/:itemId/usage', requireAdmin, async (req, res) => {
|
||||
const template = await findTopicById(getTemplateIdFromParams(req))
|
||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||
const item = await findTopicItemById(req.params.itemId)
|
||||
if (!item || item.topicId !== template.id) return res.status(404).json({ error: 'not_found' })
|
||||
const usage = await countTierListsUsingTopicItem(req.params.itemId)
|
||||
res.json({ usage })
|
||||
})
|
||||
|
||||
router.patch('/templates/:templateId/items/:itemId', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({ label: z.string().trim().min(1).max(60) })
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const game = await findGameById(req.params.gameId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
const template = await findTopicById(getTemplateIdFromParams(req))
|
||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const updated = await updateGameItemLabel(req.params.itemId, parsed.data.label)
|
||||
if (!updated || updated.gameId !== game.id) return res.status(404).json({ error: 'not_found' })
|
||||
const updated = await updateTopicItemLabel(req.params.itemId, parsed.data.label)
|
||||
if (!updated || updated.topicId !== template.id) return res.status(404).json({ error: 'not_found' })
|
||||
res.json({ item: updated })
|
||||
})
|
||||
|
||||
router.delete('/games/:gameId', requireAdmin, async (req, res) => {
|
||||
const game = await findGameById(req.params.gameId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
await deleteGame(req.params.gameId)
|
||||
router.delete('/templates/:templateId', requireAdmin, async (req, res) => {
|
||||
const templateId = getTemplateIdFromParams(req)
|
||||
const template = await findTopicById(templateId)
|
||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||
await deleteTopic(templateId)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
router.patch('/custom-items/:itemId/label', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
label: z.string().trim().min(1).max(60),
|
||||
sourceType: z.enum(['template', 'user']).optional().default('user'),
|
||||
sourceType: z.enum(['template', 'user', 'asset']).optional().default('user'),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
@@ -210,7 +340,7 @@ router.patch('/custom-items/:itemId/label', requireAdmin, async (req, res) => {
|
||||
}
|
||||
|
||||
if (parsed.data.sourceType === 'template') {
|
||||
const updated = await updateGameItemLabel(itemId, parsed.data.label)
|
||||
const updated = await updateTopicItemLabel(itemId, parsed.data.label)
|
||||
if (!updated) return res.status(404).json({ error: 'not_found' })
|
||||
return res.json({ item: updated })
|
||||
}
|
||||
@@ -225,11 +355,10 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
page: z.coerce.number().int().min(1).optional().default(1),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
|
||||
orphanOnly: z
|
||||
.union([z.literal('true'), z.literal('false'), z.boolean()])
|
||||
filter: z
|
||||
.enum(['library', 'all', 'user', 'template', 'asset', 'thumbnail', 'avatar', 'unused-user', 'unused-admin'])
|
||||
.optional()
|
||||
.default('false')
|
||||
.transform((value) => value === true || value === 'true'),
|
||||
.default('library'),
|
||||
})
|
||||
const parsed = schema.safeParse(req.query)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
@@ -238,7 +367,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
|
||||
queryText: parsed.data.q,
|
||||
page: parsed.data.page,
|
||||
limit: parsed.data.limit,
|
||||
orphanOnly: parsed.data.orphanOnly,
|
||||
filterMode: parsed.data.filter,
|
||||
})
|
||||
res.json(result)
|
||||
})
|
||||
@@ -246,6 +375,9 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
|
||||
router.get('/tierlists', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
topicId: z.string().trim().max(120).optional().default(''),
|
||||
sort: z.enum(['recent', 'created', 'favorites']).optional().default('recent'),
|
||||
minFavorites: z.coerce.number().int().min(0).max(1000000).optional().default(0),
|
||||
page: z.coerce.number().int().min(1).optional().default(1),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
|
||||
})
|
||||
@@ -254,6 +386,9 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
|
||||
|
||||
const result = await listAdminTierLists({
|
||||
queryText: parsed.data.q,
|
||||
topicId: parsed.data.topicId,
|
||||
sort: parsed.data.sort,
|
||||
minFavorites: parsed.data.minFavorites,
|
||||
page: parsed.data.page,
|
||||
limit: parsed.data.limit,
|
||||
currentUserId: req.session?.userId || '',
|
||||
@@ -261,8 +396,44 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
router.get('/tierlists/stats', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
topicId: z.string().trim().max(120).optional().default(''),
|
||||
minFavorites: z.coerce.number().int().min(0).max(1000000).optional().default(0),
|
||||
})
|
||||
const parsed = schema.safeParse(req.query)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const result = await summarizeAdminTierLists({
|
||||
queryText: parsed.data.q,
|
||||
topicId: parsed.data.topicId,
|
||||
minFavorites: parsed.data.minFavorites,
|
||||
})
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
router.patch('/tierlists/:tierListId/featured', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
isFeatured: z.boolean(),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const tierList = await findTierListById(req.params.tierListId, req.session?.userId || '')
|
||||
if (!tierList) return res.status(404).json({ error: 'not_found' })
|
||||
if (parsed.data.isFeatured && !tierList.isPublic) return res.status(400).json({ error: 'public_tierlist_required' })
|
||||
|
||||
const updated = await updateTierListFeaturedStatus({
|
||||
id: tierList.id,
|
||||
isFeatured: parsed.data.isFeatured,
|
||||
adminUserId: req.session.userId,
|
||||
})
|
||||
res.json({ tierList: updated })
|
||||
})
|
||||
|
||||
router.get('/template-requests', requireAdmin, async (req, res) => {
|
||||
const requests = await listAdminTemplateRequests({ status: 'pending' })
|
||||
const requests = await listAdminTemplateRequests({ statuses: ['pending', 'reviewing'] })
|
||||
res.json({ requests })
|
||||
})
|
||||
|
||||
@@ -338,6 +509,11 @@ router.post('/image-assets/stats/reset', requireAdmin, async (req, res) => {
|
||||
res.json({ deletedCount })
|
||||
})
|
||||
|
||||
router.post('/image-assets/missing/cleanup', requireAdmin, async (req, res) => {
|
||||
const result = await cleanupMissingUploadReferences()
|
||||
res.json({ result })
|
||||
})
|
||||
|
||||
async function removeUploadFiles(srcs) {
|
||||
await Promise.all(
|
||||
(srcs || []).map(async (src) => {
|
||||
@@ -366,19 +542,35 @@ async function removeCustomItemFiles(items) {
|
||||
)
|
||||
}
|
||||
|
||||
async function promoteLibraryItemToGameItem({ item, gameId }) {
|
||||
return createGameItem({
|
||||
async function promoteLibraryItemToTemplateItem({ item, templateId }) {
|
||||
return createTopicItem({
|
||||
id: nanoid(),
|
||||
gameId,
|
||||
topicId: templateId,
|
||||
src: item.src || '',
|
||||
label: item.label,
|
||||
})
|
||||
}
|
||||
|
||||
async function copyUploadIntoGameAsset(src) {
|
||||
if (typeof src !== 'string' || !src.startsWith('/uploads/')) return src || ''
|
||||
if (src.startsWith('/uploads/assets/')) return src
|
||||
return src
|
||||
async function copyUploadIntoTopicAsset(src) {
|
||||
if (typeof src !== 'string') return ''
|
||||
const raw = src.trim()
|
||||
if (!raw) return ''
|
||||
|
||||
if (raw.startsWith('/uploads/')) {
|
||||
if (raw.startsWith('/uploads/assets/')) return raw
|
||||
return raw
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(raw)
|
||||
if (url.pathname.startsWith('/uploads/')) {
|
||||
return url.pathname
|
||||
}
|
||||
} catch (error) {
|
||||
return raw
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
function uniqueTierListPoolItems(tierList) {
|
||||
@@ -390,18 +582,18 @@ function uniqueTierListPoolItems(tierList) {
|
||||
})
|
||||
}
|
||||
|
||||
async function promoteTierListItemsToGame({ tierList, gameId, itemIds = [] }) {
|
||||
async function promoteTierListItemsToTemplate({ tierList, templateId, itemIds = [] }) {
|
||||
const allowedIds = new Set((itemIds || []).filter(Boolean))
|
||||
const sourceItems = uniqueTierListPoolItems(tierList).filter((item) => item.origin === 'custom')
|
||||
const itemsToCopy = allowedIds.size ? sourceItems.filter((item) => allowedIds.has(item.id)) : sourceItems
|
||||
const createdItems = []
|
||||
|
||||
for (const item of itemsToCopy) {
|
||||
const copiedSrc = await copyUploadIntoGameAsset(item.src)
|
||||
const copiedSrc = await copyUploadIntoTopicAsset(item.src)
|
||||
createdItems.push(
|
||||
await createGameItem({
|
||||
await createTopicItem({
|
||||
id: nanoid(),
|
||||
gameId,
|
||||
topicId: templateId,
|
||||
src: copiedSrc,
|
||||
label: item.label,
|
||||
})
|
||||
@@ -411,67 +603,110 @@ async function promoteTierListItemsToGame({ tierList, gameId, itemIds = [] }) {
|
||||
return createdItems
|
||||
}
|
||||
|
||||
async function promoteSnapshotItemsToGame({ items, gameId }) {
|
||||
async function promoteSnapshotItemsToTemplate({ items, templateId }) {
|
||||
const existingItems = await listTopicItems(templateId)
|
||||
const existingSrcs = new Set(
|
||||
existingItems
|
||||
.map((item) => (typeof item?.src === 'string' ? item.src.trim() : ''))
|
||||
.filter(Boolean)
|
||||
)
|
||||
const createdItems = []
|
||||
|
||||
for (const item of items || []) {
|
||||
const copiedSrc = await copyUploadIntoGameAsset(item.src)
|
||||
const copiedSrc = await copyUploadIntoTopicAsset(item.src)
|
||||
if (!copiedSrc || existingSrcs.has(copiedSrc)) continue
|
||||
createdItems.push(
|
||||
await createGameItem({
|
||||
await createTopicItem({
|
||||
id: nanoid(),
|
||||
gameId,
|
||||
topicId: templateId,
|
||||
src: copiedSrc,
|
||||
label: item.label,
|
||||
})
|
||||
)
|
||||
existingSrcs.add(copiedSrc)
|
||||
}
|
||||
|
||||
return createdItems
|
||||
}
|
||||
|
||||
async function createGameTemplateFromTierList({ tierList, gameId, gameName }) {
|
||||
await createGame({ id: gameId, name: gameName })
|
||||
function pickTemplateRequestItems(templateRequest, itemIds = [], itemLabels = {}, itemSrcs = []) {
|
||||
const requestedIds = new Set((itemIds || []).filter(Boolean))
|
||||
const requestedSrcs = new Set((itemSrcs || []).filter((src) => typeof src === 'string' && src.trim()).map((src) => src.trim()))
|
||||
const items = Array.isArray(templateRequest?.items) ? templateRequest.items : []
|
||||
const filtered =
|
||||
requestedIds.size || requestedSrcs.size
|
||||
? items.filter((item) => (item?.id && requestedIds.has(item.id)) || (typeof item?.src === 'string' && requestedSrcs.has(item.src.trim())))
|
||||
: items
|
||||
return filtered
|
||||
.filter((item) => typeof item?.src === 'string' && item.src.trim())
|
||||
.map((item) => {
|
||||
const draftLabel =
|
||||
typeof itemLabels?.[item.id] === 'string' && itemLabels[item.id].trim()
|
||||
? itemLabels[item.id].trim().slice(0, 60)
|
||||
: typeof item?.label === 'string' && item.label.trim()
|
||||
? item.label.trim().slice(0, 60)
|
||||
: buildItemLabelFromSrc(item.src)
|
||||
|
||||
return {
|
||||
...item,
|
||||
src: item.src.trim(),
|
||||
label: draftLabel,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function createTemplateFromTierList({ tierList, templateSlug, templateName }) {
|
||||
const template = await createTopic({ slug: templateSlug, name: templateName, isPublic: false })
|
||||
if (tierList.thumbnailSrc) {
|
||||
const copiedThumb = await copyUploadIntoGameAsset(tierList.thumbnailSrc)
|
||||
await updateGameThumbnail(gameId, copiedThumb)
|
||||
const copiedThumb = await copyUploadIntoTopicAsset(tierList.thumbnailSrc)
|
||||
await updateTopicThumbnail(template.id, copiedThumb)
|
||||
}
|
||||
|
||||
const createdItems = []
|
||||
for (const item of uniqueTierListPoolItems(tierList)) {
|
||||
const copiedSrc = await copyUploadIntoGameAsset(item.src)
|
||||
const copiedSrc = await copyUploadIntoTopicAsset(item.src)
|
||||
createdItems.push(
|
||||
await createGameItem({
|
||||
await createTopicItem({
|
||||
id: nanoid(),
|
||||
gameId,
|
||||
topicId: template.id,
|
||||
src: copiedSrc,
|
||||
label: item.label,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return { game: await findGameById(gameId), items: createdItems }
|
||||
return { template: await findTopicById(template.id), items: createdItems }
|
||||
}
|
||||
|
||||
async function createGameTemplateFromRequest({ templateRequest, gameId, gameName }) {
|
||||
await createGame({ id: gameId, name: gameName })
|
||||
async function createTemplateFromRequest({ templateRequest, templateSlug, templateName }) {
|
||||
const template = await createTopic({ slug: templateSlug, name: templateName, isPublic: false })
|
||||
|
||||
if (templateRequest.thumbnailSrc) {
|
||||
const copiedThumb = await copyUploadIntoGameAsset(templateRequest.thumbnailSrc)
|
||||
await updateGameThumbnail(gameId, copiedThumb)
|
||||
const copiedThumb = await copyUploadIntoTopicAsset(templateRequest.thumbnailSrc)
|
||||
await updateTopicThumbnail(template.id, copiedThumb)
|
||||
}
|
||||
|
||||
const items = await promoteSnapshotItemsToGame({
|
||||
const items = await promoteSnapshotItemsToTemplate({
|
||||
items: templateRequest.items || [],
|
||||
gameId,
|
||||
templateId: template.id,
|
||||
})
|
||||
|
||||
return { game: await findGameById(gameId), items }
|
||||
return { template: await findTopicById(template.id), items }
|
||||
}
|
||||
|
||||
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
||||
const result = await listCustomItems({ page: 1, limit: 10000, orphanOnly: false })
|
||||
const result = await listCustomItems({ page: 1, limit: 10000, filterMode: 'all' })
|
||||
const target = result.items.find((item) => item.id === req.params.itemId)
|
||||
if (!target) return res.status(404).json({ error: 'not_found' })
|
||||
if (target.sourceType === 'asset' || String(target.id || '').startsWith('asset:')) {
|
||||
const assetId = String(target.id).slice('asset:'.length)
|
||||
const asset = await findImageAssetById(assetId)
|
||||
if (!asset) return res.status(404).json({ error: 'not_found' })
|
||||
await deleteImageAssets([assetId])
|
||||
await removeUploadFiles([asset.src])
|
||||
return res.json({ ok: true, sourceType: 'asset' })
|
||||
}
|
||||
|
||||
if (target.sourceType === 'template') {
|
||||
if (String(target.id || '').startsWith('asset:')) {
|
||||
const assetId = String(target.id).slice('asset:'.length)
|
||||
@@ -482,12 +717,12 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
||||
return res.json({ ok: true, sourceType: 'template-asset' })
|
||||
}
|
||||
|
||||
await deleteGameItem(target.id)
|
||||
await deleteTopicItem(target.id)
|
||||
return res.json({ ok: true, sourceType: 'template' })
|
||||
}
|
||||
|
||||
if (!target.canDelete) return res.status(409).json({ error: 'item_locked' })
|
||||
if (target.linkedGames.length > 0) return res.status(409).json({ error: 'item_linked' })
|
||||
if (target.linkedTemplates.length > 0) return res.status(409).json({ error: 'item_linked' })
|
||||
if (target.usageCount > 0) return res.status(409).json({ error: 'item_in_use' })
|
||||
|
||||
const items = await findCustomItemsByIds([target.id])
|
||||
@@ -498,21 +733,21 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
||||
|
||||
router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
gameId: z.string().min(1),
|
||||
topicId: z.string().min(1),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const game = await findGameById(parsed.data.gameId)
|
||||
if (!game) return res.status(404).json({ error: 'game_not_found' })
|
||||
const template = await findTopicById(parsed.data.topicId)
|
||||
if (!template) return res.status(404).json({ error: 'topic_not_found' })
|
||||
|
||||
const customItem = await findCustomItemById(req.params.itemId)
|
||||
const gameItem = customItem ? null : await findGameItemById(req.params.itemId)
|
||||
const templateItem = customItem ? null : await findTopicItemById(req.params.itemId)
|
||||
const assetItemId = String(req.params.itemId || '')
|
||||
const imageAsset = !customItem && !gameItem && assetItemId.startsWith('asset:') ? await findImageAssetById(assetItemId.slice(6)) : null
|
||||
const imageAsset = !customItem && !templateItem && assetItemId.startsWith('asset:') ? await findImageAssetById(assetItemId.slice(6)) : null
|
||||
const sourceItem =
|
||||
customItem ||
|
||||
gameItem ||
|
||||
templateItem ||
|
||||
(imageAsset
|
||||
? {
|
||||
src: imageAsset.src || '',
|
||||
@@ -521,56 +756,90 @@ router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
|
||||
: null)
|
||||
if (!sourceItem) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const item = await promoteLibraryItemToGameItem({ item: sourceItem, gameId: game.id })
|
||||
const item = await promoteLibraryItemToTemplateItem({ item: sourceItem, templateId: template.id })
|
||||
res.json({ item })
|
||||
})
|
||||
|
||||
router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
gameId: z.string().min(1),
|
||||
topicId: z.string().min(1),
|
||||
itemIds: z.array(z.string().min(1)).optional().default([]),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const game = await findGameById(parsed.data.gameId)
|
||||
if (!game) return res.status(404).json({ error: 'game_not_found' })
|
||||
const template = await findTopicById(parsed.data.topicId)
|
||||
if (!template) return res.status(404).json({ error: 'topic_not_found' })
|
||||
|
||||
const tierList = await findTierListById(req.params.tierListId)
|
||||
if (!tierList) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const items = await promoteTierListItemsToGame({
|
||||
const items = await promoteTierListItemsToTemplate({
|
||||
tierList,
|
||||
gameId: game.id,
|
||||
templateId: template.id,
|
||||
itemIds: parsed.data.itemIds,
|
||||
})
|
||||
res.json({ items })
|
||||
})
|
||||
|
||||
router.post('/tierlists/:tierListId/create-game-template', requireAdmin, async (req, res) => {
|
||||
router.post('/tierlists/:tierListId/create-template', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
gameId: z.string().trim().min(1).max(120),
|
||||
slug: z.string().trim().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
||||
name: z.string().trim().min(1).max(120),
|
||||
itemIds: z.array(z.string().min(1)).optional().default([]),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const exists = await findGameById(parsed.data.gameId)
|
||||
if (exists) return res.status(409).json({ error: 'game_id_taken' })
|
||||
const exists = await findTopicBySlug(parsed.data.slug)
|
||||
if (exists) return res.status(409).json({ error: 'topic_slug_taken' })
|
||||
|
||||
const tierList = await findTierListById(req.params.tierListId)
|
||||
if (!tierList) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const result = await createGameTemplateFromTierList({
|
||||
tierList: {
|
||||
...tierList,
|
||||
pool: parsed.data.itemIds.length ? (tierList.pool || []).filter((item) => parsed.data.itemIds.includes(item.id)) : tierList.pool,
|
||||
},
|
||||
gameId: parsed.data.gameId,
|
||||
gameName: parsed.data.name,
|
||||
try {
|
||||
const result = await createTemplateFromTierList({
|
||||
tierList: {
|
||||
...tierList,
|
||||
pool: parsed.data.itemIds.length ? (tierList.pool || []).filter((item) => parsed.data.itemIds.includes(item.id)) : tierList.pool,
|
||||
},
|
||||
templateSlug: parsed.data.slug,
|
||||
templateName: parsed.data.name,
|
||||
})
|
||||
return res.json(result)
|
||||
} catch (error) {
|
||||
if (error?.code === 'TOPIC_SLUG_INVALID') return res.status(400).json({ error: 'topic_slug_invalid' })
|
||||
if (error?.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'topic_slug_taken' })
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
router.patch('/tierlists/:tierListId', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
title: z.string().trim().min(1).max(120),
|
||||
description: z.string().max(500).optional().default(''),
|
||||
isPublic: z.boolean(),
|
||||
})
|
||||
res.json(result)
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const tierList = await findTierListById(req.params.tierListId)
|
||||
if (!tierList) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const updated = await updateAdminTierListMeta({
|
||||
id: tierList.id,
|
||||
title: parsed.data.title,
|
||||
description: parsed.data.description || '',
|
||||
isPublic: parsed.data.isPublic,
|
||||
})
|
||||
res.json({ tierList: updated })
|
||||
})
|
||||
|
||||
router.delete('/tierlists/:tierListId', requireAdmin, async (req, res) => {
|
||||
const tierList = await findTierListById(req.params.tierListId)
|
||||
if (!tierList) return res.status(404).json({ error: 'not_found' })
|
||||
await deleteTierList(tierList.id)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
router.post('/template-requests/:requestId/approve', requireAdmin, async (req, res) => {
|
||||
@@ -579,37 +848,156 @@ router.post('/template-requests/:requestId/approve', requireAdmin, async (req, r
|
||||
if (templateRequest.status !== 'pending') return res.status(409).json({ error: 'request_already_handled' })
|
||||
|
||||
if (templateRequest.type === 'update') {
|
||||
const targetGameId = templateRequest.targetGameId || templateRequest.sourceGameId
|
||||
const game = await findGameById(targetGameId)
|
||||
if (!game) return res.status(404).json({ error: 'game_not_found' })
|
||||
const targetTopicId = templateRequest.targetTopicId || templateRequest.sourceTopicId
|
||||
const template = await findTopicById(targetTopicId)
|
||||
if (!template) return res.status(404).json({ error: 'topic_not_found' })
|
||||
|
||||
const items = await promoteSnapshotItemsToGame({
|
||||
const items = await promoteSnapshotItemsToTemplate({
|
||||
items: templateRequest.items || [],
|
||||
gameId: game.id,
|
||||
templateId: template.id,
|
||||
})
|
||||
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' })
|
||||
return res.json({ request, items })
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
gameId: z.string().trim().min(1).max(120),
|
||||
slug: z.string().trim().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
||||
name: z.string().trim().min(1).max(120),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const exists = await findGameById(parsed.data.gameId)
|
||||
if (exists) return res.status(409).json({ error: 'game_id_taken' })
|
||||
const exists = await findTopicBySlug(parsed.data.slug)
|
||||
if (exists) return res.status(409).json({ error: 'topic_slug_taken' })
|
||||
|
||||
const result = await createGameTemplateFromRequest({
|
||||
templateRequest,
|
||||
gameId: parsed.data.gameId,
|
||||
gameName: parsed.data.name,
|
||||
})
|
||||
let result
|
||||
try {
|
||||
result = await createTemplateFromRequest({
|
||||
templateRequest,
|
||||
templateSlug: parsed.data.slug,
|
||||
templateName: parsed.data.name,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error?.code === 'TOPIC_SLUG_INVALID') return res.status(400).json({ error: 'topic_slug_invalid' })
|
||||
if (error?.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'topic_slug_taken' })
|
||||
throw error
|
||||
}
|
||||
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' })
|
||||
res.json({ request, ...result })
|
||||
})
|
||||
|
||||
router.post('/template-requests/:requestId/review', requireAdmin, async (req, res) => {
|
||||
let templateRequest = await findTemplateRequestById(req.params.requestId)
|
||||
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
|
||||
if (templateRequest.status === 'completed' || templateRequest.status === 'rejected' || templateRequest.status === 'approved') {
|
||||
return res.status(409).json({ error: 'request_already_handled' })
|
||||
}
|
||||
|
||||
if (templateRequest.type === 'create' && templateRequest.targetTopicId && !templateRequest.targetTopicName) {
|
||||
templateRequest = await updateTemplateRequestTargetTopic({
|
||||
id: templateRequest.id,
|
||||
targetTopicId: '',
|
||||
})
|
||||
}
|
||||
|
||||
if (templateRequest.status === 'reviewing') {
|
||||
return res.json({ request: templateRequest })
|
||||
}
|
||||
|
||||
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'reviewing' })
|
||||
res.json({ request })
|
||||
})
|
||||
|
||||
router.post('/template-requests/:requestId/link-template', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
topicId: z.string().trim().min(1).max(120),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const templateRequest = await findTemplateRequestById(req.params.requestId)
|
||||
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
|
||||
if (templateRequest.type !== 'create') return res.status(409).json({ error: 'create_request_required' })
|
||||
if (!['pending', 'reviewing'].includes(templateRequest.status)) {
|
||||
return res.status(409).json({ error: 'request_already_handled' })
|
||||
}
|
||||
|
||||
const template = await findTopicById(parsed.data.topicId)
|
||||
if (!template) return res.status(404).json({ error: 'topic_not_found' })
|
||||
|
||||
const request = await updateTemplateRequestTargetTopic({
|
||||
id: templateRequest.id,
|
||||
targetTopicId: template.id,
|
||||
})
|
||||
res.json({ request })
|
||||
})
|
||||
|
||||
router.post('/template-requests/:requestId/promote-items', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
topicId: z.string().trim().min(1).max(120),
|
||||
itemIds: z.array(z.string().min(1)).optional().default([]),
|
||||
itemSrcs: z.array(z.string().min(1)).optional().default([]),
|
||||
itemLabels: z.record(z.string(), z.string().min(1).max(60)).optional().default({}),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const templateRequest = await findTemplateRequestById(req.params.requestId)
|
||||
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
|
||||
if (!['pending', 'reviewing'].includes(templateRequest.status)) {
|
||||
return res.status(409).json({ error: 'request_already_handled' })
|
||||
}
|
||||
|
||||
const template = await findTopicById(parsed.data.topicId)
|
||||
if (!template) return res.status(404).json({ error: 'topic_not_found' })
|
||||
|
||||
const promotableItems = pickTemplateRequestItems(templateRequest, parsed.data.itemIds, parsed.data.itemLabels, parsed.data.itemSrcs)
|
||||
if (!promotableItems.length) {
|
||||
return res.status(400).json({ error: 'no_items_selected' })
|
||||
}
|
||||
|
||||
let items = []
|
||||
try {
|
||||
items = await promoteSnapshotItemsToTemplate({
|
||||
items: promotableItems,
|
||||
templateId: template.id,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[admin] template request promote-items failed', {
|
||||
requestId: templateRequest.id,
|
||||
topicId: template.id,
|
||||
itemCount: promotableItems.length,
|
||||
message: error?.message || 'unknown_error',
|
||||
code: error?.code || '',
|
||||
stack: error?.stack || '',
|
||||
})
|
||||
return res.status(500).json({
|
||||
error: 'promote_items_failed',
|
||||
detail: error?.message || 'unknown_error',
|
||||
code: error?.code || '',
|
||||
})
|
||||
}
|
||||
|
||||
const request =
|
||||
templateRequest.status === 'reviewing'
|
||||
? templateRequest
|
||||
: await updateTemplateRequestStatus({ id: templateRequest.id, status: 'reviewing' })
|
||||
|
||||
res.json({ request, items })
|
||||
})
|
||||
|
||||
router.post('/template-requests/:requestId/complete', requireAdmin, async (req, res) => {
|
||||
const templateRequest = await findTemplateRequestById(req.params.requestId)
|
||||
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
|
||||
if (templateRequest.status === 'completed') return res.json({ request: templateRequest })
|
||||
if (templateRequest.status === 'rejected' || templateRequest.status === 'approved') {
|
||||
return res.status(409).json({ error: 'request_already_handled' })
|
||||
}
|
||||
|
||||
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'completed' })
|
||||
res.json({ request })
|
||||
})
|
||||
|
||||
router.post('/template-requests/:requestId/reject', requireAdmin, async (req, res) => {
|
||||
const templateRequest = await findTemplateRequestById(req.params.requestId)
|
||||
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
|
||||
@@ -636,7 +1024,7 @@ router.delete('/custom-items', requireAdmin, async (req, res) => {
|
||||
router.get('/users', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
sort: z.enum(['recent', 'created', 'tierlists']).optional().default('recent'),
|
||||
sort: z.enum(['recent', 'lastLogin', 'created', 'tierlists', 'followers', 'favorites']).optional().default('recent'),
|
||||
direction: z.enum(['asc', 'desc']).optional().default('desc'),
|
||||
})
|
||||
const parsed = schema.safeParse(req.query)
|
||||
@@ -678,6 +1066,15 @@ router.patch('/users/:userId', requireAdmin, async (req, res) => {
|
||||
return res.status(403).json({ error: 'primary_admin_only' })
|
||||
}
|
||||
|
||||
const duplicateEmail = await findUserByEmail(parsed.data.email)
|
||||
if (duplicateEmail && duplicateEmail.id !== targetUser.id) {
|
||||
return res.status(409).json({ error: 'email_taken' })
|
||||
}
|
||||
const duplicateNickname = await findUserByNickname(parsed.data.nickname, targetUser.id)
|
||||
if (duplicateNickname) {
|
||||
return res.status(409).json({ error: 'nickname_taken' })
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await adminUpdateUser({
|
||||
id: targetUser.id,
|
||||
|
||||
@@ -1,26 +1,64 @@
|
||||
const express = require('express')
|
||||
const bcrypt = require('bcryptjs')
|
||||
const crypto = require('crypto')
|
||||
const { z } = require('zod')
|
||||
const { nanoid } = require('nanoid')
|
||||
const multer = require('multer')
|
||||
const {
|
||||
countUsers,
|
||||
findUserByEmail,
|
||||
findUserByNickname,
|
||||
findUserById,
|
||||
createUser,
|
||||
touchUserLastLoginAt,
|
||||
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(),
|
||||
nickname: z.string().trim().min(2).max(40),
|
||||
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(),
|
||||
@@ -54,32 +92,131 @@ 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' })
|
||||
|
||||
const { email, password } = parsed.data
|
||||
const { email, nickname, password } = parsed.data
|
||||
const exists = await findUserByEmail(email)
|
||||
if (exists) return res.status(409).json({ error: 'email_taken' })
|
||||
if (isReservedNickname(nickname)) return res.status(400).json({ error: 'nickname_reserved' })
|
||||
const nicknameExists = await findUserByNickname(nickname)
|
||||
if (nicknameExists) return res.status(409).json({ error: 'nickname_taken' })
|
||||
|
||||
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))
|
||||
const touchedUser = await touchUserLastLoginAt(user.id)
|
||||
res.json({ user: await serializeUser(touchedUser || user), verificationRequired: false })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: 'session_save_failed' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
const parsed = signupSchema.safeParse(req.body)
|
||||
const parsed = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6),
|
||||
}).safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const { email, password } = parsed.data
|
||||
@@ -88,10 +225,12 @@ 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))
|
||||
const touchedUser = await touchUserLastLoginAt(user.id)
|
||||
res.json({ user: await serializeUser(touchedUser || user) })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: 'session_save_failed' })
|
||||
}
|
||||
@@ -104,7 +243,7 @@ router.post('/logout', async (req, res) => {
|
||||
|
||||
router.get('/me', async (req, res) => {
|
||||
if (!req.session || !req.session.userId) return res.json({ user: null })
|
||||
const user = await findUserById(req.session.userId)
|
||||
const user = await touchUserLastLoginAt(req.session.userId)
|
||||
if (!user) return res.json({ user: null })
|
||||
res.json({ user: await serializeUser(user) })
|
||||
})
|
||||
@@ -113,6 +252,104 @@ 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)
|
||||
const touchedUser = await touchUserLastLoginAt(user.id)
|
||||
res.json({ user: await serializeUser(touchedUser || 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)
|
||||
const touchedUser = await touchUserLastLoginAt(verifiedUser.id)
|
||||
res.json({ user: await serializeUser(touchedUser || 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) => {
|
||||
@@ -121,6 +358,9 @@ router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) =
|
||||
|
||||
const user = await findUserById(req.session.userId)
|
||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
||||
if (isReservedNickname(parsed.data.nickname)) return res.status(400).json({ error: 'nickname_reserved' })
|
||||
const nicknameExists = await findUserByNickname(parsed.data.nickname, user.id)
|
||||
if (nicknameExists) return res.status(409).json({ error: 'nickname_taken' })
|
||||
|
||||
const optimized = req.file
|
||||
? await writeOptimizedImage({
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
const express = require('express')
|
||||
const { listGames, getGameDetail, findGameById, favoriteGame, unfavoriteGame } = require('../db')
|
||||
const { requireAuth } = require('../middleware/auth')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const games = await listGames(req.session?.userId || '')
|
||||
res.json({ games })
|
||||
})
|
||||
|
||||
router.post('/:gameId/favorite', requireAuth, async (req, res) => {
|
||||
const game = await findGameById(req.params.gameId)
|
||||
if (!game || game.id === 'freeform') return res.status(404).json({ error: 'not_found' })
|
||||
await favoriteGame({ userId: req.session.userId, gameId: game.id })
|
||||
const games = await listGames(req.session.userId)
|
||||
const updated = games.find((entry) => entry.id === game.id) || { ...game, isFavorited: true }
|
||||
res.json({ game: updated })
|
||||
})
|
||||
|
||||
router.delete('/:gameId/favorite', requireAuth, async (req, res) => {
|
||||
const game = await findGameById(req.params.gameId)
|
||||
if (!game || game.id === 'freeform') return res.status(404).json({ error: 'not_found' })
|
||||
await unfavoriteGame({ userId: req.session.userId, gameId: game.id })
|
||||
const games = await listGames(req.session.userId)
|
||||
const updated = games.find((entry) => entry.id === game.id) || { ...game, isFavorited: false }
|
||||
res.json({ game: updated })
|
||||
})
|
||||
|
||||
router.get('/:gameId', async (req, res) => {
|
||||
const detail = await getGameDetail(req.params.gameId)
|
||||
if (!detail) return res.status(404).json({ error: 'not_found' })
|
||||
res.json({ game: detail.game, items: detail.items })
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -12,6 +12,7 @@ const {
|
||||
createCustomItem,
|
||||
createTemplateRequest,
|
||||
findUserById,
|
||||
findTopicByIdentifier,
|
||||
favoriteTierList,
|
||||
unfavoriteTierList,
|
||||
duplicateTierListForUser,
|
||||
@@ -20,11 +21,11 @@ const { requireAuth } = require('../middleware/auth')
|
||||
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
|
||||
|
||||
const router = express.Router()
|
||||
const FREEFORM_GAME_ID = 'freeform'
|
||||
const FREEFORM_TOPIC_ID = 'freeform'
|
||||
const FREEFORM_DEFAULT_TITLE = '직접 티어표 만들기'
|
||||
|
||||
function normalizePoolItem(item) {
|
||||
if (!item || item.origin !== 'game' || typeof item.src !== 'string') return item
|
||||
if (!item || item.origin !== 'template' || typeof item.src !== 'string') return item
|
||||
if (item.src.startsWith('/uploads/')) return item
|
||||
|
||||
try {
|
||||
@@ -61,38 +62,42 @@ const thumbnailUpload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024 }
|
||||
const templateRequestSchema = z.object({
|
||||
type: z.enum(['create', 'update']),
|
||||
sourceTierListId: z.string().max(64).optional().default(''),
|
||||
gameId: z.string().min(1).max(120),
|
||||
topicId: z.string().min(1).max(120).optional(),
|
||||
requestTitle: z.string().trim().min(1).max(120),
|
||||
requestDescription: z.string().trim().min(1).max(1000),
|
||||
thumbnailSrc: z.string().max(255).optional().default(''),
|
||||
isPublic: z.boolean().optional().default(false),
|
||||
showCharacterNames: z.boolean().optional().default(false),
|
||||
saveToMyTierList: z.boolean().optional().default(true),
|
||||
groups: z.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).max(16),
|
||||
itemIds: z.array(z.string()).optional().default([]),
|
||||
}).passthrough()
|
||||
}).passthrough().superRefine((value, ctx) => {
|
||||
if (!value.topicId) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'topicId_required', path: ['topicId'] })
|
||||
}
|
||||
})
|
||||
),
|
||||
boardItems: z.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
src: z.string().min(1),
|
||||
label: z.string().min(1).max(60),
|
||||
origin: z.enum(['game', 'custom']).default('game'),
|
||||
origin: z.enum(['template', 'custom']).default('template'),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
const tierListUpsertSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
gameId: z.string().min(1),
|
||||
topicId: z.string().min(1).optional(),
|
||||
title: z.string().min(1).max(120),
|
||||
thumbnailSrc: z.string().max(255).optional().default(''),
|
||||
description: z.string().max(1000).optional().default(''),
|
||||
isPublic: z.boolean().default(false),
|
||||
showCharacterNames: z.boolean().optional().default(false),
|
||||
iconSize: z.number().int().min(48).max(112).optional().default(80),
|
||||
sourceTierListId: z.string().max(64).optional().default(''),
|
||||
sourceSnapshotTitle: z.string().max(120).optional().default(''),
|
||||
sourceSnapshotAuthor: z.string().max(120).optional().default(''),
|
||||
@@ -108,16 +113,20 @@ const tierListUpsertSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
src: z.string().min(1),
|
||||
label: z.string().min(1).max(60),
|
||||
origin: z.enum(['game', 'custom']).default('game'),
|
||||
origin: z.enum(['template', 'custom']).default('template'),
|
||||
})
|
||||
),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (!value.topicId) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'topicId_required', path: ['topicId'] })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/public', async (req, res) => {
|
||||
const gameId = req.query.gameId
|
||||
const topicId = typeof req.query.topicId === 'string' ? req.query.topicId : ''
|
||||
const queryText = typeof req.query.q === 'string' ? req.query.q : ''
|
||||
const lists = await listPublicTierLists(gameId, 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) => {
|
||||
@@ -226,14 +235,16 @@ router.post('/template-request', requireAuth, async (req, res) => {
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const payload = parsed.data
|
||||
const topic = await findTopicByIdentifier(payload.topicId)
|
||||
if (!topic) return res.status(404).json({ error: 'not_found' })
|
||||
const normalizedBoardItems = payload.boardItems.map(normalizePoolItem)
|
||||
const customItems = normalizedBoardItems.filter((item) => item?.origin === 'custom')
|
||||
if (!customItems.length) return res.status(400).json({ error: 'custom_items_required' })
|
||||
|
||||
if (payload.type === 'create') {
|
||||
if (payload.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
|
||||
} else if (payload.gameId === FREEFORM_GAME_ID) {
|
||||
return res.status(400).json({ error: 'game_template_required' })
|
||||
if (topic.id !== FREEFORM_TOPIC_ID) return res.status(400).json({ error: 'freeform_required' })
|
||||
} else if (topic.id === FREEFORM_TOPIC_ID) {
|
||||
return res.status(400).json({ error: 'topic_template_required' })
|
||||
}
|
||||
|
||||
let sourceTierList = null
|
||||
@@ -243,33 +254,16 @@ router.post('/template-request', requireAuth, async (req, res) => {
|
||||
if (sourceTierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' })
|
||||
}
|
||||
|
||||
let savedTierList = null
|
||||
if (payload.saveToMyTierList) {
|
||||
savedTierList = await saveTierList({
|
||||
id: sourceTierList?.id || undefined,
|
||||
authorId: req.session.userId,
|
||||
gameId: payload.gameId,
|
||||
title: payload.requestTitle,
|
||||
thumbnailSrc: payload.thumbnailSrc || '',
|
||||
description: payload.requestDescription || '',
|
||||
isPublic: !!payload.isPublic,
|
||||
showCharacterNames: !!payload.showCharacterNames,
|
||||
sourceTierListId: sourceTierList?.sourceTierListId || '',
|
||||
sourceSnapshotTitle: sourceTierList?.sourceSnapshotTitle || '',
|
||||
sourceSnapshotAuthor: sourceTierList?.sourceSnapshotAuthor || '',
|
||||
groups: payload.groups,
|
||||
pool: normalizedBoardItems,
|
||||
})
|
||||
}
|
||||
if (!payload.sourceTierListId) return res.status(400).json({ error: 'source_tierlist_required' })
|
||||
|
||||
try {
|
||||
const request = await createTemplateRequest({
|
||||
id: nanoid(),
|
||||
type: payload.type,
|
||||
requesterId: req.session.userId,
|
||||
sourceTierListId: savedTierList?.id || sourceTierList?.id || '',
|
||||
sourceGameId: payload.gameId,
|
||||
targetGameId: payload.type === 'update' ? payload.gameId : '',
|
||||
sourceTierListId: sourceTierList?.id || '',
|
||||
sourceTopicId: topic.id,
|
||||
targetTopicId: payload.type === 'update' ? topic.id : '',
|
||||
title: payload.requestTitle,
|
||||
description: payload.requestDescription,
|
||||
thumbnailSrc: payload.thumbnailSrc || '',
|
||||
@@ -278,7 +272,7 @@ router.post('/template-request', requireAuth, async (req, res) => {
|
||||
boardItems: normalizedBoardItems,
|
||||
showCharacterNames: !!payload.showCharacterNames,
|
||||
})
|
||||
return res.json({ request, savedTierList: savedTierList ? normalizeTierList(savedTierList) : null })
|
||||
return res.json({ request })
|
||||
} catch (e) {
|
||||
if (e?.code === 'TEMPLATE_REQUEST_EXISTS') {
|
||||
return res.status(409).json({ error: 'template_request_exists' })
|
||||
@@ -291,6 +285,8 @@ router.post('/', requireAuth, async (req, res) => {
|
||||
const parsed = tierListUpsertSchema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
const payload = parsed.data
|
||||
const topic = await findTopicByIdentifier(payload.topicId)
|
||||
if (!topic) return res.status(404).json({ error: 'not_found' })
|
||||
const normalizedPool = payload.pool.map(normalizePoolItem)
|
||||
|
||||
let existing = null
|
||||
@@ -301,12 +297,13 @@ router.post('/', requireAuth, async (req, res) => {
|
||||
const updated = await saveTierList({
|
||||
id: existing.id,
|
||||
authorId: existing.authorId,
|
||||
gameId: existing.gameId,
|
||||
topicId: existing.topicId,
|
||||
title: payload.title,
|
||||
thumbnailSrc: payload.thumbnailSrc || '',
|
||||
description: payload.description || '',
|
||||
isPublic: !!payload.isPublic,
|
||||
showCharacterNames: !!payload.showCharacterNames,
|
||||
iconSize: Number(payload.iconSize || 80),
|
||||
sourceTierListId: payload.sourceTierListId || existing.sourceTierListId || '',
|
||||
sourceSnapshotTitle: payload.sourceSnapshotTitle || existing.sourceSnapshotTitle || '',
|
||||
sourceSnapshotAuthor: payload.sourceSnapshotAuthor || existing.sourceSnapshotAuthor || '',
|
||||
@@ -319,12 +316,13 @@ router.post('/', requireAuth, async (req, res) => {
|
||||
const created = await saveTierList({
|
||||
id: nanoid(),
|
||||
authorId: req.session.userId,
|
||||
gameId: payload.gameId,
|
||||
topicId: topic.id,
|
||||
title: payload.title,
|
||||
thumbnailSrc: payload.thumbnailSrc || '',
|
||||
description: payload.description || '',
|
||||
isPublic: !!payload.isPublic,
|
||||
showCharacterNames: !!payload.showCharacterNames,
|
||||
iconSize: Number(payload.iconSize || 80),
|
||||
sourceTierListId: payload.sourceTierListId || '',
|
||||
sourceSnapshotTitle: payload.sourceSnapshotTitle || '',
|
||||
sourceSnapshotAuthor: payload.sourceSnapshotAuthor || '',
|
||||
|
||||
37
backend/src/routes/topics.js
Normal file
@@ -0,0 +1,37 @@
|
||||
const express = require('express')
|
||||
const { listTopics, getTopicDetail, findTopicByIdentifier, favoriteTopic, unfavoriteTopic } = require('../db')
|
||||
const { requireAuth } = require('../middleware/auth')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const topics = await listTopics(req.session?.userId || '', { includePrivate: !!req.session?.isAdmin })
|
||||
res.json({ topics })
|
||||
})
|
||||
|
||||
router.post('/:topicId/favorite', requireAuth, async (req, res) => {
|
||||
const topic = await findTopicByIdentifier(req.params.topicId)
|
||||
if (!topic || topic.id === 'freeform') return res.status(404).json({ error: 'not_found' })
|
||||
await favoriteTopic({ userId: req.session.userId, topicId: topic.id })
|
||||
const topics = await listTopics(req.session.userId)
|
||||
const updated = topics.find((entry) => entry.id === topic.id) || { ...topic, isFavorited: true }
|
||||
res.json({ topic: updated })
|
||||
})
|
||||
|
||||
router.delete('/:topicId/favorite', requireAuth, async (req, res) => {
|
||||
const topic = await findTopicByIdentifier(req.params.topicId)
|
||||
if (!topic || topic.id === 'freeform') return res.status(404).json({ error: 'not_found' })
|
||||
await unfavoriteTopic({ userId: req.session.userId, topicId: topic.id })
|
||||
const topics = await listTopics(req.session.userId)
|
||||
const updated = topics.find((entry) => entry.id === topic.id) || { ...topic, isFavorited: false }
|
||||
res.json({ topic: updated })
|
||||
})
|
||||
|
||||
router.get('/:topicId', async (req, res) => {
|
||||
const detail = await getTopicDetail(req.params.topicId)
|
||||
if (!detail) return res.status(404).json({ error: 'not_found' })
|
||||
if (!detail.topic.isPublic && !req.session?.isAdmin) return res.status(404).json({ error: 'not_found' })
|
||||
res.json({ topic: detail.topic, items: detail.items })
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
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
|
||||
|
||||
416
docs/history.md
@@ -1,5 +1,393 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-04-03 v1.4.67
|
||||
- 이미지 최적화는 해시 기반 중복 재사용을 하기 때문에, 프로필 아바타로 올린 이미지가 우연히 템플릿/사용자 아이템과 같은 `src`를 공유할 수 있다. 이때 자산 카드 쪽을 무조건 숨기면 “실제로는 프로필 이미지로 쓰이는데 관리자 필터에서 안 보이는 상태”가 생기므로, 아바타/썸네일 참조가 있는 `src`는 자산 카드도 유지하는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-03 v1.4.66
|
||||
- 특정 템플릿은 같은 아이템을 조건별로 여러 칸에 동시에 배치해야 하므로, 기존 아이템을 직접 공유해서 재사용하기보다 우클릭으로 새 복제본을 만들어 미사용 풀에 넣는 방식이 더 자연스럽다고 판단했다.
|
||||
- 현재 티어표 저장 구조는 아이템 ID 기준으로 위치를 추적하므로, 복제본이 원본과 같은 ID를 쓰면 중복 배치가 불가능해진다. 따라서 복제 시 `dup-...` 형태의 새 ID를 발급해 원본과 복제본을 별도 인스턴스로 관리하기로 했다.
|
||||
|
||||
## 2026-04-03 v1.4.62
|
||||
- 운영 서버를 새 DB로 다시 시작하는 절차는 “일반 업데이트 재빌드”와 “볼륨까지 삭제하는 완전 초기화”가 같은 문서 안에 섞이면 실수로 데이터를 날릴 위험이 크므로, 배포 문서에서 두 흐름을 별도 섹션으로 나누는 편이 맞다고 판단했다.
|
||||
- DB만 비우고 업로드 볼륨을 남기는 방식도 가능하지만, 현재 서비스는 DB 레코드와 업로드 파일 참조가 강하게 연결되어 있으므로 이 방법 역시 “운영 데이터를 전부 버리는 전제”라는 경고를 같이 적어두는 쪽으로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.61
|
||||
- 운영자가 쓰는 템플릿 주소를 `topics.id` 자체로 두면 나중에 이름/URL을 다듬고 싶을 때 참조 FK와 기존 링크까지 같이 흔들릴 수 있으므로, 내부 참조용 랜덤 `id`와 공개/관리용 `slug`를 분리하는 구조가 더 안전하다고 판단했다.
|
||||
- 운영 DB와 로컬 DB를 모두 새로 시작할 수 있는 상황이라면 예전 `id -> slug` 백필이나 레거시 호환 코드를 남기는 편이 오히려 유지보수 비용만 늘리므로, 이번 변경은 새 스키마 기준으로 깔끔하게 정리하고 기존 데이터 호환 마이그레이션은 두지 않기로 했다.
|
||||
- 빈 DB 초기화 시 예시 템플릿 2개가 자동 생성되면 운영자가 “진짜 운영 데이터인지 샘플인지”를 매번 구분해야 하므로, 시스템 필수 `freeform`만 남기고 빈 예시 템플릿 시드는 제거하는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.60
|
||||
- 신규 업로드만 샤딩 저장하고 기존 평면 `assets` 파일을 그대로 두면 운영자가 파일 구조를 볼 때 두 방식이 오래 섞여 보여 정리성이 떨어지므로, 기존 평면 자산도 같은 규칙으로 옮기는 일회성 마이그레이션 스크립트를 제공하는 편이 맞다고 판단했다.
|
||||
- 기존 파일을 재인코딩해서 새 자산으로 다시 만드는 방식은 해시 중복 처리와 품질/메타 차이가 다시 얽힐 수 있으므로, 이번 샤딩 정리는 실제 파일 rename과 경로 참조 치환만 수행해 이미지 내용 자체는 건드리지 않는 쪽으로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.59
|
||||
- 최근 최적화 이미지가 `assets` 바로 아래 평면 파일로 저장되면서 경로만으로 프로필/썸네일 역할을 구분할 수 없게 되었으므로, 관리자 아이템 분류는 폴더명 규칙 하나에만 기대지 말고 실제 DB 참조 컬럼을 역추적해 판별하는 편이 더 안전하다고 판단했다.
|
||||
- 이미지가 장기적으로 많이 쌓일 수 있는 서비스라면 한 폴더에 모든 파일을 계속 몰아넣기보다 적당한 수준의 하위 폴더 분산이 낫다고 보고, 신규 파일만 ID 앞 2글자로 1단계 샤딩 저장하되 기존 평면 경로는 그대로 유지하는 점진 방식으로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.58
|
||||
- 작성자 프로필 화면 상단에서 닉네임과 `@accountName`을 다시 보여주면 바로 아래 프로필 카드의 동일 정보와 역할이 겹치므로, 상단은 페이지 성격을 설명하는 공통 제목으로 두고 실제 사용자 식별 정보는 프로필 카드 한 곳에만 모으는 편이 낫다고 판단했다.
|
||||
- `@accountName`은 사용자가 직접 만든 핸들이 아니라 이메일 앞부분 기반 표시라서 계정명이 따로 존재하는 것처럼 오해를 만들 수 있으므로, 별도 사용자명 정책을 도입하기 전까지는 공개 프로필 UI에서 숨기는 쪽으로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.57
|
||||
- 아이템 관리 필터는 전체 데이터 탐색과 실제 반복 아이템 검수를 같은 셀렉트에서 오가야 하므로, `전체 이미지`를 맨 위에 두되 기본값은 여전히 `아이템(템플릿 + 사용자)`로 유지해 운영자가 처음부터 프로필/썸네일 자산에 묻히지 않게 하는 편이 맞다고 판단했다.
|
||||
- `미사용 사용자 업로드`라는 표현은 계정 탈퇴 잔여물처럼 오해될 수 있으므로, 실제 의미가 “사용자 아이템 레코드는 남아 있지만 현재 저장 티어표/템플릿 참조가 없는 항목”이라는 점에 맞춰 `미사용 아이템`으로 줄이고, 계정 삭제 시 외래키로 같이 삭제되는 항목은 이 범주로 남지 않는다고 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.56
|
||||
- 아이템 관리의 원래 목적은 “반복 사용 가능한 티어표 아이템”과 “사용자가 올린 커스텀 아이템”을 운영자가 구분해 검수하는 것이었는데, 프로필 아바타나 티어표 썸네일까지 `관리자 템플릿`으로 보이면 의미가 흐려지므로 보관 이미지 자산은 별도 출처와 배지로 분리하는 편이 맞다고 판단했다.
|
||||
- 평소 운영자가 가장 먼저 봐야 하는 대상도 1회성 썸네일이 아니라 실제 아이템이므로, 아이템 관리 기본 필터는 `전체 이미지`가 아니라 `아이템만 (템플릿+사용자)`로 두고 썸네일/프로필 이미지는 필요할 때만 따로 보게 하는 쪽으로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.55
|
||||
- 기존 `최근 활동`은 실제 의미가 “작성한 티어표의 마지막 수정일”에 가까웠는데, 이를 마지막 접속일처럼 읽을 수 있으면 장기 미접속 계정 정리 판단이 틀어질 수 있으므로 `최근 콘텐츠 활동`과 `마지막 접속일`을 아예 분리하는 편이 맞다고 판단했다.
|
||||
- 마지막 접속일은 로그인 성공 순간만 찍으면 장기 세션 사용자를 놓칠 수 있으므로, 세션이 살아 있는 `/api/auth/me` 확인에서도 일정 간격으로 갱신해 실제 접속 흔적에 더 가깝게 유지하는 쪽으로 정리했다.
|
||||
- 관리자 회원 카드에서 팔로워/즐겨찾기 수치를 보더라도 실제 공개 프로필과 작성 글 구성을 바로 확인할 진입점이 없으면 운영 판단이 끊기므로, `회원 정보 수정` 옆에 `프로필 보기` 버튼을 같이 두는 편이 자연스럽다고 판단했다.
|
||||
|
||||
## 2026-04-03 v1.4.54
|
||||
- 추천 티어표를 수동으로만 지정할 수 있어도, 운영자가 후보 자체를 찾지 못하면 실무상 큐레이션이 막히므로, 관리자 전체 티어표 목록에 받은 즐겨찾기 수를 직접 보여주고 즐겨찾기 많은 순/최소 즐겨찾기 필터를 먼저 붙이는 편이 맞다고 판단했다.
|
||||
- 누가 핵심 작성자인지 보는 기준도 작성 티어표 수 하나만으로는 부족하므로, 팔로워 수와 받은 즐겨찾기 수를 회원 관리 카드에 같이 노출하고 이 지표로 정렬할 수 있게 두는 쪽으로 정리했다.
|
||||
- 이메일 인증/재설정 메일이 들어간 뒤에는 운영자가 평소 화면에서 회원 비밀번호를 직접 덮어쓰는 버튼을 계속 보는 것이 과한 권한처럼 느껴질 수 있으므로, 서버 API는 최후 보루로 남기되 관리자 회원 카드의 비밀번호 초기화 UI는 숨기는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-03 v1.4.53
|
||||
- 본인 티어표 복사 기능이 타인 티어표 전용 조건으로만 남아 있었지만, 실제 사용에서는 자기 작업본을 변형용 복사본으로 다시 만들고 싶은 경우도 많으므로 저장된 본인 티어표에도 복사 버튼을 여는 편이 맞다고 판단했다.
|
||||
- 편집 중 저장하지 않은 변경이 있는 상태에서 복사본을 만들 때는 마지막 저장본이 아니라 현재 화면 상태가 복사되기를 기대하기 쉬우므로, 본인 편집본 복사는 복사 직전에 현재 원본을 한 번 저장한 뒤 새 복사본을 만드는 쪽으로 정리했다.
|
||||
- 팔로우 기능은 처음부터 추천 알고리즘까지 섞기보다, 작성자 프로필과 팔로우 피드라는 명확한 사용자 경로를 먼저 열어두는 편이 제품 구조상 자연스럽다고 보고 `user_follows` 기반 1차 구현을 먼저 붙였다.
|
||||
- 작성자 프로필 진입점은 목록 카드 내부 작성자 클릭을 바로 분리하면 기존 카드 전체 클릭 문법과 충돌할 수 있으므로, 이번 단계에서는 티어표 편집/뷰어 우측 패널의 `작성자 프로필 보기`를 우선 진입점으로 두고 카드 내부 세부 클릭 분리는 후속 UX로 미루는 편이 안전하다고 판단했다.
|
||||
|
||||
## 2026-04-03 v1.4.51
|
||||
- 불특정 다수가 만드는 공개 티어표를 전부 같은 선상에 두면 첫 화면 품질 편차가 너무 커질 수 있으므로, 주제별 목록 상단에 관리자 큐레이션 `추천 티어표` 섹션을 두고 아래 일반 공개 목록과 분리하는 편이 맞다고 판단했다.
|
||||
- 추천 선정은 처음부터 완전 자동화보다 운영자가 직접 지정/해제할 수 있는 수동 큐레이션을 먼저 넣는 편이 안전하므로, 좋아요 수 기반 자동 후보 필터와 팔로우 피드는 후속 작업으로 미루고 이번 릴리스에서는 추천 표시 구조와 관리자 토글만 먼저 완성했다.
|
||||
- 비공개 글이 추천 섹션에 올라가면 접근 정책이 꼬이므로, 추천 지정은 공개 글만 허용하고 공개글을 비공개로 바꾸면 추천 상태도 함께 해제하는 쪽으로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.49
|
||||
- 프로필 저장 실패를 하나의 일반 실패 메시지로만 보여주면 사용자가 “서버가 고장났나?”라고 오해하기 쉬우므로, 중복 닉네임/예약어 닉네임처럼 사용자가 직접 고칠 수 있는 입력 오류는 원인별 안내를 분리하는 편이 맞다고 판단했다.
|
||||
- 비밀번호를 잊은 사용자뿐 아니라 로그인 중인 사용자도 보안상 주기적으로 비밀번호를 직접 바꿀 수 있어야 하므로, 설정 화면에 현재 비밀번호 확인 기반 변경 폼을 추가하는 쪽으로 정리했다.
|
||||
- 비밀번호 재설정 링크는 로그인 세션이 남아 있어도 링크 토큰 자체의 목적이 우선이므로, `/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 수정 흐름 전부가 같은 예약어 정책을 공유하도록 정리했다.
|
||||
- 라이트 모드는 취향상 필요한 사용자가 있을 수 있으므로 완전히 제거하기보다, 기본값만 다크로 고정하고 설정 화면에서만 직접 토글하도록 두는 편이 더 균형 잡힌 선택이라고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.32
|
||||
- 서비스 공개 전 마감 단계에서는 사용자 노출 텍스트만이 아니라 파일명·composable 이름·관리자 CSS 클래스·백엔드 헬퍼 함수명까지 같이 정리해 두는 편이 이후 유지보수 비용을 확실히 낮춘다고 판단했다.
|
||||
- 이 시점부터는 `game`이 데이터 호환층도 아닌 단순 내부 이름으로 남아 있는 것조차 혼란을 만들 수 있으므로, 실제 기능을 바꾸지 않는 선에서 이름층까지 끝까지 정리해 코드 검색 결과 자체를 깨끗하게 만드는 방향으로 마감했다.
|
||||
|
||||
## 2026-04-02 v1.4.31
|
||||
- 서비스가 아직 외부 공개 전이고 예전 북마크/예전 데이터베이스를 이어갈 필요가 없다는 전제가 확인되었으므로, 남겨둔 호환층을 유지하는 것보다 지금 마감 시점에 완전히 제거해 구조를 단순화하는 편이 맞다고 판단했다.
|
||||
- 이 단계에서는 “기존 것도 읽어준다”보다 “현재 구조만 남긴다”가 더 중요한 목표가 되었으므로, redirect·legacy migration·`origin: 'game'` 허용까지 함께 정리해 실제 코드 검색에서 `game` 흔적을 0건으로 맞추는 방향으로 마감했다.
|
||||
|
||||
## 2026-04-02 v1.4.30
|
||||
- 로컬 MariaDB는 테스트용으로 새로 밀어도 된다는 전제가 확인되었으므로, 개발 환경에서는 기존 데이터를 끌고 가는 것보다 현재 스키마가 “빈 DB에서 바로 정상 부팅되는지”를 먼저 검증하는 편이 더 가치 있다고 판단했다.
|
||||
- `origin: 'game'` 호환층은 즉시 제거하기보다, `ensureData()`에서 저장 데이터와 요청 스냅샷을 자동 정규화하게 만들어 두고 새 DB에서도 legacy 값이 생기지 않는 상태를 먼저 확인한 뒤 다음 제거 판단으로 넘기는 편이 가장 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.29
|
||||
- `origin: 'game'`는 이미 저장된 티어표 데이터와 직접 맞물리므로, 이 단계에서는 새 데이터 기본값만 `template`로 옮기고 예전 값도 계속 받아주는 점진 호환이 가장 안전하다고 판단했다.
|
||||
- 아이템 라이브러리의 `linkedGames`는 실제 의미가 템플릿 연결 정보이므로, 이 응답 키까지 `linkedTemplates`로 바꿔두는 편이 이후 관리자 유지보수에서 훨씬 덜 헷갈린다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.28
|
||||
- 이 시점 이후 코드 검색에 남는 `game`는 대부분 레거시 데이터 마이그레이션, 옛 주소 redirect, 저장 데이터의 `origin` 호환처럼 의도된 층이므로, 무리하게 전부 0으로 만들기보다 기능을 깨뜨리지 않는 선에서 의미 있는 이름층만 더 줄이는 편이 맞다고 판단했다.
|
||||
- 관리자 화면 내부 상태명(`selectedTemplate.game`, `isGameLoading`, `gameVisibilitySaving`)은 실제 기능 의미와 어긋나므로, QA 전에 한 번 더 `template` 기준으로 옮겨두는 편이 이후 유지보수에 더 유리하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.27
|
||||
- 공개/관리자 API 표면까지 `topic/template`로 정리된 뒤에는, 관리자 내부 상태 이름과 DB export alias에 남은 `game` 흔적도 계속 유지할 이유가 작아졌으므로 이 단계에서 함께 걷어내는 편이 맞다고 판단했다.
|
||||
- 다만 외부에서 직접 참조할 수 있는 공개 북마크와 달리, `adminGames`, `game-admin`, `favoriteGame` 같은 이름은 내부 구현 용어라서 이번 단계에서 정리해도 위험이 낮다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.26
|
||||
- `topic/template` 표면을 거의 마감한 시점에서는 관리자 API와 관리자 화면 경로까지 계속 `/games` alias를 유지하는 편보다, 실제 사용하는 `templates` 경로만 남기고 예전 관리자 주소는 redirect로만 정리하는 편이 더 일관되고 안전하다고 판단했다.
|
||||
- 공개 사용자 북마크는 여전히 `/games -> /topics` redirect가 필요하지만, 백엔드 API의 `/api/games`까지 계속 유지할 이유는 작아졌으므로 이 단계에서 `/api/topics`만 남기는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.25
|
||||
- 이제 프런트와 백엔드 소비층이 `topic/template`를 기본으로 읽을 준비가 되었으므로, 응답과 payload에 `gameId / gameName` 호환 키를 오래 남기는 것보다 실제 표면을 먼저 정리하는 편이 더 낫다고 판단했다.
|
||||
- 다만 오래된 외부 링크까지 한 번에 끊는 건 위험하므로, 이번 단계에서는 데이터/응답/프런트 소비는 `topic`으로 마감하되 `/games/:gameId`와 관리자 route alias 같은 레거시 주소만 마지막 호환 레이어로 남기는 점진 종료가 가장 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.24
|
||||
- `topic/template` 소비층이 이미 정리된 상태라면, 공개 주제 API와 관리자 템플릿 API 응답도 이제는 `game` 키를 기본으로 유지할 이유가 크지 않으므로 새 의미 키만 기본으로 내보내는 편이 맞다고 판단했다.
|
||||
- 다만 관리자 화면 내부 상태 구조를 한 번에 뒤집는 건 위험하므로, 응답은 줄이되 `selectedTemplate.game`처럼 화면 구조에 깊게 퍼진 부분은 프런트에서 한 번 정규화해 받는 점진 방식이 가장 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.23
|
||||
- 프런트가 이미 `topic/template` 메서드만 실제로 쓰고 있다면, `api.js` 안에 남은 레거시 `game` 별칭까지 계속 유지하는 건 오히려 정리 상태를 흐리므로 이 단계에서 정리하는 편이 맞다고 판단했다.
|
||||
- 티어표 저장과 템플릿 요청처럼 핵심 생성 흐름은 백엔드 내부 payload도 먼저 `topicId` 기준으로 맞춰 두는 편이, 이후 응답 호환 키를 걷어낼 때 충격을 더 줄인다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.22
|
||||
- 내부 함수명과 export를 정리한 뒤에도 라우트 파일명이 계속 `games.js`로 남아 있으면 마지막까지 개념 충돌을 남기게 되므로, 공개 주제 라우트 파일명도 실제 의미에 맞게 `topics.js`로 옮기는 편이 맞다고 판단했다.
|
||||
- `/api/games` 호환 경로는 유지하더라도, 서버 내부 구현만큼은 `topic` 기준 param 이름과 파일 이름으로 정리해 두는 편이 이후 레거시 제거를 훨씬 더 쉽게 만든다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.21
|
||||
- 백엔드에서 `topic/template` 응답을 내보내더라도 프런트가 계속 `game` 키만 읽으면 호환 레이어가 끝나지 않으므로, 이번 단계부터는 실제 사용자 화면과 관리자 저장 흐름도 새 키를 우선 읽게 맞추는 편이 맞다고 판단했다.
|
||||
- 이 구간은 외부 API를 끊는 작업이 아니라 “프런트가 새 의미를 먼저 받아들이는 단계”이므로, 기존 `game` 키는 fallback으로만 남겨 두고 단계적으로 걷어내는 편이 가장 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.20
|
||||
- 스키마만 `topic`으로 옮기고 함수명/라우트 내부가 계속 `game`으로 남아 있으면 이후 유지보수에서 계속 의미 충돌이 생기므로, 이번 단계부터는 백엔드 export와 주요 라우트 내부 이름도 `topic/template`를 기본으로 읽히게 정리하는 편이 맞다고 판단했다.
|
||||
- 다만 외부 API와 프런트 호환을 한 번에 끊는 건 위험하므로, 실제 구현은 새 `topic` 이름을 기본으로 쓰되 기존 `game` 이름은 alias와 호환 응답으로 잠시 유지하는 점진 전환이 가장 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.19
|
||||
- 템플릿 기본 아이템 삭제가 과거에 저장된 티어표까지 바꿔 버리면 운영자 편집 의도보다 사용자 결과물 보존이 더 크게 흔들리므로, 이 삭제는 “앞으로의 템플릿 구성만 바꾸고 기존 저장본은 보존”하는 편이 맞다고 판단했다.
|
||||
- 다만 이미 여러 티어표에서 쓰인 아이템인지 모른 채 지우게 두는 것도 위험하므로, 삭제 자체를 막기보다는 저장된 티어표 사용 개수와 공개/비공개 범위를 먼저 보여주고 운영자가 맥락을 알고 결정하게 하는 쪽이 더 현실적이라고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.18
|
||||
- 새 창 열기처럼 브라우저 기본 앵커가 충분한 동작은 템플릿 안에서 `window.open`을 직접 부르기보다, 기본 링크 동작에 맡기는 편이 더 단순하고 안전하다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.17
|
||||
- `editor` 주소는 이전과 현재가 같은 URL 형태를 공유하므로, 여기까지 redirect를 두면 호환성이 아니라 자기 자신으로의 재해석만 반복하게 된다. 이 구간은 별도 레거시 레코드를 두지 않고 현재 라우트 하나로 수용하는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.16
|
||||
- 백엔드/DB 장애 상황을 단순 연결 실패처럼 보여주면 사용자가 원인을 잘못 이해하게 되므로, 네트워크 단절과 서버 점검/초기화 실패를 전역 UI에서 분리해서 안내하는 편이 맞다고 판단했다.
|
||||
- 이런 장애 안내는 각 화면별 에러 문구를 따로 손보는 것보다 `api` 공통 계층에서 상태를 감지하고 `App` 셸이 한 번에 전환하는 구조가 재사용성과 유지보수 측면에서 더 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.15
|
||||
- 실제 운영 DB에서 마지막 500 원인을 먼저 재현해본 결과, 스키마 설계보다 MariaDB의 `SHOW ... LIKE ?` 플레이스홀더 비호환과 부분 마이그레이션 상태 재진입 이슈가 핵심이었으므로, 이 단계에선 구조 변경보다 기동 안정성을 먼저 회복하는 편이 맞다고 판단했다.
|
||||
- 마이그레이션 로직은 “처음 실행”뿐 아니라 “반쯤 적용된 상태에서 다시 실행”도 견뎌야 하므로, 컬럼 존재 확인과 조건 분기를 모두 공용 `information_schema` 검사로 모으는 편이 더 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.14
|
||||
- 기존 `/games` 주소 호환은 alias보다 redirect가 더 맞다고 판단했다. 이번 단계에선 주소는 유지하되 라우트 파라미터 의미는 항상 `topicId`로 정규화해 Vue Router 경고와 내부 분기를 함께 줄였다.
|
||||
- 운영 DB에 직접 `RENAME TABLE`과 컬럼 `CHANGE`를 거는 방식은 실제 환경에서 실패 여지가 커서, 마지막 스키마 전환도 새 topic 스키마를 먼저 만들고 기존 game 데이터를 복사하는 비파괴 마이그레이션이 더 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.13
|
||||
- 사용자 표면과 API 이름층까지 `topic/template`로 옮긴 뒤에는, DB 스키마도 실제로 따라오게 해야 이후 유지보수 비용이 덜 쌓이므로 `games` 계열 실명을 `topics` 계열로 마이그레이션하는 편이 맞다고 판단했다.
|
||||
- 다만 한 번에 응답 키까지 완전히 끊으면 프런트와 관리자 흐름이 너무 크게 흔들릴 수 있으므로, 이번 단계에서는 실제 저장 스키마는 `topic`으로 옮기고 응답의 `gameId / gameName`은 호환 키로 잠시 함께 유지하는 점진 마감이 가장 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.12
|
||||
- 프런트 이름만 바꾸는 단계가 끝난 뒤에는, 백엔드도 새 `/api/topics`, `/api/admin/templates` 경로를 열고 기존 `/games`는 호환용으로 남기는 점진 전환이 가장 안전하다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.11
|
||||
- 백엔드 `/api/games` 경로를 바로 바꾸기보다, 프런트 API 객체에서 먼저 `topic/template` 의미 이름을 제공하고 호출부를 옮기는 편이 위험이 훨씬 낮다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.10
|
||||
- 사용자 주소는 이미 `/topics`로 옮기기 시작했으므로, 라우트 이름과 기본 파라미터도 `topicHub / topicId` 기준으로 맞추고 기존 `gameId`는 호환 fallback으로만 남기는 편이 더 자연스럽다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.9
|
||||
- 경로 전환은 화면마다 문자열을 직접 고치는 방식보다, 공용 경로 헬퍼를 먼저 세워 주제·에디터·로그인 리다이렉트 흐름을 한 기준으로 묶는 편이 이후 리네이밍 비용을 훨씬 줄인다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.8
|
||||
- 주제 상세 화면 제목은 내부 ID를 잠깐 보여주는 것보다, 로딩 문구를 거쳐 실제 이름으로 자연스럽게 바뀌는 편이 사용자 체감상 더 안정적이라고 판단했다.
|
||||
- 주요 목록 화면은 `pageHead` 문법을 계속 통일해 두는 편이, 이후 검색/필터 툴바를 더 붙이더라도 구조를 예측하기 쉽다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.7
|
||||
- 주제 상세 컬렉션 화면도 즐겨찾기·나의 티어표와 같은 `pageHead` 문법으로 맞춰야, 네비게이션으로 이동하는 주요 화면들의 리듬이 더 자연스럽다고 판단했다.
|
||||
- 라우트 전환은 한 번에 `/games`를 없애기보다, 먼저 `/topics`를 기본 진입 경로로 세우고 기존 `/games`는 alias로 유지하는 점진 전환이 더 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.6
|
||||
- 내부 리네이밍 2단계는 관리자 화면처럼 상태와 액션이 많은 영역부터 정리해 두는 편이, 이후 `/games` 라우트와 API 계층을 손볼 때 위험을 줄이는 데 더 유리하다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.5
|
||||
- 내부 리네이밍은 한 번에 API와 DB까지 건드리기보다, 홈·주제 화면·에디터처럼 영향 범위가 비교적 명확한 프런트 핵심 흐름부터 `game` 의존 이름을 줄여 나가는 편이 더 안전하다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.4
|
||||
- 용어 정리 마무리 단계에서는 눈에 잘 띄는 영어 헤더를 그대로 두기보다, 홈과 관리자처럼 진입 빈도가 높은 화면의 상단 라벨까지 한국어로 맞춰야 전체 제품 인상이 더 자연스럽다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.3
|
||||
- 용어 전환은 메뉴 타이틀만 바꾸는 것으로 끝나지 않고, 관리자 작업 중 반복해서 보게 되는 토스트와 확인창까지 맞춰야 실제 체감 일관성이 살아난다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.2
|
||||
- 용어 정리를 시작한 뒤에는 일부 화면만 바꾸는 것보다, 관리자 모달과 확인 메시지처럼 실제 운영 중 많이 보는 문구도 함께 맞춰 주는 편이 체감 일관성이 더 높다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.1
|
||||
- 좌측 메뉴와 화면 타이틀의 명칭이 서로 다르면 사용자가 현재 위치를 직관적으로 매칭하기 어렵기 때문에, 메뉴 이름과 진입 타이틀을 같은 문구로 맞추는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.0
|
||||
- 서비스가 게임 외 주제 전반을 다룰 수 있는 단계에 온 만큼, 내부 모델명은 유지하더라도 사용자에게 보이는 주요 용어는 `주제 / 템플릿` 기준으로 먼저 정리하는 편이 맞다고 판단했다.
|
||||
- 대규모 내부 리네이밍은 API와 DB까지 손대야 하므로, 이번 단계에서는 사용자 화면 문구만 우선 바꾸고 내부 `game` 모델은 그대로 두는 점진적 전환이 더 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.93
|
||||
- 목록 카드 썸네일은 드래그 대상이 아니라 클릭 대상에 가깝기 때문에, 브라우저 기본 이미지 드래그 프리뷰는 전부 막아 두는 편이 UX 측면에서 맞다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.92
|
||||
- 왼쪽 레일 활성 메뉴도 로그인 토글과 같은 이동형 배경 문법을 쓰는 편이 앱 전체 인터랙션 언어를 더 일관되게 만든다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.91
|
||||
- 로그인/회원가입 탭은 즉시 배경 교체보다, 선택 배경이 실제로 이동하는 토글 문법이 더 직관적이고 상태 전환이 잘 읽힌다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.90
|
||||
- 경고 수준의 CSS 진단이라도 실제 의미 없는 속성이나 벤더 전용 속성 누락이라면 바로 정리해 두는 편이 이후 유지보수 피로를 줄인다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.89
|
||||
- 더 이상 참조되지 않는 Vite 기본 자산과 레거시 public 아이콘 묶음은 남겨둘수록 혼동만 커지므로, 실제 사용 파일만 남기고 정리하는 편이 맞다고 판단했다.
|
||||
- 공유용 썸네일은 코드 수정과 별개로 시각 자산 손질이 자주 일어날 수 있으므로, 이번처럼 워크트리에 이미 반영된 최신 이미지 수정본은 함께 릴리스에 포함하는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.88
|
||||
- 헤더의 `by zenn`은 이미 공통 카피라이트 링크가 생긴 뒤 역할이 겹치므로, 브랜드 영역은 서비스명 중심으로 정리하는 편이 맞다고 판단했다.
|
||||
- 외부 공유 미리보기는 메타 태그만 넣는 것보다 실제 전용 썸네일 자산을 함께 두는 편이 메신저/소셜/모바일 홈 화면까지 더 안정적으로 동작한다고 정리했다.
|
||||
- 파비콘은 인라인 data URL 하나에 의존하기보다 `svg + png + apple-touch-icon` 조합으로 두는 편이 브라우저와 기기 호환성 측면에서 안전하다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.86
|
||||
- 아이콘 크기는 이미지 다운로드 결과에만 반영되고 저장본에는 남지 않으면 사용자가 체감상 “저장되지 않는 설정”으로 느끼게 되므로, 티어표 본문 설정으로 저장하는 편이 맞다고 정리했다.
|
||||
- 저장 경로를 고친 뒤에도 프리뷰 화면이 기본값으로 보인다면, 데이터보다 프런트 렌더링 루트에 동일 CSS 변수가 전달되는지 먼저 확인하는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.83
|
||||
- 모바일에서 열 헤더가 칸과 시각적으로 분리되는 문제는 전체 레이아웃을 다시 갈아엎기보다, 각 칸 안에 열 이름 배지를 같이 보여주는 편이 가장 적은 변경으로 효과를 낸다고 정리했다.
|
||||
- 배지를 쓰는 반응형 구간에서는 기존 상단 열 헤더까지 남겨두면 중복 정보가 되므로, 같은 브레이크포인트에서 헤더는 숨기고 칸 배지 하나만 남기는 편이 맞다고 정리했다.
|
||||
- 반응형 보정은 한 미디어 구간 안에서 서로 다른 규칙이 다시 덮어쓰지 않게 정리해야 하므로, 모바일용 `1fr` 레이아웃을 선언한 뒤 예전 `140px/150px` 규칙은 제거하는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.82
|
||||
- 프리뷰 완성본도 결국 공유/열람용 결과물이므로, 이미지 다운로드 결과와 같은 작성자/저장 시각 메타를 같이 보여주는 편이 자연스럽다고 정리했다.
|
||||
- 관리자 템플릿 요청 카드는 “요청 티어표 보기”가 실제로 새창 이동용이라면 하단 버튼과 썸네일 클릭을 둘 다 유지하기보다, 썸네일 클릭 하나로 통합하는 편이 더 단순하고 직관적이라고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.81
|
||||
- 저장된 티어표 공유는 별도 새 페이지를 만들기보다, 이미 완성본 열람에 쓰고 있는 `preview=1` 주소를 그대로 공유 링크로 재사용하는 편이 가장 단순하고 일관적이라고 정리했다.
|
||||
- 공유 액션은 저장/삭제처럼 저장본 전제의 보조 기능이므로, 메인 저장 버튼 영역보다 하단 유틸리티 링크 영역에 두는 편이 더 자연스럽다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.79
|
||||
- 카피라이트처럼 앱 전체 브랜딩 성격의 footer는 관리자 텔레포트 안에 두기보다, `App.vue`의 공통 오른쪽 레일 footer로 두는 편이 위치도 안정적이고 화면 간 일관성도 높다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.78
|
||||
- 축소 상태에서는 텍스트가 사라지므로 같은 `티어표 만들기` 계열 액션이라도 커스텀 제작과 템플릿 기반 제작을 아이콘으로 구분해 주는 편이 맞다고 정리했다.
|
||||
- 관리자 우측 카피라이트처럼 “사이드바 하단”에 붙어야 하는 정보는 텔레포트 루트의 형제 노드로 두기보다, 실제 사이드바 컨테이너 내부의 마지막 행으로 두는 편이 레이아웃상 안전하다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.77
|
||||
- 왼쪽 레일을 접었을 때 하단 액션을 완전히 숨기면 `새 티어표 만들기` 진입점이 사라지므로, 펼친 상태의 하단 위치는 유지하되 축소 상태에서는 같은 위치의 아이콘 전용 버튼으로 바꿔 남겨두는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.76
|
||||
- 왼쪽 사이드 레일을 접었을 때는 텍스트가 사라진 뒤에도 행 높이가 제각각이면 아이콘 전용 탐색기로 읽히지 않으므로, 아바타/검색/내비 항목의 높이를 같은 규격으로 통일하는 편이 맞다고 정리했다.
|
||||
- 왼쪽 레일 검색은 화면에 따라 티어표 검색으로 바뀌면 사용자가 사이드 검색과 메인 검색 역할을 구분하기 어려우므로, 사이드는 게임 검색으로 고정하고 티어표 검색은 메인 화면 문맥에 맡기는 편이 더 자연스럽다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.75
|
||||
- 관리자 공용 모달은 기본 카드 여백을 계속 쓰되, 내부에 자체 셸을 가진 대형 상세 모달까지 같은 패딩을 강제로 받으면 오히려 레이아웃이 무너지므로 예외 클래스로 분리하는 편이 맞다고 정리했다.
|
||||
- 관리자 표기 링크는 텍스트만 두기보다, 추후 주소 변경이 쉬운 한 곳짜리 상수와 새 창 링크로 관리하는 편이 운영 측면에서 더 낫다고 판단했다.
|
||||
- 왼쪽 사이드 레일 접힘 상태는 요소를 좁히는 것만으로는 높이와 정렬 문제가 계속 남으므로, 메타 텍스트는 실제로 숨기고 아이콘 중심 문법으로 따로 정리하는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.74
|
||||
- 관리자 공용 게임 선택 모달은 단순 검색만 제공하기보다, 현재 문맥에서 이미 선택 불가능한 대상을 `이미 추가됨`으로 명시하고 막아 주는 편이 운영 실수를 줄이는 데 더 효과적이라고 정리했다.
|
||||
- 프로젝트 표기는 관리자 헤더 상단보다 사이드바 최하단의 작은 카피라이트 문구로 빼는 편이 정보 밀도를 덜 방해한다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.73
|
||||
- 게임 선택이 여러 관리자 화면에 퍼지기 시작한 시점에서는 일부 화면만 셀렉트나 내부 리스트를 유지하기보다, 공용 검색 모달 하나로 통일하는 편이 장기적으로 더 일관되고 확장에 강하다고 정리했다.
|
||||
- 검색 입력과 실행 버튼은 세로로 같은 문법으로 쌓기보다, 입력은 입력끼리 실행은 액션으로 읽히게 한 줄 배치로 적당히 구분해주는 편이 운영 화면에서 덜 답답하다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.72
|
||||
- 라우트 복원용 watcher가 composable 반환값 초기화보다 먼저 돌 수 있는 구간에서는 직접 함수를 즉시 호출하기보다, 초기화 완료 뒤 실행되도록 한 템포 미루는 편이 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.71
|
||||
- 관리자에서 게임 선택 지점이 늘어나는 구조라면 각 화면마다 셀렉트/긴 리스트를 따로 두기보다, 공용 검색 모달 하나로 통일하는 편이 이후 100개 이상 게임이 쌓여도 더 안정적이라고 정리했다.
|
||||
- 아이템 모달은 참조 정보 정리 뒤에도 왼쪽 선택 요약 카드가 여전히 과하다고 판단해, 예전처럼 게임 선택 자체에 더 집중한 구조로 한 단계 더 되돌리는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.70
|
||||
- 관리자 티어표 목록은 페이지 단위 열람만으로는 운영 개입이 부족하므로, 게임별 필터와 카드 단위 관리 액션을 함께 붙여 실제 검수 도구로 쓰는 편이 맞다고 정리했다.
|
||||
- 공개 여부는 문장 속 메타보다 배지로 따로 떼어 보여주는 편이 다른 관리자 카드들과 문법이 맞고, 공개/비공개 전환도 같은 관리 모달 안에서 바로 처리하는 쪽이 운영 흐름상 자연스럽다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.69
|
||||
- 관리자 아이템 라이브러리의 참조 수는 저장 구조 설명에는 도움이 되지만 실제 운영에서는 대부분 의미가 약하므로, 카드와 모달에서 계속 전면에 두기보다 다시 숨기고 필요한 경우 내부 데이터로만 남기는 편이 맞다고 정리했다.
|
||||
- 관리자 상단 요약 수치는 `활성/대기` 같은 상태 문구보다 실제 운영 판단에 바로 쓰이는 `공개/비공개 개수`가 더 중요하므로, 게임 관리와 티어표 관리 모두 공개 상태 집계를 먼저 보여주는 편이 낫다고 판단했다.
|
||||
- 공개/비공개 수치는 현재 페이지 일부를 세면 오해가 생기기 쉬우므로, 검색/게임 기준 전체 결과를 집계하는 별도 관리자 API로 계산하는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.68
|
||||
- 관리자 아이템 상세는 새 모달을 겹쳐 올리는 방식보다 기존 모달 안에서 `왼쪽 선택 대상 / 오른쪽 작업과 참조 정보` 역할만 분명히 나누는 편이 더 안정적이라고 정리했다.
|
||||
- 같은 이미지를 두 위치에서 반복 노출하면 “모달이 두 개 겹친 것처럼” 느껴질 수 있으므로, 선택 썸네일은 한 곳에만 두고 양쪽 패널은 각자 스크롤되는 구조로 정리하는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.67
|
||||
- 같은 이미지 공유 구조는 저장 효율에는 유리하지만 운영자가 관계를 읽기 어렵기 때문에, 카드 단계에서는 참조 수를 바로 보여주고 상세 모달에서는 같은 `src`를 가리키는 기록들을 함께 펼쳐 보여주는 편이 맞다고 정리했다.
|
||||
- 삭제 제한을 과하게 두기보다, 삭제 전 영향 범위를 문구와 개수로 먼저 보여주는 쪽이 운영 측면에서 더 현실적이라고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.66
|
||||
- 누락 참조 정리 도구는 커스텀 아이템 누락이 없어도 티어표/요청 썸네일 누락을 항상 따로 정리해야 하므로, 썸네일 정리를 커스텀 아이템 분기에 묶어두면 안 된다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.65
|
||||
- 누락 파일 수치가 계속 쌓이는 상태에서는 원인 분석만으로는 운영 부담이 줄지 않으므로, 실제 파일이 없는 참조만 골라 썸네일/아바타/게임 아이템/커스텀 아이템 참조를 정리하는 관리자 액션을 제공하는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.64
|
||||
- 관리자 이미지 최적화 기록은 “재사용” 자체보다 이번 업로드와 재사용 자산의 관계가 더 중요하므로, 용량 숫자와 설명을 함께 보여주는 편이 운영자가 오해하지 않기 쉽다고 정리했다.
|
||||
- 관리자 아이템 라이브러리는 `사용자 업로드 미사용만` 같은 단일 체크박스보다 출처와 사용 상태를 함께 고를 수 있는 필터 모드가 더 실용적이므로, 사용자·템플릿·보관 자산·미사용 상태를 분리해 보는 구조가 맞다고 판단했다.
|
||||
- 게임 목록이 커질수록 선택 게임 설정을 사이드바 하단에 두는 구조는 스크롤 부담이 커지므로, 공개 상태와 썸네일 관리 액션은 선택된 게임 본문 상단 카드로 올리는 편이 더 안정적이라고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.63
|
||||
- 이미지 최적화 기록은 내부 라우트 카테고리를 그대로 보여주면 운영자가 실제 의미를 해석해야 하므로, 관리자 화면에는 기능 기준의 한국어 라벨과 재사용 여부를 함께 보여주는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.62
|
||||
- 커스텀 이미지가 많은 상태에서 저장할 때 사용자 체감 순서가 흔들리는 것은 업로드 성공보다 더 직접적인 UX 문제이므로, 내부 객체 키 순서가 아니라 현재 화면 배치 순서를 저장 기준으로 삼는 편이 맞다고 정리했다.
|
||||
- 템플릿 요청이 저장본에서만 가능하다면 삭제도 같은 기준을 따르는 편이 흐름상 자연스러우므로, 저장되지 않은 초안에는 삭제 액션을 노출하지 않는 쪽으로 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.61
|
||||
- 업로드 드롭존은 기능만 같고 생김새가 다르면 운영자와 사용자 모두 맥락 전환 비용이 생기므로, 관리자와 에디터에서 같은 아이콘·점선 보더·버튼 문법으로 읽히게 맞추는 편이 낫다고 정리했다.
|
||||
- 썸네일 교체 영역은 일반 입력 필드처럼 보이면 클릭 가능성이 떨어지므로, 이미지 미리보기 위에서도 업로드 박스라는 인상이 유지되게 밝은 배경과 아이콘을 함께 쓰는 쪽으로 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.60
|
||||
- 관리자 접근 차단은 유지하되, 이미 로그인된 관리자가 새로고침할 때 홈으로 튕기는 체감은 권한 제어보다 더 큰 문제이므로 인증 결과가 나올 때까지 같은 세션 확인 요청을 기다리는 편이 맞다고 정리했다.
|
||||
- 관리자 썸네일 드롭존과 에디터 보드 드롭존은 기능은 같아도 현재 상태가 문구와 형태로 바로 드러나야 하므로, 빈 상태와 교체 상태를 텍스트로 구분하고 점선 박스 형태를 더 적극적으로 드러내는 쪽으로 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.59
|
||||
- 템플릿 요청 아이템 반영은 관리자 주의에만 맡기기보다, 서버에서 같은 게임 안의 동일 `src` 중복 생성을 막고 프런트에서도 이미 반영된 요청 아이템을 다시 드래프트에 올리지 않는 편이 운영 안정성에 더 낫다고 정리했다.
|
||||
- 신규 템플릿 요청은 “완전한 임시 게임” 구조로 바로 바꾸기보다, 우선 한 번 만든 게임을 요청과 연결해 다시 `확인하기`를 눌러도 같은 게임을 재사용하게 만드는 편이 리스크 대비 효과가 크다고 판단했다.
|
||||
- 신규 템플릿 요청 카드는 생성 여부가 관리자의 머릿속 상태가 아니라 UI 메타로 드러나야 하므로, `연결된 게임 있음/없음`과 `이미 반영 n개`를 카드와 작업 패널 양쪽에서 함께 보여주는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.55
|
||||
- 관리자 요청/업로드 배지는 문구만 다르면 빠르게 구분하기 어려우므로, 같은 `pill` 구조를 유지하되 색으로도 역할을 나누는 편이 운영 판단에 더 적합하다고 정리했다.
|
||||
- 신규 템플릿 요청으로 새 게임을 만들 때는 아이템만 가져오고 썸네일이 비어 있으면 식별성이 떨어지므로, 요청 썸네일도 기본값으로 함께 승계하는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.54
|
||||
- 관리자 요청 카드는 운영자가 이미 흐름을 알고 있다는 전제에서, 설명형 힌트보다 즉시 판단에 필요한 메타와 액션만 남기는 편이 더 적합하다고 정리했다.
|
||||
- 요청 종류 표시는 중복 텍스트보다 오른쪽 상단의 짧은 상태 배지 하나로 고정하고, 하단 액션 줄은 `보조 링크는 왼쪽 / 실제 처리 버튼은 오른쪽` 구조가 더 읽기 쉽다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.53
|
||||
- 관리자 후속 리팩터링은 남은 큰 액션 묶음인 `상단 고정 게임 정렬`과 `커스텀 아이템 검수`부터 composable로 분리하는 편이 `AdminView.vue` 체감 복잡도를 가장 빨리 낮춘다고 판단했다.
|
||||
- 이 단계에서도 레이아웃이나 문구보다 로직 책임 경계를 먼저 옮기고, 실제 스타일 파일 분리는 그 다음 단계로 이어가는 편이 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.52
|
||||
- 관리자 화면은 본문을 컴포넌트로 나눈 뒤에도 같은 시각 문법을 유지해야 하므로, `scoped`를 유지한 채 각 섹션에 스타일을 복붙하기보다 관리자 범위 공통 스타일로 다시 묶는 편이 더 안전하다고 정리했다.
|
||||
- `템플릿 요청 관리 / 전체 티어표 관리` 내부 모드 값은 URL과 버튼 상태가 어긋나지 않도록 `all` 하나로 통일하는 편이 맞다고 판단했다.
|
||||
- 릴리스 기록은 문서 버전만 올라가고 태그가 빠지면 추적이 끊기므로, 뒤늦게라도 누락 태그를 다시 맞춰 버전 흐름을 복구하는 편이 낫다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.51
|
||||
- 관리자 리팩터링은 본문 분리 다음 단계에서 `회원 관리`처럼 모달과 부수 액션이 많은 영역을 composable로 떼어내는 편이 효과가 크다고 판단했다.
|
||||
- 이 단계에서는 UI 문구나 사용자가 이미 손본 CSS를 다시 건드리기보다, 현재 동작을 유지한 채 책임 경계만 옮기는 쪽이 더 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.50
|
||||
- 템플릿 요청 카드는 게임 이름/ID만 남기기보다 대표 썸네일까지 유지하는 편이 운영자가 요청 대상을 훨씬 빨리 구분할 수 있다고 정리했다.
|
||||
- 템플릿 요청의 `확인하기`는 단순히 해당 게임을 선택하는 동작이 아니라, 게임 관리 화면에서 요청 아이템 후보가 실제 작업 상태로 복원되어야 한다고 판단했다.
|
||||
- 관리자 화면은 기능이 많아진 만큼 단일 `/admin` 상태보다 섹션별 경로를 갖는 편이 뒤로가기와 직접 진입 모두에서 더 안정적이라고 정리했다.
|
||||
- 관리자 URL은 보이기만 막는 수준이 아니라, 라우터 단계에서 비로그인/비관리자 접근 자체를 차단하는 편이 맞다고 정리했다.
|
||||
- 티어표 에디터의 커스텀 아이템 이름 편집 목록은 “사전순 재정렬”보다 입력 안정성이 더 중요하므로, 실시간 라벨 기준 정렬은 제거하는 쪽으로 결정했다.
|
||||
- 게임 기본 아이템은 최신 추가 항목이 먼저 보이도록 하되, 관리자가 필요하면 직접 드래그해 기준 순서를 고정할 수 있어야 한다고 판단했다.
|
||||
- 관리자 리팩터링은 한 번에 로직까지 갈아엎기보다, 먼저 각 관리 본문을 섹션 컴포넌트로 분리해 `AdminView.vue`의 책임을 줄이는 단계형 접근이 더 안전하다고 정리했다.
|
||||
- 본문 템플릿 분리 다음 단계에서는 `게임 관리`와 `템플릿 요청`처럼 상태가 무거운 영역부터 composable로 옮겨, 뷰 파일과 업무 로직 파일의 경계를 먼저 세우는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-01 v1.3.49
|
||||
- 템플릿 요청은 임시 편집 상태에 기대기보다, 먼저 저장된 티어표를 기준으로 요청 스냅샷을 만드는 편이 훨씬 안정적이라고 정리했다. 그래서 요청 버튼은 저장본이 있을 때만 노출하고, 제목이 비어 있으면 사람이 쓰는 기본 문구 대신 고유한 랜덤 제목으로 먼저 저장본을 만든 뒤 요청을 이어가도록 했다.
|
||||
- 관리자 템플릿 요청 미리보기는 별도 요청 전용 보드 레이아웃을 유지하기보다, 일반 티어표 완성본과 같은 행·열·남은 아이템 문법을 그대로 재사용하는 편이 검수 체감이 가장 자연스럽다고 판단했다.
|
||||
## 2026-04-01 v1.3.48
|
||||
- 관리자 탭 데이터는 첫 진입 로딩만 믿기보다, 인증 완료와 탭 전환 시점에 필요한 목록을 다시 채워 넣는 편이 실제 운영 화면에서 더 안정적이라고 정리했다.
|
||||
- 템플릿 요청 미리보기는 일반 티어표 보기와 완전히 같은 구현을 억지로 분기하기보다, 같은 내부 프레임 문법과 정보 밀도를 먼저 맞춰 체감 차이를 줄이는 쪽이 현실적이라고 판단했다.
|
||||
## 2026-04-01 v1.3.47
|
||||
- 관리자 `사용자 템플릿 요청`도 결국 검수용 카드이므로, 요청 전용 카드 문법을 따로 두기보다 `전체 티어표 관리`와 같은 카드 구조를 재사용하는 편이 더 직관적이라고 정리했다.
|
||||
- 새 템플릿 생성 요청의 기본 게임 ID는 사람이 읽기 어려운 난수보다 요청 단위에서 유일한 임시값을 먼저 채워두고, 승인 전에 관리자가 수정하는 흐름이 더 현실적이라고 판단했다.
|
||||
|
||||
## 2026-04-01 v1.3.46
|
||||
- 관리자 전체 티어표 카드에서는 좌측 영역 전체를 버튼처럼 만드는 것보다, 실제 썸네일 이미지만 미리보기 진입점으로 읽히게 두는 편이 카드 정보 구조가 덜 흔들린다고 정리했다.
|
||||
- 템플릿 요청 미리보기는 일반 티어표 보기와 다른 요약 레이아웃을 새로 두기보다, 같은 내부 프레임 문법 안에서 보드 자체를 먼저 보여주는 편이 사용자가 더 자연스럽게 이해한다고 판단했다.
|
||||
|
||||
## 2026-04-01 v1.3.45
|
||||
- 템플릿 요청에서 `내 티어리스트에도 저장`은 별도 부가 기능이 아니라 실제 저장본 생성 경로를 타므로, 새 저장본 ID는 호출자에 기대지 말고 저장 함수 내부에서 항상 보장하는 편이 더 안전하다고 정리했다.
|
||||
- 개발 단계의 내부 조치 문구인 `백엔드 재시작` 같은 표현은 사용자 토스트에 직접 노출하지 않고, 운영형 재시도 안내로 낮추는 편이 맞다고 판단했다.
|
||||
@@ -264,6 +652,18 @@
|
||||
- 작성자가 자기 티어표를 직접 삭제할 수 있어야 관리 흐름이 완결되므로, `내 티어표` 화면에 삭제 기능을 추가하기로 결정했다.
|
||||
- 사용자 커스텀 이미지는 서버 용량을 계속 차지하므로, 참조가 하나도 남지 않은 이미지(미사용 항목)를 식별하고 관리자 화면에서 개별/일괄 정리할 수 있도록 하기로 결정했다.
|
||||
|
||||
## 2026-04-03 v1.4.65
|
||||
- 운영 환경에서 루트 정적 favicon 요청이 계속 `403`으로 떨어지는 상황에서는 원인을 프록시/권한 계층에서 끝까지 추적하기보다, 브라우저 탭용 파비콘을 인라인 데이터 URL로 제공해 해당 요청 자체를 없애는 편이 더 단순하고 안정적이라고 판단했다.
|
||||
- 다만 iOS 홈 화면용 `apple-touch-icon.png`와 외부 공유용 `og-card.png`는 실제 파일이 필요하므로, 일반 브라우저 탭 favicon만 인라인 처리하는 선으로 범위를 제한했다.
|
||||
|
||||
## 2026-04-03 v1.4.64
|
||||
- 운영/로컬 DB를 새로 미는 흐름을 공식화한 만큼, 더 이상 “기존 DB에서만 우연히 남아 있던 컬럼”에 기대지 않고 `ensureSchema()`의 신규 생성 정의만으로 관리자 화면 전체가 떠야 한다고 다시 정리했다.
|
||||
- `template_requests`는 요청 목록 카드뿐 아니라 요청 미리보기와 이미지 참조 추적에도 쓰이므로, 저장 스냅샷 컬럼(`groups_json`, `board_items_json`, `show_character_names_snapshot`)을 초기 스키마에 반드시 포함하기로 결정했다.
|
||||
|
||||
## 2026-04-03 v1.4.63
|
||||
- 관리자/에디터 화면의 우측 패널은 Teleport로 공통 셸의 레일 DOM에 끼워 넣는 구조이므로, 라우트 변경 시 Teleport 대상 노드 자체를 조건부로 없애면 Vue 언마운트/패치 순서에 따라 DOM 기준점이 깨질 수 있다고 판단했다.
|
||||
- 따라서 `#local-right-rail-root`는 항상 렌더링해두고, 일반 화면에서는 숨김 클래스만 적용하는 방식으로 유지해 라우트 전환 안정성을 우선 확보하기로 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.13
|
||||
- 관리자 페이지는 기능 수가 늘어난 만큼 게임, 아이템, 회원 관리 탭으로 나누는 편이 더 안전하다고 판단했다.
|
||||
- 사용자 커스텀 아이템은 수량이 빠르게 커질 수 있으므로, 검색과 페이지네이션을 기본 제공하는 관리 화면으로 보는 방향을 채택했다.
|
||||
@@ -322,6 +722,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`)을 사용했다.
|
||||
|
||||
48
docs/map.md
@@ -2,23 +2,23 @@
|
||||
|
||||
## `/`
|
||||
- 화면 파일: `frontend/src/views/HomeView.vue`
|
||||
- 역할: 데스크톱 기본 4열 게임 카드 라이브러리 대시보드, 상단 메인 썸네일과 `게임명 + 작은 ID` 메타를 가진 템플릿 선택 카드, 게임 카드 클릭 이동, `직접 티어표 만들기` 진입
|
||||
- 연동 API: `GET /api/games`
|
||||
- 역할: 데스크톱 기본 4열 주제 카드 라이브러리 대시보드, 상단 메인 썸네일과 `주제명 + 작은 ID` 메타를 가진 템플릿 선택 카드, 주제 카드 클릭 이동, `직접 티어표 만들기` 진입
|
||||
- 연동 API: `GET /api/topics`
|
||||
|
||||
## `/games/:gameId`
|
||||
- 화면 파일: `frontend/src/views/GameHubView.vue`
|
||||
- 역할: 선택한 게임 정보 표시, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
|
||||
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`
|
||||
## `/topics/:topicId`
|
||||
- 화면 파일: `frontend/src/views/TopicHubView.vue`
|
||||
- 역할: 선택한 주제 slug 기준 정보 표시, 관리자 추천 티어표 상단 강조 섹션과 일반 공개 티어표 목록 분리 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
|
||||
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`
|
||||
|
||||
## `/editor/:gameId/new`, `/editor/:gameId/:tierListId`
|
||||
## `/editor/:topicId/new`, `/editor/:topicId/:tierListId`
|
||||
- 화면 파일: `frontend/src/views/TierEditorView.vue`
|
||||
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드
|
||||
- 연동 API: `GET /api/games/:gameId`, `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`
|
||||
- 역할: 주제 slug 기반 에디터 진입, 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 아이템 클릭 선택 후 셀/풀 재배치, 아이템 우클릭 메뉴 기반 복제본 생성, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, 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,18 +47,18 @@
|
||||
|
||||
## `/admin`
|
||||
- 화면 파일: `frontend/src/views/AdminView.vue`
|
||||
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 게임 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/완성본 이동, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 게임 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
|
||||
- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `PATCH /api/admin/games/:gameId/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/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-game-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId`
|
||||
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `템플릿 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 템플릿 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 템플릿 이름/slug 수정, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/받은 즐겨찾기 수 표시/인기순 정렬/최소 즐겨찾기 필터/추천 지정 토글/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, 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, 전역 우측 상단 토스트 렌더링
|
||||
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker by zenn`이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
|
||||
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, `preview=1` 공유 프리뷰에서도 같은 좌우 레일과 중앙 헤더 유지, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링, 관리자/에디터 화면이 Teleport로 사용하는 `#local-right-rail-root` 대상 DOM을 상시 유지해 라우트 전환 중 우측 레일 언마운트 순서를 안정화
|
||||
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker`와 서비스 설명이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
|
||||
|
||||
## 백엔드 진입점
|
||||
- 서버 엔트리: `backend/index.js`
|
||||
@@ -56,6 +66,8 @@
|
||||
- 로컬 DB 실행 설정: `docker-compose.yml`
|
||||
- 로컬 MariaDB 가이드: `docs/local-mariadb.md`
|
||||
- 인증 라우트: `backend/src/routes/auth.js`
|
||||
- 게임 라우트: `backend/src/routes/games.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`
|
||||
|
||||
196
docs/spec.md
@@ -8,8 +8,8 @@
|
||||
- 업로드 저장소: 로컬 파일 시스템(`backend/uploads/`)
|
||||
- 운영 배포: `frontend(Nginx 정적 서빙 + /api,/uploads 프록시) + backend + mariadb` Docker Compose 구조
|
||||
- NAS HTTPS 리버스 프록시 운영 시 프런트 Nginx는 백엔드로 `X-Forwarded-Proto: https`를 전달하고, Express 세션은 프록시 환경에서 `secure` 쿠키를 허용하도록 설정한다.
|
||||
- 프런트 파비콘은 운영 정적 파일 차단 영향을 줄이기 위해 `index.html`의 인라인 데이터 URL로 제공한다.
|
||||
- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, preview 모드에서는 이 셸을 숨기고 콘텐츠만 렌더링한다.
|
||||
- 프런트 브라우저 탭 파비콘은 운영 정적 파일 차단 영향을 줄이기 위해 `index.html`의 인라인 SVG 데이터 URL로 제공하고, iOS 홈 화면용 `apple-touch-icon.png`와 공유 미리보기용 `og-card.png`만 정적 파일로 유지한다.
|
||||
- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, `preview=1` 모드에서도 같은 셸을 유지한 채 중앙 본문만 완성본 프리뷰로 렌더링한다.
|
||||
- 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 우측 패널은 상단 토글 버튼으로 접고 펼칠 수 있다.
|
||||
- 좌측 패널은 필요 시 축소형 레일로 접을 수 있으며, 접힌 상태에서는 아이콘 중심 내비게이션과 축약된 바로가기만 유지한다.
|
||||
- 이 3단 셸 구조는 홈, 게임 허브, 에디터, 관리자 등 일반 페이지 전반의 공통 뼈대로 유지하고, 페이지별 차이는 중앙/우측에 어떤 콘텐츠를 넣는지만 달라지도록 관리한다.
|
||||
@@ -24,6 +24,8 @@
|
||||
- 아바타: `backend/uploads/avatars/`
|
||||
- 커스텀 아이템: `backend/uploads/custom/`
|
||||
- 시드 이미지: `backend/uploads/seeds/`
|
||||
- 최적화 이미지 자산: 신규 업로드는 `backend/uploads/assets/<앞2글자>/<파일명>.webp` 형태로 1단계 샤딩 저장하고, 기존 `backend/uploads/assets/<파일명>.webp` 평면 경로도 계속 읽는다.
|
||||
- 기존 평면 자산을 샤딩 구조로 정리할 때는 `npm --prefix backend run images:shard-assets`를 실행하며, 스크립트가 파일 이동과 DB/JSON 참조 치환을 함께 처리한다.
|
||||
|
||||
## 화면 구조
|
||||
- 좌측 패널
|
||||
@@ -45,16 +47,17 @@
|
||||
- 좌우 레일의 주요 CTA는 스크롤되는 본문과 분리된 하단 `56px` 액션 영역에 배치한다.
|
||||
- 하단 액션은 화면 바닥에 바로 붙지 않도록 푸터 내부에 추가 하단 여백을 둔다.
|
||||
- 홈 화면 기준 우측 패널은 임시 정보 카드 여러 개보다 핵심 CTA 하나만 남겨, 시안처럼 단순한 보조 레일 역할을 우선 유지한다.
|
||||
- 광고 영역은 상단 헤더와 시각적으로 너무 붙지 않도록 `78px` 상단 여백을 두고, 하단 카피라이트는 중앙 정렬된 공통 footer로 표시한다. 카피라이트 링크는 다크/라이트 테마 모두에서 읽히도록 고정 민트색 대신 테마 텍스트 색과 굵기를 사용한다.
|
||||
- 티어표 편집 화면
|
||||
- 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다.
|
||||
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 보드 영역은 메인 컬럼에, 우측 `320px` 편집 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
|
||||
- 공통 상단 토글 버튼은 Teleport로 이동한 로컬 편집 패널의 접힘/펼침 상태와도 연결되어, 우측 패널을 숨기면 중앙 보드 영역이 확장된다.
|
||||
- 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다.
|
||||
- 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다. 템플릿 등록/업데이트 요청 버튼은 저장된 티어표가 있을 때만 노출하며, 제목이 비어 있는 상태에서 저장하면 랜덤 고유 제목을 먼저 부여해 저장본을 만든다.
|
||||
- 보드 바로 옆에는 드래그용 아이템 풀을 별도 패널로 두고, 커스텀 아이템 이름 정리 목록은 우측 편집 패널 내부에서 관리한다.
|
||||
- 관리자 화면
|
||||
- 공통 우측 패널 대신 전용 로컬 운영 패널을 사용한다.
|
||||
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 관리 목록은 메인 컬럼에, 우측 운영 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
|
||||
- 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다.
|
||||
- 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다. 템플릿 요청 카드는 전체 티어표 카드와 같은 썸네일 좌측/정보 우측 구조를 따르며, 요청 미리보기와 `preview=1` 공유 프리뷰는 공통 앱 셸 안에서 일반 티어표 완성본과 같은 행·열 보드 문법으로 검수한다.
|
||||
- 상단 헤더에는 현재 탭 기준 요약 통계 카드를 배치해 운영 상태를 먼저 읽고, 각 관리 카드는 공통 대시보드 카드 문법(두꺼운 반경, 얕은 레이어 배경, 강조된 액션 버튼)을 공유한다.
|
||||
|
||||
## DB 스키마
|
||||
@@ -63,20 +66,43 @@
|
||||
- `email`: string
|
||||
- `nickname`: string
|
||||
- `passwordHash`: string
|
||||
- `emailVerified`: boolean
|
||||
- `isAdmin`: boolean
|
||||
- `avatarSrc`: string
|
||||
- `lastLoginAt`: number
|
||||
- `createdAt`: number
|
||||
- `games`
|
||||
- 관리자 목록 집계 응답에서는 `tierListCount`, `followerCount`, `receivedFavoriteCount`, `lastLoginAt`, `recentActivityAt`도 함께 내려준다.
|
||||
- `emailVerificationTokens`
|
||||
- `id`: string
|
||||
- `userId`: string
|
||||
- `tokenHash`: string
|
||||
- `expiresAt`: number
|
||||
- `consumedAt`: number
|
||||
- `createdAt`: number
|
||||
- `passwordResetTokens`
|
||||
- `id`: string
|
||||
- `userId`: string
|
||||
- `tokenHash`: string
|
||||
- `expiresAt`: number
|
||||
- `consumedAt`: number
|
||||
- `createdAt`: number
|
||||
- `topics`
|
||||
- `id`: string
|
||||
- 서버가 자동 생성하는 내부 참조용 랜덤 ID이며, 공개 URL 노출값으로 직접 사용하지 않는다.
|
||||
- `slug`: string
|
||||
- 운영자가 지정/수정하는 공개 주소용 식별자이며, 영문 소문자/숫자/하이픈 조합만 허용한다.
|
||||
- `name`: string
|
||||
- `thumbnailSrc`: string
|
||||
- `isPublic`: boolean
|
||||
- `displayRank`: number | null
|
||||
- `createdAt`: number
|
||||
- 시스템 전용 `freeform` 레코드는 홈 화면의 `직접 티어표 만들기` 저장 대상이며 일반 게임 목록에서는 숨긴다.
|
||||
- `gameItems`
|
||||
- 시스템 전용 `freeform` 레코드는 홈 화면의 `직접 티어표 만들기` 저장 대상이며 일반 주제 목록에서는 숨긴다. 신규 빈 DB 초기화 시 자동 생성되는 템플릿은 이 `freeform` 한 건만 유지한다.
|
||||
- `topicItems`
|
||||
- `id`: string
|
||||
- `gameId`: string
|
||||
- `topicId`: string
|
||||
- `src`: string
|
||||
- `label`: string
|
||||
- `displayOrder`: number | null
|
||||
- `createdAt`: number
|
||||
- `customItems`
|
||||
- `id`: string
|
||||
@@ -87,12 +113,16 @@
|
||||
- `tierLists`
|
||||
- `id`: string
|
||||
- `authorId`: string
|
||||
- `gameId`: string
|
||||
- `topicId`: string
|
||||
- DB에는 내부 `topics.id`를 저장하고, API 응답에는 공개 경로용 `topicSlug`도 함께 내려준다.
|
||||
- `title`: string
|
||||
- `thumbnailSrc`: string
|
||||
- 사용자가 직접 지정하지 않으면 저장 시 티어표 대표 아이템 이미지로 자동 채운다.
|
||||
- `description`: string
|
||||
- `isPublic`: boolean
|
||||
- `isFeatured`: boolean
|
||||
- `featuredAt`: number
|
||||
- `featuredBy`: string
|
||||
- `groups`: `{ id, name, itemIds[] }[]`
|
||||
- `pool`: `{ id, src, label, origin }[]`
|
||||
- `createdAt`: number
|
||||
@@ -101,25 +131,63 @@
|
||||
- `userId`: string
|
||||
- `tierListId`: string
|
||||
- `createdAt`: number
|
||||
- `gameSuggestions`
|
||||
- `id`: string
|
||||
- `name`: string
|
||||
- `userFollows`
|
||||
- `followerId`: string
|
||||
- `followingId`: string
|
||||
- `createdAt`: number
|
||||
- `favoriteTopics`
|
||||
- `userId`: string
|
||||
- `topicId`: string
|
||||
- `createdAt`: number
|
||||
- `templateRequests`
|
||||
- `id`: string
|
||||
- `type`: string
|
||||
- `requesterId`: string
|
||||
- `sourceTierListId`: string | null
|
||||
- `sourceTopicId`: string
|
||||
- `targetTopicId`: string
|
||||
- `status`: string
|
||||
- `sourceTierListTitle`: string
|
||||
- `sourceDescription`: string
|
||||
- `thumbnailSrc`: string
|
||||
- `items`: `{ id, src, label, origin }[]`
|
||||
- `snapshotGroups`: `{ id, name, itemIds[] }[]`
|
||||
- `snapshotItems`: `{ id, src, label, origin }[]`
|
||||
- `snapshotShowCharacterNames`: boolean
|
||||
- `createdAt`: number
|
||||
- `updatedAt`: number
|
||||
|
||||
## 주요 API
|
||||
- 인증
|
||||
- `POST /api/auth/signup`
|
||||
- 첫 관리자 계정은 바로 로그인 세션을 만들고, 이후 일반 계정은 인증 메일 발송 후 `verificationRequired` 상태를 반환한다.
|
||||
- `POST /api/auth/login`
|
||||
- 이메일 인증이 끝나지 않은 계정은 `email_unverified`로 차단한다.
|
||||
- `POST /api/auth/logout`
|
||||
- `GET /api/auth/me`
|
||||
- 로그인 세션이 살아 있는 사용자의 `last_login_at`을 주기적으로 갱신해, 회원 관리에서 `마지막 접속일`을 따로 볼 수 있게 한다.
|
||||
- `GET /api/auth/meta`
|
||||
- `POST /api/auth/profile`
|
||||
- 게임
|
||||
- `GET /api/games`
|
||||
- `GET /api/games/:gameId`
|
||||
- `POST /api/auth/password`
|
||||
- 로그인한 사용자가 현재 비밀번호를 확인한 뒤 새 비밀번호로 직접 변경한다.
|
||||
- `POST /api/auth/email/verify`
|
||||
- `login?verifyToken=...` 링크에서 받은 토큰으로 이메일 인증을 완료하고 바로 로그인 세션을 만든다.
|
||||
- 인증 완료 직후 로그인 세션이 열리면서 `last_login_at`도 함께 갱신한다.
|
||||
- `POST /api/auth/email/resend`
|
||||
- 미인증 계정의 인증 메일을 다시 발송한다.
|
||||
- `POST /api/auth/password-reset/request`
|
||||
- 입력한 이메일로 비밀번호 재설정 링크를 발송한다.
|
||||
- `POST /api/auth/password-reset/confirm`
|
||||
- `login?resetToken=...` 링크의 토큰과 새 비밀번호로 비밀번호를 재설정하고 바로 로그인 세션을 만든다.
|
||||
- 재설정 완료 직후 로그인 세션이 열리면서 `last_login_at`도 함께 갱신한다.
|
||||
- 주제
|
||||
- `GET /api/topics`
|
||||
- `GET /api/topics/:topicId`
|
||||
- `:topicId`는 공개 URL에서는 보통 `slug`를 받지만, 내부 ID를 넘겨도 같은 템플릿을 찾을 수 있게 서버가 레코드를 해석한다.
|
||||
- 티어표
|
||||
- `GET /api/tierlists/public`
|
||||
- `gameId` 없이 `q`만 전달하면 전체 공개 티어표 검색에 사용한다.
|
||||
- `featuredTierLists`와 일반 공개 `tierLists`를 분리해서 반환한다.
|
||||
- `topicId`에는 주제 `slug`를 우선 전달하며, `topicId` 없이 `q`만 전달하면 전체 공개 티어표 검색에 사용한다.
|
||||
- `GET /api/tierlists/me`
|
||||
- `GET /api/tierlists/favorites/me`
|
||||
- `GET /api/tierlists/:id`
|
||||
@@ -130,28 +198,48 @@
|
||||
- `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/games`
|
||||
- `POST /api/admin/games/:gameId/thumbnail`
|
||||
- `POST /api/admin/games/:gameId/images`
|
||||
- 여러 이미지를 한 번에 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다.
|
||||
- `PATCH /api/admin/games/:gameId/items/:itemId`
|
||||
- `POST /api/admin/templates`
|
||||
- 요청 본문은 `slug`, `name`, `isPublic`, `thumbnailSrc`를 사용하고, 내부 `topics.id`는 서버가 자동 생성한다.
|
||||
- `PATCH /api/admin/templates/:templateId`
|
||||
- 내부 ID로 템플릿을 찾아 `name`, `slug`, `isPublic`을 수정한다.
|
||||
- `POST /api/admin/templates/:templateId/thumbnail`
|
||||
- `POST /api/admin/templates/:templateId/images`
|
||||
- 여러 이미지를 한 번에 최대 `100개`까지 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다.
|
||||
- `PATCH /api/admin/templates/:templateId/items/:itemId`
|
||||
- `GET /api/admin/tierlists`
|
||||
- `sort=recent|created|favorites`, `minFavorites`, `topicId`, `q`, `page`, `limit`으로 인기 티어표 후보를 정렬/필터링할 수 있다.
|
||||
- `GET /api/admin/tierlists/stats`
|
||||
- 현재 검색어/주제/최소 즐겨찾기 필터가 적용된 범위의 전체/공개/비공개/추천 수를 반환한다.
|
||||
- `PATCH /api/admin/tierlists/:tierListId/featured`
|
||||
- `GET /api/admin/template-requests`
|
||||
- `POST /api/admin/template-requests/:requestId/approve`
|
||||
- `POST /api/admin/template-requests/:requestId/reject`
|
||||
- `POST /api/admin/template-requests/:requestId/link-template`
|
||||
- `POST /api/admin/tierlists/:tierListId/promote-items`
|
||||
- `POST /api/admin/tierlists/:tierListId/create-game-template`
|
||||
- `POST /api/admin/tierlists/:tierListId/create-template`
|
||||
- `GET /api/admin/custom-items`
|
||||
- `filter=library`를 기본값으로 사용해 반복 사용 가능한 `템플릿 아이템 + 사용자 아이템`만 먼저 보여주고, `filter=thumbnail` / `filter=avatar`로는 현재 참조 역할이 썸네일/프로필인 이미지를 따로 조회한다.
|
||||
- `filter=all|library|template|user|thumbnail|avatar|unused-user`를 사용하며, `filter=asset|unused-admin`은 과거 UI 호환용으로만 유지한다.
|
||||
- `POST /api/admin/custom-items/:itemId/promote`
|
||||
- `DELETE /api/admin/custom-items/:itemId`
|
||||
- `DELETE /api/admin/custom-items`
|
||||
- `GET /api/admin/users`
|
||||
- `sort=recent|lastLogin|created|tierlists|followers|favorites`, `direction=asc|desc`로 회원을 콘텐츠 활동/마지막 접속/작성량/팔로워/받은 즐겨찾기 기준으로 정렬한다.
|
||||
- `PATCH /api/admin/users/:userId`
|
||||
- `PATCH /api/admin/users/:userId/password`
|
||||
- `DELETE /api/admin/users/:userId`
|
||||
- `DELETE /api/admin/games/:gameId/items/:itemId`
|
||||
- `DELETE /api/admin/games/:gameId`
|
||||
- `DELETE /api/admin/templates/:templateId/items/:itemId`
|
||||
- `DELETE /api/admin/templates/:templateId`
|
||||
|
||||
## 관리자 화면 메모
|
||||
- 썸네일은 16:9 비율 미리보기 후 `썸네일 적용` 버튼으로 실제 반영한다.
|
||||
@@ -161,30 +249,56 @@
|
||||
- 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다.
|
||||
- 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다.
|
||||
- 게임 관리 탭에서는 홈 화면 상단에 먼저 노출할 게임을 최대 50개까지 지정하고, 드래그 또는 위/아래 버튼으로 순서를 저장할 수 있다.
|
||||
- 사용자 업로드 커스텀 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
|
||||
- 사용자 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
|
||||
- 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다.
|
||||
- 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다.
|
||||
- 사용자 아이템은 사용 횟수(`usageCount`)를 표시하며, `미사용 아이템` 필터에서 저장 티어표/템플릿 참조가 더 이상 없는 항목만 개별/일괄 삭제할 수 있다. 사용자가 계정 탈퇴 등으로 삭제된 경우는 `custom_items.owner_id` 외래키로 레코드가 같이 사라지므로 보통 `미사용 아이템`으로 남지 않는다.
|
||||
- 아이템 관리 기본 필터는 `아이템(템플릿 + 사용자)`이며, 우측 필터 순서는 `전체 이미지 → 아이템(템플릿 + 사용자) → 템플릿 아이템 → 사용자 아이템 → 썸네일 이미지 → 프로필 이미지 → 미사용 아이템`을 사용한다.
|
||||
- `/uploads/assets/avatars/`는 `프로필 아바타`, `/uploads/assets/tierlists/`와 `/uploads/assets/topics/`는 `썸네일 이미지`, 그 외 보관 이미지는 `보관 자산` 배지로 표시한다. 최근처럼 `/uploads/assets/<파일명>.webp` 또는 `/uploads/assets/<앞2글자>/<파일명>.webp` 경로만 보고 종류를 알 수 없는 자산은 DB 참조(`avatar_src`, `thumbnail_src`, `thumbnail_src_snapshot`)를 역추적해 프로필/썸네일 여부를 분류한다. 실제 템플릿 기본 항목은 `템플릿 아이템`, 사용자 커스텀 항목은 `사용자 아이템` 배지를 사용한다.
|
||||
- 같은 이미지 `src`가 해시 중복 재사용으로 템플릿 아이템/사용자 아이템과 프로필 아바타 또는 썸네일 자산에서 동시에 공유되더라도, 아바타/썸네일로 참조 중인 `src`는 자산 카드도 함께 유지해 `프로필 이미지`, `썸네일 이미지`, `전체 이미지` 필터에서 누락되지 않게 한다.
|
||||
- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 내부에서 `템플릿 요청 관리 / 전체 티어표 관리`를 분리해 볼 수 있고, 확인용 완성본은 탐색 UI 없는 preview 전용 모달로 연다.
|
||||
- `전체 티어표 관리`에서는 공개 티어표를 `추천 지정 / 추천 해제`할 수 있고, 추천 지정된 티어표는 주제별 공개 목록 상단의 `추천 티어표` 섹션에 먼저 노출된다. 비공개 티어표는 추천 지정할 수 없고, 추천글을 비공개로 바꾸면 추천 상태도 함께 해제된다.
|
||||
- `전체 티어표 관리` 카드에는 받은 즐겨찾기 수를 함께 보여주며, 우측 운영 패널에서 즐겨찾기 많은 순 정렬과 최소 즐겨찾기 수 필터로 추천 후보를 좁힐 수 있다.
|
||||
- `티어표 관리` 탭의 추가 아이템은 작은 그리드 카드로 표시하고, 클릭 시 `기존 템플릿에 추가 / 새 템플릿 만들기` 모달을 통해 목적지를 선택한다.
|
||||
- `티어표 관리` 탭에서는 티어표 안의 커스텀 아이템을 개별 또는 일괄로 기존 게임 템플릿에 복제할 수 있다.
|
||||
- `freeform` 티어표는 관리자 화면에서 새 게임 ID/이름을 입력해 새로운 게임 템플릿으로 복제 생성할 수 있다.
|
||||
- `freeform` 티어표는 관리자 화면에서 새 템플릿 slug/이름을 입력해 새로운 템플릿으로 복제 생성할 수 있다. 내부 ID는 서버가 자동 생성하므로 운영자가 직접 입력하지 않는다.
|
||||
- 관리자 티어표 관리 상단에는 사용자가 보낸 템플릿 등록/업데이트 요청 목록이 별도로 표시되며, 여기서 승인/반려를 바로 처리할 수 있다.
|
||||
- 관리자 템플릿 요청 목록에서 `반려 후 숨김`을 누르면 해당 요청은 pending 목록에서 즉시 제외된다.
|
||||
- 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다.
|
||||
- 회원 관리 카드에는 아바타, 작성 티어표 수, 최근 활동 시각을 함께 표시한다.
|
||||
- 단, 일반 운영자는 최고 관리자 계정의 프로필 이미지/회원 정보/비밀번호/삭제 버튼을 사용할 수 없고, 최고 관리자만 다른 관리자 권한을 변경할 수 있다.
|
||||
- 관리자 회원 정보 수정은 운영상 필요한 경우 예약어 닉네임도 저장할 수 있지만, 일반 회원가입과 개인 프로필 수정에서는 운영자 사칭성 예약어 닉네임을 계속 차단한다.
|
||||
- 회원 관리 카드에는 아바타, 작성 티어표 수, 팔로워 수, 받은 즐겨찾기 수, 최근 콘텐츠 활동, 마지막 접속일을 함께 표시한다.
|
||||
- 운영자는 회원 목록을 작성 티어표 수뿐 아니라 팔로워 수와 받은 즐겨찾기 수 기준으로도 정렬할 수 있어, 핵심 작성자를 더 빠르게 찾을 수 있다.
|
||||
- 마지막 접속일은 로그인/세션 확인 기준, 최근 콘텐츠 활동은 작성한 티어표의 마지막 수정일 기준으로 분리해서 보여준다. 따라서 장기 미접속 계정 정리 판단은 마지막 접속일을 우선 사용하고, 콘텐츠 기여가 최근인지 볼 때는 최근 콘텐츠 활동을 사용한다.
|
||||
- 회원 카드의 `프로필 보기` 버튼은 해당 회원의 `/users/:userId` 공개 프로필 화면으로 이동해, 팔로워/공개 티어표 현황을 관리자 화면 밖에서도 바로 확인할 수 있게 한다.
|
||||
- 회원 비밀번호를 운영자가 임의로 덮어쓰는 기능은 비상 상황용 API로만 유지하고, 일반 회원 관리 카드에서는 비밀번호 초기화 버튼과 모달을 숨긴다. 평소 사용자 비밀번호 변경은 이메일 재설정 메일과 설정 화면 직접 변경을 우선 사용한다.
|
||||
|
||||
## 티어표 접근 메모
|
||||
- `new` 작성 경로는 로그인한 사용자만 진입할 수 있다.
|
||||
- 비로그인 사용자는 공개된 티어표를 열람만 할 수 있고, 편집 UI와 저장 동작은 비활성화된다.
|
||||
- 공유 링크로 여는 `preview=1` 화면은 `뷰어 모드`로 취급하며, 드래그/행열 편집/저장 같은 편집 UI 없이 완성본만 렌더링한다.
|
||||
- 비로그인 사용자나 작성자 본인이 아닌 로그인 사용자는 저장된 티어표를 기본적으로 뷰어 모드로 열람하며, 일반 편집 URL로 직접 진입해도 소유자가 아니면 `preview=1` 주소로 자동 전환된다.
|
||||
- 비로그인 사용자도 뷰어 모드 우측 레일의 `공유하기` 버튼으로 현재 공유 링크를 복사할 수 있다.
|
||||
- 로그인한 사용자는 뷰어 모드 우측 레일에서 저장된 티어표를 복사할 수 있고, 타인 티어표면 `내 티어표로 복사`, 본인 티어표면 `복사본 만들기` 문구를 사용한다. 작성자 본인은 `수정 모드로 전환`도 사용할 수 있다.
|
||||
- 작성자 본인이 일반 편집 화면에서 저장된 본인 티어표를 보고 있을 때는 우측 패널의 `뷰어 모드로 보기`로 공유 화면 형태를 바로 확인할 수 있다.
|
||||
- 편집/뷰어 우측 패널의 `작성자 프로필 보기`로 해당 작성자의 공개 프로필과 공개 티어표 목록을 열 수 있고, 로그인 상태에서는 작성자 프로필에서 팔로우/언팔로우를 전환할 수 있다.
|
||||
- `/users/:userId` 공개 프로필 화면 상단 헤더는 고정 제목 `사용자 프로필`과 안내 문구를 보여주고, 실제 닉네임/아바타는 본문 프로필 카드에서만 표시한다. 이메일 앞부분에서 파생된 `@accountName`은 사용자가 직접 설정한 핸들이 아니므로 프로필 UI에 노출하지 않는다.
|
||||
- 같은 `TierEditorView` 안에서 `topicId / tierListId / preview` 라우트 값만 바뀌어도 화면이 이전 티어표 데이터에 남지 않도록, 라우트 변경마다 에디터 상태를 다시 로드한다.
|
||||
- 복사한 티어표 상단의 `원본` 링크를 누르면 원본 티어표로 이동하며, 편집 모드에서 저장하지 않은 변경이 있으면 이동 전에 `저장 없이 이동` 확인 모달을 먼저 띄운다.
|
||||
- 본인 티어표도 저장된 상태라면 편집/뷰어 우측 패널에서 복사본을 만들 수 있고, 편집 중 저장하지 않은 수정이 남아 있으면 복사 직전에 현재 수정본을 먼저 저장해 최신 상태 기준 복사본을 만든다.
|
||||
- 비공개 티어표라도 관리자는 편집 화면에서 완성본을 열람할 수 있다.
|
||||
- 공개 티어표는 목록과 상세 화면에서 즐겨찾기 토글 및 개수를 함께 표시한다.
|
||||
- 카드형 목록에서는 즐겨찾기 수/상태만 표시하고, 실제 토글은 상세 화면에서 처리한다.
|
||||
- 공개 티어표 목록은 현재 게임 기준으로 제목/작성자 검색을 지원한다.
|
||||
- 주제별 공개 티어표 화면은 관리자 추천글을 상단 `추천 티어표` 섹션으로 먼저 보여주고, 일반 공개 목록은 아래 `전체 공개 티어표` 섹션으로 분리해 중복 없이 렌더링한다. 추천 섹션은 최대 16개까지 표시한다.
|
||||
- `내 즐겨찾기` 화면에서는 즐겨찾기한 순, 최신 업데이트순, 인기순 정렬을 제공한다.
|
||||
- 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다.
|
||||
- 사용자가 직접 추가한 커스텀 아이템 이름은 편집 화면 우측 목록에서 정리할 수 있고, 저장 시 원본 커스텀 아이템 라벨과 함께 동기화된다.
|
||||
- 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다.
|
||||
- 티어 행에 넣은 아이템은 작은 제거 버튼으로 다시 우측 아이템 풀로 되돌릴 수 있다.
|
||||
- 편집 가능한 티어표에서는 아이템을 드래그로 옮길 수 있고, 아이템을 클릭해 선택한 뒤 원하는 셀이나 아이템 풀 빈 영역을 클릭하는 방식으로도 위치를 바꿀 수 있다.
|
||||
- 클릭 배치 모드에서 선택된 아이템은 파란 포커스 테두리로 표시하며, 드래그를 시작하면 선택 상태를 해제하고 드래그 직후 짧은 클릭 입력은 무시해 드래그/클릭 조작이 섞이지 않게 한다.
|
||||
- 보드 칸이나 미사용 아이템 풀의 아이템을 우클릭하면 `아이템 복제` 메뉴가 열리고, 실행 시 같은 이미지/이름/출처를 가진 새 아이템 인스턴스를 미사용 풀 맨 앞에 추가한다. 복제본은 `dup-...` 형태의 새 ID를 쓰므로 원본과 복제본을 서로 다른 칸에 동시에 배치할 수 있다.
|
||||
- 기존 저장 티어표/복사본을 수정 가능한 상태로 다시 열 때는 저장본의 그룹/풀을 먼저 복원하고, 이후 현재 템플릿에 새로 추가된 기본 아이템만 미사용 풀 끝에 병합한다.
|
||||
- 관리자 템플릿에서 삭제된 기본 아이템이라도 이미 저장된 티어표의 그룹/풀에 포함되어 있던 항목은 자동 제거하지 않고 그대로 보존한다.
|
||||
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
|
||||
- 제목이 비어 있는 상태로 저장하면 내부 제목은 현재 게임명을 기본값으로 사용한다.
|
||||
- 제목 입력이 비어 있는 동안에는 무분별한 도배 방지를 위한 관리자 임의 삭제 가능성 안내 문구를 표시한다.
|
||||
@@ -196,7 +310,8 @@
|
||||
- 티어표에 썸네일을 직접 지정하지 않으면 저장 시 대표 아이템 이미지 하나를 기본 썸네일로 자동 선택한다.
|
||||
- 편집 화면 상단 헤더는 좌측 제목/설명, 우측 썸네일 카드 구조를 사용하며 모바일에서는 한 열로 접힌다.
|
||||
- 티어표 편집 화면의 우측 패널은 공통 `rightRail`의 `localRightRailRoot`에 직접 section들을 쌓는 구조이며, 별도 외곽 래퍼 카드 없이 공통 오른쪽 컬럼 문법을 그대로 따른다.
|
||||
- 공통 앱 셸은 좌측/중앙/우측 컬럼마다 높이 `56px`의 상단 헤더 블록을 유지하며, 중앙 헤더에는 고정 사이트 타이틀 `Tier Maker by zenn`을 표시한다.
|
||||
- 뷰어 모드 우측 패널도 같은 `localRightRailRoot`를 사용하며, 상단에는 광고 블록을, 하단에는 공유/복사/수정 전환 액션 카드를 배치한다.
|
||||
- 공통 앱 셸은 좌측/중앙/우측 컬럼마다 높이 `56px`의 상단 헤더 블록을 유지하며, 중앙 헤더에는 사이트 타이틀 `Tier Maker`와 현재 서비스 설명을 표시한다.
|
||||
- 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다.
|
||||
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
|
||||
- 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다.
|
||||
@@ -205,11 +320,14 @@
|
||||
- 저장/삭제/가져오기 같은 사용자 행동 피드백은 전역 우측 상단 토스트로 표시한다.
|
||||
- 전역 토스트는 동일 메시지/타입이 연속 발생하면 하나로 합쳐 카운트를 올리고, 종료 시 짧은 페이드아웃 애니메이션을 사용한다.
|
||||
- 홈 게임 목록은 관리자 상단 고정 순서가 있으면 그 순서를 먼저 적용하고, 그 외 게임은 최근 생성순으로 뒤에 이어진다.
|
||||
- 홈 주제 템플릿 목록의 실제 정렬 우선순위는 `즐겨찾기 여부 → 관리자 수동 순서(displayRank) → 최신 생성순(createdAt DESC) → 이름순`이다.
|
||||
- `커스텀 티어표 만들기`는 카드가 아니라 홈 우측 상단 버튼으로 진입한다.
|
||||
|
||||
## 업로드 제한 메모
|
||||
- 프로필 아바타 업로드는 파일당 최대 `3MB`다.
|
||||
- 관리자 게임 썸네일/기본 아이템 업로드와 사용자 커스텀 이미지 업로드는 파일당 최대 `6MB`다.
|
||||
- 관리자 템플릿 썸네일/기본 아이템 업로드는 파일당 최대 `20MB`다.
|
||||
- 사용자 커스텀 이미지 업로드는 파일당 최대 `6MB`다.
|
||||
- 운영 프런트 Nginx는 다중 이미지 업로드 한 번의 요청 본문을 최대 `1024MB`까지 허용한다.
|
||||
- 현재는 업로드 전에 이미지 리사이즈/압축을 하지 않고 원본 파일을 그대로 저장한다.
|
||||
|
||||
## 운영 환경 변수
|
||||
@@ -227,6 +345,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)이다.
|
||||
|
||||
195
docs/todo.md
@@ -1,23 +1,204 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 단기 확인
|
||||
- `v1.4.67`에서 같은 `src`가 프로필 아바타와 템플릿/사용자 아이템으로 동시에 쓰여도 자산 카드를 유지하도록 바꿨으므로, 운영 관리자 화면의 `전체 이미지`와 `프로필 이미지` 필터에서 실제 아바타가 보이고 상세 모달의 공유 참조 목록도 자연스럽게 읽히는지 QA한다.
|
||||
- 아이템 우클릭 복제 기능을 추가했으므로, 템플릿 아이템 복제/커스텀 아이템 복제/이미 보드에 배치된 아이템 복제 각각에서 복제본이 미사용 풀 맨 앞에 생기고 원본과 복제본을 서로 다른 칸에 동시에 둘 수 있는지 QA한다.
|
||||
- 복제본은 `dup-...` 새 ID로 저장되므로, 저장 후 재진입/티어표 복사본 생성/뷰어 모드 열람에서도 복제본이 그대로 유지되는지와, 템플릿 업데이트 요청에 복제된 커스텀 아이템이 포함될 때 운영상 이상이 없는지 확인한다.
|
||||
- `v1.4.62`에서 NAS 배포 문서에 운영 DB 완전 초기화 절차를 추가했으므로, 실제 NAS에서 `git pull → docker compose ... down -v → up -d --build` 순서로 재배포했을 때 빈 DB가 현재 스키마로 다시 올라오고 `freeform`만 생성되는지 확인한다.
|
||||
- `docker volume rm tier-maker_tmaker_mariadb_data` 방식은 프로젝트 디렉터리명에 따라 실제 볼륨 이름이 달라질 수 있으므로, 운영 NAS에서는 먼저 `docker volume ls | grep tmaker`로 이름을 확인한 뒤 문서 명령이 그대로 맞는지 점검한다.
|
||||
- `v1.4.61`에서 템플릿 공개 주소를 `slug`로 분리했으므로, 홈 카드/주제 상세/나의 티어표/즐겨찾기/검색 결과/팔로우 피드/사용자 프로필에서 열리는 URL이 `/topics/:slug`, `/editor/:slug/...` 형태로 바뀌고, 실제 화면 내용도 같은 주제 템플릿으로 정확히 열리는지 QA한다.
|
||||
- 관리자 템플릿 생성/설정은 이제 내부 ID가 아니라 `slug + 이름`만 입력하므로, 새 템플릿 생성, 기존 템플릿 이름/slug 저장, 중복 slug 입력, 대문자/특수문자 slug 입력, 공개/비공개 토글, 썸네일/기본 아이템 관리가 모두 같은 템플릿에 정상 반영되는지 확인한다.
|
||||
- 신규 빈 DB 초기화 시 `topics`에 `freeform` 한 건만 생성되고 `example-topic`, `another-topic` 같은 예시 템플릿이 더 이상 자동으로 생기지 않는지 운영/로컬 재배포 후 확인한다.
|
||||
- `v1.4.60`에서 추가한 `npm --prefix backend run images:shard-assets`를 로컬/운영에 적용할 때는 먼저 백업을 확보한 뒤 실행하고, 평면 `/uploads/assets/<파일명>.webp` 파일이 샤딩 폴더로 이동하면서 `image_assets.src`와 각 참조 컬럼/JSON이 모두 새 경로로 바뀌었는지 확인한다.
|
||||
- `v1.4.59`에서 `thumbnail/avatar` 필터를 실제 DB 참조 역할 기준으로 다시 판별하도록 바꿨으므로, 최근 업로드처럼 `/uploads/assets/<파일명>.webp` 또는 `/uploads/assets/<앞2글자>/<파일명>.webp` 경로여도 썸네일 이미지/프로필 이미지 필터에서 빠지지 않는지 확인한다.
|
||||
- 신규 업로드 이미지는 `/uploads/assets/<앞2글자>/<파일명>.webp`로 저장되므로, 템플릿 썸네일/티어표 썸네일/프로필 아바타/아이템 업로드를 각각 새로 올린 뒤 실제 파일이 샤딩 폴더에 생성되고, 브라우저 표시·삭제·중복 재사용이 모두 기존처럼 동작하는지 QA한다.
|
||||
- 기존 `/uploads/assets/<파일명>.webp` 평면 경로는 그대로 유지되므로, 예전에 만든 티어표 썸네일과 아이템 이미지가 새 저장 구조 변경 후에도 깨지지 않는지 확인한다.
|
||||
- `v1.4.58`에서 작성자 프로필 상단 헤더를 `사용자 프로필` 공통 제목으로 바꾸고 `@accountName` 노출을 뺐으므로, `/users/:userId`에서 상단 문구와 본문 프로필 카드가 중복되지 않고 닉네임/아바타/팔로우 버튼만 자연스럽게 읽히는지 확인한다.
|
||||
- `v1.4.57`에서 관리자 아이템 필터 순서를 `전체 이미지 → 아이템(템플릿 + 사용자) → 템플릿 아이템 → 사용자 아이템 → 썸네일 이미지 → 프로필 이미지 → 미사용 아이템`으로 바꿨으므로, 우측 셀렉트 순서와 실제 필터링 결과가 같은 의미로 동작하는지 QA한다.
|
||||
- `썸네일 이미지` 필터에서는 `/uploads/assets/tierlists`, `/uploads/assets/topics`만 모이고, `프로필 이미지` 필터에서는 `/uploads/assets/avatars`만 모이며, 각 카드 배지가 `썸네일 이미지 / 프로필 아바타`로 구분되는지 확인한다.
|
||||
- `미사용 아이템` 필터는 사용자 아이템 중 저장 티어표 사용 횟수와 템플릿 연결이 모두 0인 항목만 보여주고, 계정 탈퇴로 이미 `custom_items` 레코드가 삭제된 항목이 따로 남지 않는지 확인한다.
|
||||
- `v1.4.56`에서 아이템 관리 기본 필터를 `아이템만 (템플릿+사용자)`로 바꿨으므로, 관리자 화면 첫 진입 시 프로필 아바타/티어표 썸네일 같은 1회성 자산이 기본 목록에서 빠지고 실제 템플릿/사용자 아이템만 보이는지 확인한다.
|
||||
- 보관 이미지 자산도 이름 변경, 템플릿에 추가, 개별 삭제가 기존처럼 동작하는지 확인한다.
|
||||
- 아이템 관리 상단 통계의 `미사용 아이템` 수치는 프로필/썸네일 자산을 포함하지 않고, 실제 사용자 아이템 중 사용 횟수와 템플릿 연결이 모두 0인 항목만 세는지 확인한다.
|
||||
- `v1.4.55`에서 회원 카드의 `최근 활동`을 `최근 콘텐츠 활동`으로 바꾸고 `마지막 접속일`을 따로 추가했으므로, 티어표를 수정하지 않고 로그인만 한 계정은 마지막 접속일만 갱신되고 최근 콘텐츠 활동은 유지되는지, 반대로 로그인 없이 과거 티어표만 있던 계정은 두 값이 다르게 보이는지 QA한다.
|
||||
- `/api/auth/me`에서도 `last_login_at`을 10분 단위 이상 간격으로만 갱신하도록 넣었으므로, 새로고침을 반복해도 과도한 DB 쓰기가 생기지 않으면서 실제 재접속 후에는 마지막 접속일이 자연스럽게 갱신되는지 확인한다.
|
||||
- 관리자 회원 목록의 `마지막 접속순` 정렬과 회원 카드의 `프로필 보기` 버튼이 정상 동작하고, 버튼 클릭 시 해당 회원의 `/users/:userId` 공개 프로필 화면으로 이동하는지 확인한다.
|
||||
- `v1.4.54`에서 관리자 전체 티어표 카드에 즐겨찾기 수와 인기순 정렬/최소 즐겨찾기 필터를 붙였으므로, 즐겨찾기 많은 순으로 바꿨을 때 실제 받은 즐겨찾기 수가 큰 글부터 보이고 최소값을 올리면 추천 후보만 남는지 확인한다.
|
||||
- 관리자 전체 티어표 통계 카드도 최소 즐겨찾기 필터가 적용된 범위 기준으로 `전체/추천/공개/비공개` 숫자가 바뀌는지 QA한다.
|
||||
- `v1.4.54`에서 회원 관리 카드에 팔로워 수와 받은 즐겨찾기 수를 추가했으므로, 팔로워 많은 순/받은 즐겨찾기 많은 순 정렬이 실제 운영 데이터 순서와 맞고 최고 관리자 보호 로직도 그대로 유지되는지 확인한다.
|
||||
- 관리자 회원 카드에서 `비밀번호 초기화` 버튼과 모달을 숨겼으므로, 일반 운영 동선에서는 비밀번호 직접 조작 UI가 보이지 않고 기존 회원 정보 수정/삭제/썸네일 변경은 그대로 동작하는지 확인한다.
|
||||
- `v1.4.53`에서 본인 티어표 복사 버튼을 다시 열었으므로, 작성자 본인 편집 모드와 뷰어 모드 모두에서 `복사본 만들기`가 보이고, 복사 후 새 복사본 화면으로 실제 이동하는지 확인한다.
|
||||
- 본인 티어표를 수정한 뒤 저장하지 않은 상태로 `복사본 만들기`를 누르면 복사 직전에 원본이 먼저 저장되고, 새 복사본이 방금 수정한 최신 내용 기준으로 생성되는지 QA한다.
|
||||
- `/users/:userId` 작성자 프로필에서 비로그인 사용자는 팔로우 버튼이 안 보이고, 로그인 사용자는 타인 프로필에서 `팔로우 / 팔로잉` 전환과 팔로워 수 갱신이 정상이며, 자기 프로필에서는 팔로우 버튼이 숨겨지는지 확인한다.
|
||||
- `/following` 팔로우 피드는 팔로우한 작성자의 공개 티어표만 최신 업데이트순으로 보이고, 비로그인 진입 시 `/login?redirect=/following`으로 이동하며, 검색어로 제목/주제/작성자를 필터링할 수 있는지 확인한다.
|
||||
- 티어표 편집/뷰어 우측 패널의 `작성자 프로필 보기`가 현재 티어표 작성자 프로필로 정확히 이동하고, 복사본에서는 복사본 작성자 자신 프로필로, 원본 링크는 기존처럼 원본 티어표로 이동하는지 함께 QA한다.
|
||||
- `v1.4.51`에서 주제별 공개 목록을 `추천 티어표 / 전체 공개 티어표`로 분리했으므로, 추천 지정된 티어표가 상단 강조 섹션에만 나오고 아래 일반 목록에는 중복되지 않는지, 추천 해제 즉시 아래 일반 목록으로 내려가는지 확인한다.
|
||||
- 관리자 `전체 티어표 관리`에서 공개 글은 `추천 지정 / 추천 해제`가 정상 동작하고, 비공개 글은 추천 지정 버튼이 비활성화되며, 추천글을 비공개로 바꾸면 추천 상태가 자동 해제되는지 QA한다.
|
||||
- 추천 섹션은 최대 16개까지만 보여주도록 잘라두었으므로, 17개 이상 추천 지정 시 최근 지정순과 좋아요 수 보조 정렬이 기대대로 적용되는지 한 번 더 확인한다.
|
||||
- `v1.4.50`에서 설정 화면을 좌우 2열 카드형으로 나눴으므로, 데스크톱 폭에서는 프로필 정보가 왼쪽, 비밀번호 변경이 오른쪽에 나란히 보이고, 모바일/좁은 폭에서는 두 카드가 자연스럽게 위아래로 쌓이는지 확인한다.
|
||||
- `v1.4.49`에서 설정 화면에 비밀번호 변경 섹션을 추가했으므로, 현재 비밀번호가 틀린 경우 `현재 비밀번호가 일치하지 않아요.`, 새 비밀번호 확인이 다른 경우 `비밀번호 확인이 일치하지 않아요.`, 성공 시 `비밀번호를 변경했어요.` 토스트가 각각 정확히 뜨는지 확인한다.
|
||||
- 설정 화면 닉네임 저장도 중복/예약어 에러를 구체적으로 보여주도록 바꿨으므로, 이미 사용 중인 닉네임과 예약어 닉네임을 각각 넣었을 때 서버 문제처럼 보이지 않고 원인 문구가 정확히 뜨는지 QA한다.
|
||||
- 로그인한 상태로 비밀번호 재설정 메일의 `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한다.
|
||||
- `v1.4.32`에서 파일명·composable·관리자 클래스명·백엔드 헬퍼 함수명까지 `topic/template` 기준으로 끝까지 정리했으므로, 다음 실제 QA는 기능 동작 확인에 집중하고 이름층 회귀는 별도 체크만 하면 된다.
|
||||
- 현재 `backend/src`, `frontend/src`, `backend/scripts`, `backend/index.js` 기준 `game/Game` 검색은 0건이므로, 이후 남는 확인 작업은 서비스 동작과 배포 환경 쪽에만 집중한다.
|
||||
- `v1.4.31`에서 `/games` redirect와 legacy DB 마이그레이션까지 제거했으므로, 실제 QA에서는 오직 현재 주소(`/topics`, `/admin/templates`)와 새 DB 기준 흐름만 집중적으로 확인하면 된다.
|
||||
- 현재 `backend/src`, `frontend/src` 기준 `game` 검색은 0건이므로, 이후 남는 확인 작업은 기능 QA와 운영 환경 배포 점검 쪽에만 집중한다.
|
||||
- `v1.4.30`에서 빈 로컬 MariaDB 재초기화 검증까지 통과했으므로, 다음 실제 QA에서는 “기존 데이터가 있는 환경”에서 `ensureData()`가 저장 티어표와 템플릿 요청 스냅샷의 legacy origin을 정상 정규화하는지만 추가 확인하면 된다.
|
||||
- 개발 환경 기준으로는 새 DB에서 `legacyTierItems=0`, `legacyRequestItems=0`가 확인됐으므로, 이후에는 `origin: 'game'` 호환 코드를 언제 완전히 제거할지 운영 데이터 기준으로만 판단하면 된다.
|
||||
- `v1.4.29`에서 새 티어표 데이터 기본 origin을 `template`로 바꿨으므로, 저장 후 다시 열기/복사/요청 생성/관리자 가져오기 흐름에서 예전 데이터와 새 데이터가 함께 섞여도 정상 동작하는지 한 번 더 확인한다.
|
||||
- 관리자 아이템 라이브러리 응답 키가 `linkedTemplates`로 정리됐으므로, 사용자 업로드 이미지 삭제 차단과 템플릿 이동 모달이 그대로 정상 동작하는지 확인한다.
|
||||
- 현재 남아 있는 `game`는 레거시 redirect, DB 마이그레이션, 호환용 origin만 남겨둔 상태이므로, `v1.4` QA 후에는 이 레거시 층을 언제 제거할지 별도 마감 판단만 하면 된다.
|
||||
- `v1.4.28`에서 관리자 템플릿 상세 상태와 기본 아이템 정렬 상태 이름을 `template` 기준으로 더 정리했으므로, 관리자 템플릿 선택/공개 전환/기본 아이템 정렬 저장이 그대로 정상인지 한 번 더 확인한다.
|
||||
- 새 템플릿 썸네일/기본 아이템 업로드는 이제 `topics` 디렉터리로 저장되므로, 실제 업로드 후 최적화 작업 분류와 관리자 최근 작업 표시가 자연스럽게 보이는지 확인한다.
|
||||
- 현재 코드 검색에 남는 `game`는 레거시 redirect, DB 마이그레이션, `origin: 'game'` 호환이 중심이므로, 이 층까지 실제로 없앨지 여부는 `v1.4` QA 후 안정성 기준으로 다시 판단한다.
|
||||
- `v1.4.27`에서 관리자 내부 탭/라우트 이름과 DB alias export까지 더 정리했으므로, 관리자 템플릿 탭 이동, 커스텀 아이템에서 템플릿 관리로 점프, 템플릿 요청 확인하기 이동이 모두 정상인지 한 번 더 확인한다.
|
||||
- `v1.4.26`에서 관리자 기본 경로를 `/admin/templates`로 바꾸고 `/api/admin/templates`만 남겼으므로, 관리자 진입/새로고침/뒤로가기와 템플릿 생성·썸네일 업로드·아이템 추가가 모두 정상인지 확인한다.
|
||||
- `v1.4.26`에서 공개 API `/api/games`를 제거했으므로, 실제 서버 재시작 후 홈/주제 상세/티어표 편집기에서 `/api/topics`만으로 모두 정상 동작하는지 확인한다.
|
||||
- 오래된 관리자 주소 `/admin/games`는 redirect만 남겼으므로, 북마크로 직접 진입해도 `/admin/templates`로 자연스럽게 바뀌는지 본다.
|
||||
- `v1.4.25`에서 티어표/요청 응답의 `gameId / gameName` 호환 키를 실제로 제거했으므로, 브라우저에서 홈 목록, 주제 상세, 저장된 티어표 열기, 즐겨찾기, 검색 결과, 관리자 템플릿 요청/전체 티어표 관리가 모두 정상 동작하는지 한 번 더 QA한다.
|
||||
- `v1.4.25`에서 관리자 route query와 편집기 저장/request payload를 `topicId` 기준으로 옮겼으므로, `/admin/templates?topicId=...`, `/admin/tierlists?mode=all&topicId=...`, 티어표 저장, 템플릿 요청, 추가 아이템 가져오기 흐름이 모두 정상인지 확인한다.
|
||||
- 남은 `gameId`는 의도적으로 유지한 레거시 주소 alias(`/games/:gameId`)와 관리자 alias route path뿐이므로, 오래된 외부 링크 진입 후 주소가 새 `topic` 체계로 자연스럽게 정규화되는지만 마지막으로 본다.
|
||||
- `v1.4.24`에서 공개 주제 API와 관리자 템플릿 API의 기본 응답 키를 더 줄였으므로, 실제 브라우저에서 홈 목록, 즐겨찾기 토글, 주제 상세, 티어표 편집기, 관리자 템플릿 공개 전환/생성이 모두 그대로 정상인지 한 번 더 QA한다.
|
||||
- 다음 단계에서는 `mapTierListRow`, `mapTemplateRequestRow`, 관리자 route query, 저장 payload 입력 호환에 남아 있는 `gameId/gameName/sourceGameId/targetGameId`를 끝까지 걷어낼지 최종 결정한다.
|
||||
- `v1.4.23`에서 프런트 `api.js`의 레거시 `game` 별칭 메서드와 티어표 저장/요청 내부 payload를 더 걷어냈으므로, 실제 브라우저에서 저장/복사/템플릿 요청/관리자 요청 카드 표시가 그대로 정상인지 한 번 더 QA한다.
|
||||
- 다음 단계에서는 응답의 `game`, `gameId`, `gameName`, `sourceGameId`, `targetGameId` 호환 키를 실제로 제거할지, 아니면 `v1.4` 마감 후 안정화 기간을 두고 걷어낼지 최종 결정한다.
|
||||
- `v1.4.22`에서 공개 주제 라우트 파일을 `topics.js`로 옮겼으므로, 실제 서버 재기동 후 `/api/topics`와 `/api/games` 호환 경로가 모두 정상 응답하는지 한 번 더 QA한다.
|
||||
- 다음 단계에서는 응답의 `game`, `gameId`, `gameName` 호환 키를 실제로 어느 범위까지 제거할지, 그리고 관리자/티어표 저장 payload에서 남은 `gameId` 입력 호환을 어디까지 유지할지 최종 결정한다.
|
||||
- `v1.4.21`에서 홈/주제 상세/에디터/나의 티어표/즐겨찾기/검색 결과/관리자 템플릿 생성이 `topic/template` 응답 키를 우선 읽도록 바뀌었으므로, 실제 브라우저에서 즐겨찾기 토글과 에디터 이동, 관리자 신규 템플릿 생성이 모두 정상인지 한 번 더 QA한다.
|
||||
- 다음 단계에서는 실제 응답의 `game`, `gameId`, `gameName` 호환 키를 어디까지 남길지, 그리고 `/api/games` 호환 경로와 `games.js` 파일명을 언제 걷어낼지 최종 범위를 정한다.
|
||||
- `v1.4.20`에서 백엔드 `db` export와 공개/관리자 라우트 내부 이름을 `topic/template` 기준으로 정리했으므로, 실제 브라우저와 관리자 화면에서 주제 목록/즐겨찾기/템플릿 생성/요청 반영 흐름이 모두 정상인지 한 번 더 QA한다.
|
||||
- 다음 단계에서는 남아 있는 호환 응답 키 `game`, `gameId`, `gameName`과 레거시 route 파일명 `games.js`를 어디까지 실제 `topic` 이름으로 마감할지 범위를 결정한다.
|
||||
- `v1.4.19`에서 템플릿 기본 아이템 삭제는 기존 저장 티어표를 보존하도록 정책이 바뀌었으므로, 실제 운영 데이터에서 삭제 후 예전 티어표의 배치/대기풀이 그대로 유지되는지와 새 티어표 생성 시에만 아이템이 빠지는지 한 번 더 QA한다.
|
||||
- `v1.4.19`에서 삭제 전 영향 개수 경고를 붙였으므로, 공개/비공개 티어표가 섞인 템플릿에서 숫자가 기대대로 보이는지와 삭제 취소/확정 후 스크롤 위치가 안정적으로 유지되는지 한 번 더 QA한다.
|
||||
- `v1.4.19`에서 템플릿 썸네일 등록 아이콘은 썸네일이 있을 때 숨기도록 정리했으므로, 썸네일 있음/없음 상태 전환과 드래그 오버 활성 상태에서 안내 문구가 겹치지 않는지 한 번 더 QA한다.
|
||||
- `v1.4.18`에서 관리자 템플릿 요청 카드 썸네일 클릭을 브라우저 기본 새 창 열기로 정리했으므로, 요청 썸네일 클릭 시 오류 없이 새 탭이 열리고 `전체 티어표 관리` 썸네일 모달 동작과도 섞이지 않는지 한 번 더 QA한다.
|
||||
- `v1.4.17`에서 주제 컬렉션 카드 클릭 시 에디터 진입 무한 루프를 끊었으므로, 새 티어표 만들기/기존 티어표 열기/공유 링크 열기 세 흐름이 모두 정상 진입하는지 한 번 더 QA한다.
|
||||
- `v1.4.16`에서 장애 전용 안내 화면을 붙였으므로, 실제로 `db_init_failed`와 네트워크 차단 상황에서 각각 `서비스 점검 중`, `서버 연결 확인 중` 화면이 기대대로 분기되는지 한 번 더 QA한다.
|
||||
- `v1.4.15`에서 `ensureData()`가 실제 운영 DB 설정으로 `ok`까지 통과한 것은 확인했으므로, 이제는 브라우저에서 `/api/auth/me`, `/api/auth/meta`, `/api/topics` 500이 실제로 사라졌는지와 기존 세션 로그인 흐름이 복구됐는지 한 번 더 QA한다.
|
||||
- `v1.4.14`부터는 DB 마이그레이션이 rename 대신 복사 기반으로 바뀌었으므로, 실제 운영 DB에서 서버 재시작 후 `topics` 계열 테이블과 `tierlists.topic_id`, `template_requests.source_topic_id/target_topic_id`가 기대대로 채워지는지 먼저 확인한다.
|
||||
- 레거시 `/games/...`와 `/editor/:gameId/...`는 redirect로 남겼으므로, 오래된 북마크 진입 후 주소가 `/topics/...`, `/editor/:topicId/...`로 자연스럽게 정규화되는지 한 번 더 QA한다.
|
||||
- `v1.4.13`부터 DB 실명도 `topics / topic_items / favorite_topics / topic_id` 기준으로 옮겼으므로, 기존 운영 DB에서 서버 재시작 후 자동 마이그레이션이 한 번만 자연스럽게 수행되는지 먼저 확인한다.
|
||||
- 백엔드 응답은 현재 `topicId / topicName`과 `gameId / gameName`을 함께 내려주고 있으므로, 다음 단계에서는 실제 프런트/관리자에서 더 이상 `gameId` fallback이 필요 없는 지점을 확인해 호환 키 제거 순서를 정한다.
|
||||
- 티어표 공개 목록, 관리자 전체 티어표 관리, 저장/요청 API는 `topicId`를 우선 받도록 바꿨으므로, 실제 브라우저에서 검색/저장/공유/관리자 필터가 모두 같은 파라미터 체계로 자연스럽게 이어지는지 한 번 더 QA한다.
|
||||
- `/api/topics`, `/api/admin/templates` alias를 연 뒤 프런트 호출도 새 경로로 옮겼으므로, 실제 브라우저에서 주제 목록/즐겨찾기/주제 상세/관리자 템플릿 관리가 모두 같은 세션으로 자연스럽게 동작하는지 한 번 더 QA한다.
|
||||
- 프런트 API 호출부는 `topic/template` 의미 이름으로 옮겼으므로, 다음 단계에서는 `api.js` 안의 레거시 alias를 얼마나 더 유지할지와 백엔드 API 경로를 실제로 바꿀지 범위를 정한다.
|
||||
- 관리자 템플릿/주제 화면과 홈·에디터·즐겨찾기에서 새 API 이름층으로 바뀐 뒤에도 저장, 즐겨찾기, 템플릿 생성, 아이템 정렬 흐름이 자연스러운지 한 번 더 QA한다.
|
||||
- `topicHub / topicId`를 기본 라우트 기준으로 세웠으므로, 기존 `/games/...` 북마크와 새 `/topics/...` 주소 양쪽에서 주제 상세와 에디터 진입이 모두 자연스럽게 이어지는지 한 번 더 QA한다.
|
||||
- 경로 헬퍼를 사용자 주요 화면에 연결했으므로, 로그인 리다이렉트·공유 프리뷰·복사본 이동·주제 복귀 흐름이 실제 브라우저에서 모두 같은 주소 체계로 자연스럽게 이어지는지 한 번 더 QA한다.
|
||||
- 다음 단계에서는 `router/index.js`의 `gameHub`, `route.params.gameId`, `api.getGame`처럼 남아 있는 프런트 내부 이름도 어디까지 `topic/template` 의미로 감쌀지 범위를 더 좁힌다.
|
||||
- 주제 상세 화면 제목은 ID fallback 대신 로딩 문구를 쓰도록 바꿨으므로, 직접 진입·뒤로가기·다른 주제로 연속 이동할 때 헤더가 깜빡이거나 이전 제목을 잠깐 유지하지 않는지 한 번 더 QA한다.
|
||||
- 검색 결과 화면도 `pageHead` 구조로 맞췄으므로, 주요 목록 화면들 간 상단 여백과 타이포 리듬이 자연스러운지 한 번 더 비교 QA한다.
|
||||
- 주제 상세 컬렉션 화면은 `pageHead` 공통 레이아웃과 `/topics` 기본 경로로 옮겼으므로, 직접 진입·뒤로가기·검색 후 재진입 시 주소와 헤더 흐름이 자연스러운지 한 번 더 QA한다.
|
||||
- `/topics/:gameId`를 기본 경로로 세우고 `/games/:gameId`는 alias로 남겼으므로, 다음 단계에서는 에디터/검색/공유 흐름에서 어떤 링크를 새 경로로 더 전환할지 범위를 정한다.
|
||||
- 신규 DB 기준 `template_requests` 스키마 누락은 보정했으므로, 운영 NAS에서 `down -v` 후 재배포했을 때 관리자 `/admin/featured`, `/admin/tierlists`, `/admin/users` 진입과 템플릿 요청/이미지 통계 API가 모두 500 없이 동작하는지 한 번 더 QA한다.
|
||||
- 브라우저 탭 파비콘은 다시 인라인 SVG로 돌려 정적 `/favicon.svg`, `/favicon-32x32.png` 요청 자체를 끊었으므로, 최신 배포 후 강력 새로고침 기준으로 favicon 403 로그가 실제로 사라졌는지 한 번 더 QA한다.
|
||||
- 우측 레일 Teleport 대상 DOM을 상시 유지하는 방식으로 바꿨으므로, 관리자 `/admin/...` → 설정 `/profile` → 홈 `/`처럼 전용 우측 레일과 일반 우측 레일을 오가는 라우트 전환에서 콘솔 오류가 더 이상 재현되지 않는지 운영 브라우저로 한 번 더 QA한다.
|
||||
- 내부 리네이밍 2단계로 관리자 `selectedTemplate / templates / loadTemplate / refreshTemplates` 묶음까지 정리했으므로, 다음 단계에서는 `/games/:gameId` 라우트와 프런트 API 호출부를 어디까지 `topic/template` 의미로 감쌀지 범위를 먼저 정리한다.
|
||||
- 화면/문서 층의 용어 정리는 거의 마무리됐으므로, 다음 단계에서는 내부 `gameId / games` 모델을 실제로 옮길지, 아니면 라우트 alias/리다이렉트부터 둘지 점진 전환 순서를 정한다.
|
||||
- 관리자 화면까지 포함한 사용자 노출 `게임` 문구를 3차까지 정리했으므로, 실제 운영 흐름에서 뜨는 확인창/토스트/선택 모달까지 표현이 자연스러운지 한 번 더 QA한다.
|
||||
- 왼쪽 사이드 메뉴와 각 화면 타이틀을 한글 기준으로 맞췄으므로, 홈/나의 티어표/즐겨찾기/설정 진입 시 실제 체감이 자연스럽고 중복 표현이 어색하지 않은지 한 번 더 QA한다.
|
||||
- 사용자 노출 용어는 `주제 / 템플릿` 기준으로 계속 걷어내고 있으므로, 홈/주제 화면/관리자 템플릿 관리에서 어색하게 남은 `게임` 문구가 없는지 한 번 더 QA한다.
|
||||
- 내부 모델명은 아직 `game`을 유지하므로, 다음 단계에서는 문서와 보조 화면 문구를 더 정리할지, 아니면 내부 리네이밍 계획을 따로 잡을지 결정한다.
|
||||
- 주제 목록과 티어표 카드 썸네일은 기본 이미지 드래그를 막았으므로, 데스크톱 브라우저에서 클릭/드래그 시 원본 이미지 프리뷰가 더 이상 뜨지 않는지 한 번 더 QA한다.
|
||||
- 왼쪽 레일 활성 배경은 공용 인디케이터가 이동하는 방식으로 바뀌었으므로, 홈/내 티어표/즐겨찾기/설정 전환과 레일 접힘 상태 양쪽에서 위치 보정이 자연스러운지 한 번 더 QA한다.
|
||||
- 로그인 화면 상단 토글은 이동형 인디케이터로 바뀌었으므로, 데스크톱과 모바일에서 `로그인 / 회원가입` 전환 애니메이션이 어색하지 않고 포커스/클릭 상태도 자연스러운지 한 번 더 QA한다.
|
||||
- 관리자 카드 설명 줄임은 `line-clamp` 표준 속성까지 함께 선언했으므로, 실제 브라우저별 표시 차이가 없는지 한 번 더 QA한다.
|
||||
- 사용하지 않는 기본 자산을 정리했으므로, 배포본에서 누락 참조 없이 파비콘/공유 썸네일/좌측 레일 아이콘이 정상 노출되는지 한 번 더 QA한다.
|
||||
- 공유 썸네일 `og-card`는 이번에 이미지 수정본까지 함께 반영했으므로, 실제 메신저 미리보기에서 최신 그림이 캐시 갱신 후 정상 노출되는지 한 번 더 QA한다.
|
||||
- 홈페이지 공유 메타와 새 `og-card.png`는 이번에 처음 붙였으므로, 카카오톡/디스코드/슬랙/모바일 브라우저에서 제목·설명·썸네일이 기대대로 보이는지 한 번 더 QA한다.
|
||||
- 파비콘은 `svg + 32px png + apple-touch-icon` 조합으로 정리했으므로, 데스크톱 브라우저 탭과 iOS 홈 화면 추가에서 모두 정상 노출되는지 한 번 더 QA한다.
|
||||
- 티어표 `아이콘 크기`는 이제 저장 데이터로 승격됐으므로, 저장 후 재진입/프리뷰/복사본 생성에서 같은 크기가 유지되는지 한 번 더 QA한다.
|
||||
- 티어표 편집/프리뷰 모바일 열 배지는 새로 붙였으므로, 실제 좁은 화면에서 칸 상단 배지와 아이템 썸네일이 겹치지 않고 열 구분이 자연스러운지 한 번 더 QA한다.
|
||||
- 모바일 열 배지는 같은 구간에서 상단 열 제목을 숨기도록 다시 맞췄으므로, 720px 안팎뿐 아니라 980px 이하 전 구간에서 중복 표기 없이 자연스러운지 한 번 더 QA한다.
|
||||
- 모바일 티어표 편집 레이아웃은 행 라벨 폭을 다시 덮어쓰던 규칙을 걷어냈으므로, 실제 980px 이하 구간에서 행 라벨이 과하게 넓지 않고 칸 폭을 충분히 남기는지 한 번 더 QA한다.
|
||||
- 프리뷰 완성본 하단 메타는 새로 붙였으므로, 작성자/저장 시각이 공개 열람 화면과 이미지 다운로드 결과 기준에서 모두 자연스럽게 읽히는지 한 번 더 QA한다.
|
||||
- 관리자 템플릿 요청 카드는 썸네일 클릭이 새창 열기 역할로 바뀌었으므로, 썸네일 클릭과 `확인하기` 액션이 서로 헷갈리지 않는지 한 번 더 QA한다.
|
||||
- 티어표 만들기 화면의 `공유하기`는 저장된 티어표에서만 노출되므로, 저장 직후/수정 중/복사본/읽기 전용 상태 각각에서 노출 조건과 클립보드 복사가 자연스러운지 한 번 더 QA한다.
|
||||
- 우측 카피라이트는 이제 공통 오른쪽 레일 footer이므로, 관리자 화면뿐 아니라 홈/프로필 등 오른쪽 사이드가 보이는 화면에서도 같은 최하단 위치에 유지되는지 한 번 더 QA한다.
|
||||
- 왼쪽 레일 축소 상태의 하단 액션 아이콘은 홈과 주제 화면에서 서로 다른 아이콘을 쓰도록 나눴으므로, 실제로 두 문맥이 한눈에 구분되는지 한 번 더 QA한다.
|
||||
- 왼쪽 레일 축소 상태 최하단의 `티어표 만들기` 아이콘 버튼은 새로 추가했으므로, 홈/주제 화면에서 실제로 같은 위치 감각으로 동작하는지 한 번 더 QA한다.
|
||||
- 관리자 우측 카피라이트 문구는 사이드바 내부 최하단으로 다시 옮겼으므로, 실제 관리자 화면에서 스크롤/창 크기 변화에도 계속 보이는지 한 번 더 QA한다.
|
||||
- 왼쪽 레일 축소 상태는 아이콘 줄 높이를 50px 기준으로 통일했으므로, 실제 데스크톱에서 아바타/검색/메뉴 아이콘이 시각적으로 같은 리듬으로 보이는지 한 번 더 QA한다.
|
||||
- 왼쪽 레일 검색은 이제 항상 주제 템플릿 검색으로 홈으로 이동하므로, 홈이 아닌 화면에서 사이드 검색 후 주제 목록 결과로 자연스럽게 이동하는지 한 번 더 QA한다.
|
||||
- 앱 왼쪽 사이드 레일은 접힘 상태 레이아웃을 다시 손봤으므로, 데스크톱에서 접기/펼치기 반복 시 아바타 영역 높이, 아이콘 중앙 정렬, 검색 버튼 간격, 네비게이션 히트 영역이 모두 자연스러운지 한 번 더 QA한다.
|
||||
- 관리자 우측 사이드바 하단 카피라이트 링크는 새 창 외부 링크로 바꿨으므로, 실제 클릭 시 `zenn.town` 연결과 hover 대비가 자연스러운지 한 번 더 QA한다.
|
||||
- 관리자 아이템 상세 모달은 공통 패딩 예외 처리를 다시 넣었으므로, 대형 상세 모달과 일반 템플릿 선택 모달이 각각 기대한 크기로 보이는지 한 번 더 QA한다.
|
||||
- 아이템 관리 모달의 공용 템플릿 선택기에서는 이미 연결된 템플릿이 비활성화되므로, 실제 운영 데이터에서 중복 연결 방지와 `이미 추가됨` 표시가 기대대로 읽히는지 한 번 더 QA한다.
|
||||
- 공용 템플릿 선택 모달을 아이템 관리 모달에도 붙였으므로, 관리자 `템플릿 관리 / 전체 티어표 관리 / 아이템 관리` 세 흐름에서 같은 검색/선택 UX가 자연스럽게 느껴지는지 한 번 더 QA한다.
|
||||
- 관리자 `/admin/games?gameId=...` 직접 진입과 새로고침에서 선택 템플릿이 정상 복원되고 콘솔 오류가 없는지 한 번 더 QA한다.
|
||||
- 공용 `템플릿 선택` 검색 모달은 새로 붙였으므로, 템플릿 관리 선택/전체 티어표 관리 필터 양쪽에서 검색어 입력, 선택, 해제, 뒤로가기 흐름을 한 번 더 QA한다.
|
||||
- 관리자 `전체 티어표 관리`의 템플릿 필터와 관리 모달은 새로 붙였으므로, 실제 운영 데이터에서 필터 전환 후 공개/비공개 집계, 제목 수정, 비공개 전환, 삭제 흐름이 자연스럽게 이어지는지 한 번 더 QA한다.
|
||||
- 관리자 템플릿 관리 상단의 `전체 / 공개 / 비공개` 티어표 수치와 전체 티어표 관리 상단 집계가 실제 운영 데이터와 맞는지, 공개 전환 직후 숫자가 자연스럽게 갱신되는지 한 번 더 QA한다.
|
||||
- 관리자 아이템 상세 모달은 왼쪽 선택 카드와 오른쪽 상세 본문으로 다시 정리했으므로, 데스크톱/모바일에서 긴 템플릿 목록과 긴 참조 목록이 각각 자연스럽게 스크롤되는지 한 번 더 QA한다.
|
||||
- 관리자 계정으로 `/admin/...`을 직접 새로고침했을 때 홈으로 튕기지 않고 그대로 유지되는지, 실제 세션이 살아 있는 브라우저와 만료된 브라우저 각각에서 한 번 더 QA한다.
|
||||
- 티어표 만들기 화면의 보드 드롭존은 점선/높이/중앙 정렬로 존재감이 커졌으므로, 데스크톱과 모바일에서 파일 선택 버튼과 안내 문구 밀도를 한 번 더 QA한다.
|
||||
- 관리자 썸네일 드롭존과 기본 아이템 추가 드롭존은 에디터 드롭존과 시각 문법을 맞췄으므로, 라이트모드와 좁은 화면에서 아이콘 대비와 배경 밝기가 과하지 않은지 한 번 더 QA한다.
|
||||
- 커스텀 이미지가 많은 티어표를 저장할 때 커스텀 이름 정리 목록과 미배치 아이템 영역 순서가 실제로 안정적으로 유지되는지, 10장 이상 업로드한 상태로 한 번 더 QA한다.
|
||||
- 관리자 이미지 최적화 현황은 최근 작업 라벨을 읽기 쉬운 이름으로 바꿨으므로, 실제 운영 데이터에서 `커스텀 아이템 / 티어표 썸네일 / 게임·템플릿 이미지 / 프로필 아바타` 흐름이 기대와 맞게 찍히는지 한 번 더 QA한다.
|
||||
- 관리자 게임 설정 카드를 본문 상단으로 옮겼으므로, 긴 게임 목록 상태에서 선택-썸네일 교체-공개 전환-삭제 흐름이 스크롤 없이 자연스러운지 한 번 더 QA한다.
|
||||
- `누락 참조 정리`는 실제 파일이 없는 참조만 지우도록 넣었으므로, 운영 데이터에서 실행했을 때 누락 수치가 줄고 대표 썸네일/티어표/템플릿 요청 표시가 기대대로 비워지는지 한 번 더 QA한다.
|
||||
- 아이템 관리의 같은 이미지 참조 요약과 상세 모달은 붙였으므로, 실제 운영 데이터에서 카드 수치와 모달 목록이 기대한 참조 관계와 맞는지 한 번 더 QA한다.
|
||||
|
||||
## 중기 개선
|
||||
- 목록 카드의 작성자 메타를 카드 전체 열기 버튼과 충돌 없이 직접 프로필 링크로 분리하는 후속 UX를 검토한다.
|
||||
- 추천 티어표는 전체 누적 즐겨찾기 기준 정렬/필터부터 붙였으므로, 다음 단계에서는 최근 N일 기준 급상승 추천 후보 필터와 추천 섹션 노출 개수 설정을 관리자 화면에 추가할지 검토한다.
|
||||
- 이메일 인증/비밀번호 재설정 1차 구현이 들어갔으므로, 다음 단계에서는 Gmail 발신 기반이 실제 운영에서 스팸함으로 얼마나 가는지 보고 필요하면 Cloudflare DNS의 SPF/DKIM/DMARC와 도메인 발신 주소 전환을 정리한다.
|
||||
- 구글 계정 로그인은 아직 붙이지 않았으므로, 이메일 인증 안정화 후 Google OAuth 클라이언트를 만들고 일반 이메일 계정과 같은 이메일의 구글 계정을 자동 연결할지 정책을 먼저 결정한다.
|
||||
- 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다.
|
||||
- 관리자 템플릿 요청은 동일 `src` 중복 생성 방지와 `연결된 게임/이미 반영 n개` 표시까지 붙였으므로, 이후에는 요청별 반영 이력(예: 어떤 아이템을 언제 반영했는지)까지 별도로 남길지 검토한다.
|
||||
- 관리자 URL 분리는 시작했으므로, 다음 단계에서는 `AdminView.vue` 단일 대형 파일을 섹션별 뷰/컴포저블로 쪼개고 직접 진입 시 선택 게임/요청 작업 상태 복원 범위도 함께 정리한다.
|
||||
- 관리자 본문 컴포넌트 분리와 `게임/템플릿 요청/회원 관리/아이템 관리/목록 관리` composable 분리는 시작했으므로, 다음 단계에서는 공통 모달 상태를 어느 계층에서 소유할지 정리하고 남은 관리자 유틸 함수를 더 줄인다.
|
||||
- 관리자 화면은 섹션 경로 분리까지 끝났으므로, 다음 단계에서는 `AdminView.vue`를 실제 레이아웃 뷰와 섹션별 라우트 컴포넌트로 더 쪼갤지 결정한다.
|
||||
- 관리자 공통 스타일은 `adminUiScope` 기준으로 다시 묶었으므로, 다음 단계에서는 각 섹션을 별도 파일로 완전히 분리할 때 스타일도 `admin.css` 또는 섹션별 스타일로 옮길지 결정한다.
|
||||
- 관리자 요청 카드 밀도는 줄였으므로, 다음 단계에서는 전체 티어표 카드와 요청 카드의 상단/하단 액션 정렬을 한 번 더 통일할지 비교 QA한다.
|
||||
- 신규 템플릿 요청 썸네일 기본 승계는 붙였으므로, 다음 단계에서는 요청 아이템 반영 후 `처리 완료`까지의 관리자 흐름을 실제 데이터로 한 번 더 QA한다.
|
||||
- 관리자 게임 아이템 순서 저장은 추가됐으므로, 다음 단계에서는 새 아이템 추가 직후 `자동 맨 앞 배치`와 `관리자 수동 고정 순서`의 우선순위를 실제 운영 흐름 기준으로 한 번 더 QA한다.
|
||||
- 관리자 템플릿 요청 미리보기는 실제 완성본 iframe 방식과의 체감 차이를 마지막으로 한 번 더 QA한다.
|
||||
- 라이트모드/다크모드 2차 보정까지 반영했으므로, 남은 작업은 전체 화면을 실제 사용 흐름으로 돌려 보며 대비·명도·아이콘 가독성을 미세하게 QA하는 최종 테마 점검 단계로 가져간다.
|
||||
- 라이트모드 공통 토큰 재정비와 카드/아바타/즐겨찾기 버튼 보정까지 반영했으므로, 다음 QA에서는 로그인/홈/주제 허브/에디터/관리자 순으로 실제 플로우를 돌리며 남은 하드코딩 색과 과한 대비가 없는지 확인한다.
|
||||
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
|
||||
- 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다.
|
||||
- 회원 일괄 작업(다중 선택, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다. 비밀번호는 평소 운영자가 직접 덮어쓰기보다 이메일 재설정 흐름을 우선하므로, 관리자 일괄 비밀번호 초기화는 별도 긴급 대응 정책이 생긴 뒤에만 다시 검토한다.
|
||||
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
|
||||
- 로그인/회원가입/관리자 비밀번호 초기화에 요청 횟수 제한을 추가한다.
|
||||
- 업로드 파일은 MIME 타입뿐 아니라 파일 시그니처 기반 검증까지 확장한다.
|
||||
- production에서 SESSION_SECRET 누락 시 서버가 부팅되지 않도록 강제한다.
|
||||
- helmet 기반 보안 헤더와 업로드 정적 응답 헤더를 정리한다.
|
||||
- 책 아이콘 기반 사용법 모달은 제작 흐름뿐 아니라 복사, 템플릿 업데이트 요청, 새 템플릿 요청까지 확장했으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다.
|
||||
|
||||
- 관리자 아이템 라이브러리에서 동일 이미지(src)를 여러 템플릿이 공유하는 경우, 필요하면 묶어서 보거나 대표 카드로 합쳐 보는 후속 정리 옵션을 검토한다.
|
||||
|
||||
- 라이트모드 최종 QA 시 홈/설정/관리자/에디터를 실제 사용 흐름으로 돌리며, 남아 있는 하드코딩 텍스트 색과 플레이스홀더 배경을 한 번 더 점검한다.
|
||||
|
||||
- 템플릿 기본 아이템 다중 업로드는 8개까지 성공, 9개 이상 한 번에 전송 시 실패하는 사례가 있었으므로 NAS/리버스 프록시의 업로드 body 제한(`client_max_body_size` 등)과 실제 응답 코드를 운영 환경에서 확인한다.
|
||||
- 프리뷰 우측 광고 레일을 붙였으므로, 실제 운영 환경에서 광고가 로드될 때 프리뷰 본문 폭이 과하게 줄지 않는지 데스크톱 기준으로 한 번 더 확인한다.
|
||||
- 관리자 아이템 라이브러리는 보관 자산까지 노출되므로, 이후에는 `활성 템플릿 / 보관 자산` 분리 필터나 그룹 보기까지 검토한다.
|
||||
|
||||
- 가이드 모달과 관리자 아이템 모달은 현재 같은 톤의 큰 셸을 쓰므로, 이후 공통 모달 레이아웃 컴포넌트로 분리할지 검토한다.
|
||||
|
||||
- 관리자 아이템 라이브러리 이름 변경은 템플릿·사용자 업로드·보관 자산까지 모두 가능하므로, 이후에는 일괄 이름 정리나 중복 이름 감지 보조 기능까지 검토한다.
|
||||
- 관리자 템플릿 요청 미리보기는 실제 완성본 모달과 더 가까운 체감이 되도록, 이후에도 보드 여백·행/열 헤더·남은 아이템 밀도를 한 번 더 비교 QA한다.
|
||||
- 관리자 템플릿 요청 카드와 전체 티어표 카드가 같은 문법으로 맞춰졌으므로, 이후에는 라이트모드까지 포함해 두 카드의 썸네일 높이·입력창 밀도·아이템 그리드를 한 번 더 비교 QA한다.
|
||||
- 템플릿 요청 저장 흐름은 저장된 티어표 기준으로 바뀌었으므로, 이후 실제 데이터로 빈 제목 저장 시 자동 생성 제목·요청 버튼 노출 시점·관리자 요청 미리보기 밀도를 한 번 더 비교 QA한다.
|
||||
|
||||
@@ -93,7 +93,41 @@ docker compose --env-file .env.production -f docker-compose.prod.yml up -d --bui
|
||||
- Dockerfile, Nginx 설정, 프런트 소스, 백엔드 소스가 바뀐 경우에는 `--build`를 유지한다.
|
||||
- 단순 재시작만 필요할 때도 있지만, 운영에서는 실수 방지를 위해 `up -d --build`를 기본값으로 두는 편이 안전하다.
|
||||
|
||||
## 3-2. 이번 v0.1.34까지 적용하는 예시
|
||||
## 3-2. 운영 DB/업로드/세션까지 완전 초기화하고 새로 빌드하기
|
||||
- 운영 데이터를 전부 버리고 새 DB로 다시 시작할 때만 사용한다.
|
||||
- 아래 명령은 MariaDB 데이터, 업로드 이미지, 세션 파일 볼륨까지 같이 삭제하므로 실행 전에 정말 초기화해도 되는지 반드시 확인한다.
|
||||
- `.env.production`은 프로젝트 폴더에 그대로 남고, Docker volume 데이터만 제거된다.
|
||||
|
||||
```bash
|
||||
cd /volume1/docker/projects/apps/tier-maker
|
||||
git pull origin main
|
||||
docker compose --env-file .env.production -f docker-compose.prod.yml down -v
|
||||
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
- 이렇게 올리면 백엔드가 빈 MariaDB에 현재 스키마를 새로 만들고, 초기 템플릿은 시스템용 `freeform` 한 건만 생성한다.
|
||||
- `down -v` 후 첫 기동은 MariaDB 초기화 때문에 조금 더 오래 걸릴 수 있으니, 아래 명령으로 상태를 확인한다.
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env.production -f docker-compose.prod.yml ps
|
||||
docker compose --env-file .env.production -f docker-compose.prod.yml logs -f mariadb
|
||||
docker compose --env-file .env.production -f docker-compose.prod.yml logs -f backend
|
||||
```
|
||||
|
||||
## 3-3. DB만 비우고 업로드/세션 볼륨은 유지하기
|
||||
- 이미지 파일과 세션 볼륨은 유지하고 MariaDB 데이터만 새로 시작하고 싶다면 `tmaker_mariadb_data` 볼륨만 지운다.
|
||||
- 이 경우에도 기존 티어표/유저 DB 레코드와 업로드 파일 참조가 끊길 수 있으므로, 현재 운영 데이터를 전부 버리는 전제에서만 사용한다.
|
||||
|
||||
```bash
|
||||
cd /volume1/docker/projects/apps/tier-maker
|
||||
docker compose --env-file .env.production -f docker-compose.prod.yml down
|
||||
docker volume rm tier-maker_tmaker_mariadb_data
|
||||
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
- 만약 볼륨 이름이 다르게 잡혀 있는지 확인하고 싶다면 먼저 `docker volume ls | grep tmaker`로 실제 이름을 확인한 뒤 지운다.
|
||||
|
||||
## 3-4. 이번 최신 main까지 적용하는 예시
|
||||
- 이미 NAS 폴더가 Git clone 상태라면:
|
||||
|
||||
```bash
|
||||
@@ -153,6 +187,8 @@ docker compose --env-file .env.production -f docker-compose.prod.yml down -v
|
||||
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
- 이 명령도 `down -v` 때문에 DB/업로드/세션 볼륨을 모두 삭제한다. 데이터를 유지해야 하는 상황이면 `-v`를 빼고 다시 올린다.
|
||||
|
||||
## 9. 참고
|
||||
- 현재 업로드 이미지는 서버 저장 전에 리사이즈/압축하지 않는다.
|
||||
- 운영 중 원본 이미지가 많이 쌓이면 이후 `sharp` 기반 최적화 단계를 추가하는 것이 좋다.
|
||||
|
||||
508
docs/update.md
@@ -1,5 +1,478 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-03 v1.4.67
|
||||
- 관리자 아이템 관리에서 프로필 아바타가 `전체 이미지`와 `프로필 이미지` 필터에 보이지 않을 수 있던 문제를 수정했다.
|
||||
- 원인은 `image_assets`의 같은 `src`가 템플릿 아이템이나 사용자 아이템에서도 쓰이는 경우, 자산 카드 생성 단계에서 해당 `src`를 무조건 제외하던 필터였다. 이제는 `users.avatar_src`나 각종 썸네일 참조로 실제 사용 중인 자산이면 같은 이미지가 다른 아이템에 재사용되더라도 자산 카드도 함께 유지한다.
|
||||
- 로컬 MariaDB를 새로 만든 뒤 `프로필 아바타`와 사용자 아이템이 같은 `src`를 공유하는 테스트 데이터를 직접 넣고, `listCustomItems({ filterMode: 'avatar' })`와 `filterMode: 'all'` 결과에 프로필 자산 카드가 포함되는 것까지 확인했다.
|
||||
|
||||
## 2026-04-03 v1.4.66
|
||||
- 티어표 편집 화면에서 보드 위 아이템이나 미사용 아이템 풀의 아이템을 우클릭하면 `아이템 복제` 메뉴가 뜨고, 선택한 아이템의 이미지/이름/출처를 유지한 새 복제본을 미사용 풀 맨 앞에 추가하도록 구현했다.
|
||||
- 기존 아이템 ID를 그대로 다시 쓰면 같은 항목을 서로 다른 칸에 동시에 둘 수 없으므로, 복제 시 `dup-...` 새 ID를 발급해 원본과 복제본을 별도 아이템 인스턴스로 저장하도록 정리했다.
|
||||
- 우클릭 메뉴는 메뉴 밖 클릭, 다른 곳 우클릭, 스크롤, 창 포커스 이탈 시 닫히도록 했고, 화면 가장자리에서는 메뉴가 뷰포트 밖으로 나가지 않게 좌표를 보정했다.
|
||||
|
||||
## 2026-04-03 v1.4.62
|
||||
- UGREEN NAS 운영 배포 문서에 `git pull origin main` 후 일반 재빌드하는 절차와, 운영 데이터를 전부 버리고 `docker compose ... down -v`로 MariaDB/업로드/세션 볼륨까지 초기화한 뒤 새로 `up -d --build` 하는 절차를 분리해서 추가했다.
|
||||
- DB만 비우고 싶을 때 `tmaker_mariadb_data` 볼륨만 삭제하는 방법과, 실제 볼륨 이름이 다를 수 있으니 `docker volume ls | grep tmaker`로 먼저 확인하는 안내도 함께 적었다.
|
||||
- 새로 초기화한 운영 DB로 올리면 현재 스키마가 다시 생성되고 시스템 템플릿은 `freeform` 한 건만 들어간다는 점을 배포 문서에 명시했다.
|
||||
|
||||
## 2026-04-03 v1.4.61
|
||||
- 템플릿 공개 URL과 내부 참조를 분리해, `topics.id`는 서버가 자동 생성하는 랜덤 내부 ID로 두고 운영자가 직접 관리하는 공개 주소는 `topics.slug`로 저장하도록 바꿨다.
|
||||
- 공개 주제/에디터 경로는 `slug`를 우선 사용하고, 백엔드는 `/api/topics/:topicId`, `/api/tierlists/public?topicId=...`, 티어표 저장/템플릿 요청의 `topicId` 입력을 `slug` 또는 내부 ID에서 실제 템플릿 레코드로 해석한 뒤 내부 `topic_id`를 저장하도록 정리했다.
|
||||
- 관리자 템플릿 생성 모달과 템플릿 설정 카드에서 내부 ID 대신 `템플릿 이름 + slug`를 입력/수정할 수 있게 바꾸고, `slug` 중복/형식 오류는 `이미 사용 중인 템플릿 slug입니다.`, `slug는 영문 소문자, 숫자, 하이픈만 사용할 수 있어요.`처럼 원인 문구를 분리했다.
|
||||
- 새 DB를 처음 만들 때는 시스템 전용 `freeform` 템플릿만 생성하고, 예전에 기본 시드로 넣던 빈 예시 템플릿 `example-topic`, `another-topic`과 샘플 아이템은 더 이상 자동 생성하지 않도록 제거했다.
|
||||
- 로컬 MariaDB를 한 번 비운 뒤 새 스키마로 `ensureData()`를 실행해, 초기 `topics`가 `[{ id: "freeform", slug: "freeform", name: "직접 티어표 만들기" }]` 한 건만 생성되는 상태까지 확인했다.
|
||||
|
||||
## 2026-04-03 v1.4.60
|
||||
- 샤딩 구조가 생기기 전에 이미 `/uploads/assets/<파일명>.webp`로 평면 저장된 기존 최적화 이미지도 `/uploads/assets/<앞2글자>/<파일명>.webp`로 옮길 수 있도록 일회성 마이그레이션 스크립트 `backend/scripts/migrate-flat-assets-to-sharded.js`를 추가했다.
|
||||
- 이 스크립트는 `backend/uploads/assets` 루트에 남아 있는 실제 평면 파일을 기준으로 샤딩 폴더로 이동하고, `image_assets.src`와 사용자 아바타/주제 썸네일/템플릿 아이템/사용자 아이템/티어표 JSON/템플릿 요청 JSON 참조도 같은 새 경로로 일괄 치환한다.
|
||||
- 로컬 실행용 `npm --prefix backend run images:shard-assets` 스크립트를 추가해, 기존 100여 개 수준의 평면 자산도 별도 수작업 없이 한 번에 정리할 수 있게 했다.
|
||||
|
||||
## 2026-04-03 v1.4.59
|
||||
- 최근 업로드된 최적화 이미지가 `/uploads/assets/<파일명>.webp`처럼 하위 폴더 없이 저장되면서, `썸네일 이미지 / 프로필 이미지` 필터가 경로 문자열만으로 자산 종류를 판별하지 못해 비어 보일 수 있던 문제를 고쳤다.
|
||||
- 관리자 아이템 목록 생성 시 `users.avatar_src`, `topics.thumbnail_src`, `tierlists.thumbnail_src`, `template_requests.thumbnail_src_snapshot`을 역으로 모아 해당 `src`가 프로필 이미지인지 썸네일 이미지인지 먼저 판별하고, `thumbnail/avatar` 필터는 `sourceType`이 아니라 이 실제 참조 역할(`assetKind`) 기준으로 걸리도록 보정했다.
|
||||
- 신규 최적화 이미지 저장은 한 폴더에 무한정 쌓이지 않도록 파일 ID 앞 2글자 기준으로 `/uploads/assets/ab/<파일명>.webp`처럼 1단계 샤딩 디렉터리를 사용하게 바꿨다. 기존에 이미 저장된 `/uploads/assets/<파일명>.webp` 평면 경로는 그대로 유지해 과거 이미지 링크가 깨지지 않게 했다.
|
||||
|
||||
## 2026-04-03 v1.4.58
|
||||
- 작성자 프로필 화면 상단 헤더가 `Author + 닉네임 + @accountName`을 다시 보여주면서, 바로 아래 프로필 카드의 아바타/닉네임 정보와 거의 같은 내용이 반복되던 구성을 정리했다.
|
||||
- 상단 헤더는 공통 제목 `사용자 프로필`과 안내 문구로 바꾸고, 실제 닉네임은 아래 프로필 카드에서만 보여주도록 나눠 화면의 정보 역할이 겹치지 않게 했다.
|
||||
- 이메일 앞부분에서 파생된 `@accountName`은 사용자가 직접 설정한 핸들이 아니라서 오히려 “내가 입력한 적 없는 계정명”처럼 느껴질 수 있으므로, 프로필 화면의 시각 노출에서는 제거했다.
|
||||
|
||||
## 2026-04-03 v1.4.57
|
||||
- 관리자 아이템 관리 필터를 `전체 이미지 → 아이템(템플릿 + 사용자) → 템플릿 아이템 → 사용자 아이템 → 썸네일 이미지 → 프로필 이미지 → 미사용 아이템` 순서로 다시 정리해, 전체 조회와 실제 아이템 검수 흐름이 더 직관적으로 이어지게 맞췄다.
|
||||
- 기본 필터는 계속 `아이템(템플릿 + 사용자)`를 유지하되, 썸네일과 프로필 이미지는 각각 `filter=thumbnail`, `filter=avatar`로 분리 조회할 수 있게 백엔드 필터 enum과 자산 분류 값을 확장했다.
|
||||
- 보관 자산 배지 문구도 `/uploads/assets/topics/` 경로는 `썸네일 이미지`, 사용자 업로드 항목은 `사용자 아이템`, 템플릿 기본 항목은 `템플릿 아이템`으로 맞춰 `관리자 템플릿`처럼 실제 의미와 어긋나는 표현이 남지 않도록 정리했다.
|
||||
- `미사용 아이템`은 계정 탈퇴로 같이 삭제된 항목이 아니라, 사용자 아이템 레코드는 남아 있지만 저장 티어표/템플릿에서 더 이상 참조하지 않는 항목이라는 의미가 드러나도록 통계 라벨과 일괄 삭제 버튼 문구를 다시 정돈했다.
|
||||
|
||||
## 2026-04-03 v1.4.56
|
||||
- 관리자 아이템 관리에서 `/uploads/assets/...` 아래의 보관 이미지가 템플릿 기본 아이템이 아닌데도 모두 `관리자 템플릿` 배지로 표시되던 분류를 정리했다.
|
||||
- 보관 이미지 자산은 이제 `asset` 출처로 분리하고, 경로에 따라 `프로필 아바타`, `티어표 썸네일`, `템플릿 썸네일`, `보관 자산` 배지가 붙도록 바꿔 반복 사용 아이템과 1회성/관리용 이미지를 구분해서 볼 수 있게 했다.
|
||||
- 아이템 관리 필터 기본값을 `아이템만 (템플릿+사용자)`로 바꾸고, `썸네일·프로필 이미지`, `미사용 썸네일·프로필 이미지` 필터를 따로 제공해 기본 화면에서는 실제 아이템만 먼저 검수할 수 있게 했다.
|
||||
- 아이템 관리 상단의 `미사용` 통계가 프로필/썸네일 같은 자산까지 `usageCount=0`으로 같이 세면 잘못된 숫자처럼 보일 수 있으므로, `미사용 사용자 아이템`이라는 라벨로 바꾸고 실제 사용자 업로드 아이템 중 템플릿 연결과 사용 횟수가 모두 없는 항목만 세도록 보정했다.
|
||||
|
||||
## 2026-04-03 v1.4.55
|
||||
- 관리자 회원 카드의 `최근 활동`이 실제로는 마지막 접속이 아니라 작성 티어표의 마지막 수정 시각 기준이었으므로, 라벨을 `최근 콘텐츠 활동`으로 분명하게 바꾸고 `마지막 접속일`을 별도 줄로 추가해 두 의미를 분리했다.
|
||||
- 백엔드 `users`에 `last_login_at`을 추가하고, 로그인/이메일 인증 완료/비밀번호 재설정 완료/세션 기반 `/api/auth/me` 확인 시 해당 시각을 갱신하도록 보강했다. 기존 계정은 마이그레이션 시 `created_at`으로 1차 채워 오래된 계정도 빈 값 없이 정렬할 수 있게 했다.
|
||||
- 관리자 회원 목록 정렬에 `마지막 접속순`을 추가하고, 회원 카드의 `회원 정보 수정` 옆에 `프로필 보기` 버튼을 붙여 해당 유저의 `/users/:userId` 공개 프로필 화면으로 바로 이동할 수 있게 했다.
|
||||
|
||||
## 2026-04-03 v1.4.54
|
||||
- 관리자 `전체 티어표 관리` 카드에 받은 즐겨찾기 수를 표시하고, 우측 운영 패널에 `최근 수정순 / 최근 생성순 / 즐겨찾기 많은 순` 정렬과 `최소 즐겨찾기 수` 필터를 추가해, 운영자가 추천 후보가 될 만한 인기 티어표를 더 빨리 찾을 수 있게 했다.
|
||||
- 관리자 회원 관리 카드에 `팔로워 수`와 `받은 즐겨찾기 수`를 추가하고, 정렬 기준에도 `팔로워 많은 순`, `받은 즐겨찾기 많은 순`을 붙여 어떤 작성자가 핵심 기여자인지 운영자가 더 쉽게 파악할 수 있게 했다.
|
||||
- 이메일 인증과 비밀번호 재설정 메일이 들어간 뒤에는 운영자가 회원 비밀번호를 직접 바꾸는 버튼이 평소 화면에 드러나 있을 필요가 작다고 보고, 회원 카드의 `비밀번호 초기화` 버튼과 해당 모달 UI를 숨겼다. 서버의 관리자 비밀번호 변경 API는 비상 상황용 최후 수단으로 남겨두되, 일반 운영 동선에서는 직접 조작처럼 보이지 않도록 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.53
|
||||
- 티어표 복사 버튼이 타인 티어표에서만 보이도록 묶여 있어 본인 티어표에서는 숨겨지던 문제를 고쳐, 저장된 본인 티어표도 `복사본 만들기`로 새 복사본을 만들 수 있게 복구했다.
|
||||
- 본인 티어표를 편집 중 저장하지 않은 변경이 있는 상태로 복사본을 만들면 화면에 보이는 최신 수정 내용이 빠질 수 있었으므로, 복사 실행 직전에 현재 수정본을 먼저 저장한 뒤 복사본을 생성하도록 보정했다.
|
||||
- 작성자 프로필 화면(`/users/:userId`)과 팔로우 피드 화면(`/following`)을 추가하고, 백엔드에 `user_follows` 테이블과 팔로우/언팔로우/작성자 공개 티어표/팔로잉 피드 API를 붙였다.
|
||||
- 티어표 편집/뷰어 우측 패널에 `작성자 프로필 보기` 진입점을 추가하고, 왼쪽 내비게이션에도 `팔로우 피드` 메뉴를 노출해 팔로우한 작성자의 공개 티어표를 따로 모아 볼 수 있게 했다.
|
||||
- 프런트 HTML 메타 제목/설명에서도 `게임 템플릿` 표현을 `템플릿` 기준 문구로 맞춰, 실제 서비스가 특정 게임만 다루는 것처럼 보이지 않도록 한 번 더 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.52
|
||||
- 관리자 전체 티어표 카드 컴포넌트의 `추천 지정 / 추천 해제` 버튼과 `추천 노출중` 배지, 추천 개수 통계 표시가 실제 릴리스 커밋에 함께 포함되도록 누락 파일을 다시 묶었다.
|
||||
|
||||
## 2026-04-03 v1.4.51
|
||||
- 주제별 공개 티어표 목록을 `추천 티어표`와 `전체 공개 티어표`로 분리해, 관리자가 추천 지정한 글은 상단 강조 섹션에 먼저 보여주고 아래 일반 목록에서는 중복 노출되지 않도록 정리했다.
|
||||
- 관리자 `전체 티어표 관리` 카드에 `추천 지정 / 추천 해제` 버튼과 `추천 노출중` 배지를 추가하고, 상단 통계에도 추천 개수를 함께 표시하도록 보강했다.
|
||||
- 백엔드 `tierlists`에 `is_featured`, `featured_at`, `featured_by`를 추가하고, 공개 목록 API가 추천 티어표 최대 16개와 일반 공개 티어표 목록을 분리해서 내려주도록 확장했다.
|
||||
- 비공개 티어표를 추천으로 지정하려는 경우는 서버에서 `public_tierlist_required`로 차단하고, 이미 추천된 글을 비공개로 전환하면 추천 상태도 자동 해제되도록 맞췄다.
|
||||
|
||||
## 2026-04-03 v1.4.50
|
||||
- 설정 화면 메인 영역이 `max-width: 620px` 단일 컬럼으로 고정되어 넓은 화면에서 오른쪽 공간이 많이 비어 보였으므로, 프로필 정보 카드와 비밀번호 변경 카드를 좌우 2열 그리드로 나누고 좁은 화면에서만 1열로 내려가도록 레이아웃을 재정리했다.
|
||||
- 왼쪽 카드는 아바타/닉네임/이메일/로그아웃/프로필 저장을, 오른쪽 카드는 현재 비밀번호 확인과 새 비밀번호 저장을 담당하게 분리해, 설정 화면의 정보 묶음이 더 명확하게 읽히도록 맞췄다.
|
||||
|
||||
## 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`처럼 운영자·공식 계정으로 오해될 수 있는 닉네임은 예약어로 막고, 프로필 수정/관리자 회원 수정에서도 같은 규칙을 공유하도록 맞췄다.
|
||||
- 로그인·회원가입 화면은 중복된 이메일/닉네임일 때 빨간색 오류 메시지를 바로 보여주도록 보강했고, 테마는 저장값이 없을 때 무조건 다크로 시작하면서 설정 화면에서만 라이트/다크 토글을 다시 노출하도록 정리했다.
|
||||
- 관리자 템플릿 썸네일 드롭존의 빈 상태 아이콘은 제거했고, 아이템 상세 모달에는 선택한 썸네일 프리뷰를 추가해 현재 선택한 이미지가 더 잘 보이게 했다.
|
||||
|
||||
## 2026-04-02 v1.4.32
|
||||
- 파일명과 내부 심볼 이름까지 `topic/template` 기준으로 마감했다. `GameHubView`는 `TopicHubView`, `AdminGamesSection`은 `AdminTemplatesSection`, `useAdminGameManager`와 `useAdminFeaturedGames`는 각각 `useAdminTemplateManager`, `useAdminFeaturedTemplates`로 정리했다.
|
||||
- 관리자 화면 내부 상태와 스타일 클래스도 `adminTemplatePicker`, `templateManagerGrid`, `templateSettingsCard` 기준으로 바꿔, 사용자에게는 안 보이지만 코드 검색에서 남던 `Game` 흔적을 더 걷어냈다.
|
||||
- 백엔드도 `copyUploadIntoTopicAsset`, `mapTopicRow`, `mapTopicItemRow`처럼 내부 함수명을 맞추고, 업로드 디렉터리/정리 스크립트도 `topics` 기준으로 통일해 `backend/src`, `frontend/src`, `backend/scripts`, `backend/index.js` 범위의 `game/Game` 검색 결과를 0건으로 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.31
|
||||
- 서비스가 아직 공개 전이고 예전 링크/예전 DB를 이어갈 필요가 없다는 전제에 맞춰, `/games` redirect와 관리자 `/admin/games` redirect, DB 레거시 마이그레이션 코드, legacy origin 정규화 코드를 실제로 제거했다.
|
||||
- 티어표 저장/request schema도 이제 `origin: 'template' | 'custom'`만 받도록 정리했고, 관리자 최근 최적화 작업 분류 fallback에 남아 있던 `games` 처리도 걷어냈다.
|
||||
- seed 데이터 ID까지 `example-topic`, `another-topic` 기준으로 바꿔, 현재 `backend/src`와 `frontend/src` 코드 검색에서 `game` 흔적이 0건인 상태까지 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.30
|
||||
- `ensureData()` 단계에서 저장된 티어표 `pool_json`과 템플릿 요청 스냅샷(`items_json`, `board_items_json`) 안에 남아 있을 수 있는 `origin: 'game'` 값을 자동으로 `template`로 정리하도록 보강했다.
|
||||
- 로컬 MariaDB를 비운 뒤 현재 스키마로 다시 올리는 검증도 함께 진행했고, 새 DB 기준으로 `topics=3`, `tierlists=0`, `legacyTierItems=0`, `legacyRequestItems=0` 상태까지 확인했다.
|
||||
- 즉 현재 개발 환경에서는 새로 생성되거나 다시 초기화한 데이터에 `game` 기반 origin이 남지 않으며, 남은 `game` 코드는 레거시 redirect·DB 마이그레이션 감지·과거 데이터 호환층만 담당하게 됐다.
|
||||
|
||||
## 2026-04-02 v1.4.29
|
||||
- 티어표 저장/request schema는 이제 새 데이터에서 `origin: 'template'`를 기본으로 쓰고, 예전 `origin: 'game'`도 계속 읽을 수 있게 호환 레이어를 남겼다.
|
||||
- 관리자 아이템 라이브러리의 템플릿 연결 정보도 `linkedTemplates` 기준으로 정리해, 내부 응답/프런트 상태에 남아 있던 `linkedGames` 흔적을 제거했다.
|
||||
- 현재 `game` 검색에 남는 것은 레거시 주소 redirect, DB 마이그레이션용 legacy 테이블/컬럼명, 과거 저장 데이터 호환용 `origin: 'game'`처럼 의도적으로 남겨둔 층만 남도록 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.28
|
||||
- 관리자 템플릿 상세 상태(`selectedTemplate.game`)와 관련 응답 키를 `template` 기준으로 정리해, 내부 코드 검색에서 남던 `game` 흔적을 더 줄였다.
|
||||
- 관리자 기본 아이템 정렬/로딩 상태 이름도 `templateItem*`, `isTemplateLoading`, `templateVisibilitySaving` 기준으로 바꾸고, 새 템플릿 자산 업로드는 `topics` 디렉터리로 저장되게 맞췄다.
|
||||
- 현재 코드 검색에서 남는 `game`는 주로 레거시 주소 redirect(`/games/:gameId`), DB 마이그레이션용 legacy 테이블/컬럼명, 기존 저장 데이터와 맞춘 `origin: 'game'` 값처럼 의도적으로 남겨둔 호환층만 남도록 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.27
|
||||
- 관리자 내부 탭/라우트 이름도 `template-admin`, `adminTemplates`, `/admin/templates` 기준으로 더 정리해, 화면 상태값과 라우트 이름에 남아 있던 `game-admin`, `adminGames` 흔적을 줄였다.
|
||||
- 더 이상 참조되지 않는 DB alias export(`listGames`, `createGame`, `favoriteGame` 등)와 `updateTemplateRequestTargetGame` 별칭도 제거해, 백엔드 모듈 표면에서 남아 있던 레거시 `game` 이름층을 더 걷어냈다.
|
||||
- 커스텀 아이템 모달 내부 클래스명도 `createTemplateButton` 기준으로 정리해, 관리자 코드 검색에서 남는 `createGame` 흔적을 줄였다.
|
||||
|
||||
## 2026-04-02 v1.4.26
|
||||
- 관리자 API 레거시 `/games` alias를 걷어내고 `POST /api/admin/templates`, `.../templates/:templateId/...`만 남기도록 정리했다. 관리자 템플릿 연결/가져오기 액션도 `link-template`, `create-template` path로 바꿨다.
|
||||
- 백엔드 공개 주제 라우트도 이제 `/api/topics`만 마운트하고, 오래된 `/api/games` 경로는 제거했다. 관리자 화면 URL 역시 `/admin/games` 대신 `/admin/templates`를 기본 경로로 쓰고, 예전 주소는 redirect만 남겼다.
|
||||
- 문서의 API/화면 매핑도 현재 구조 기준으로 갱신해, `games` 중심 설명 대신 `topics / templates` 기준으로 읽히게 맞췄다.
|
||||
|
||||
## 2026-04-02 v1.4.25
|
||||
- 티어표와 템플릿 요청 응답에서 `gameId / gameName / sourceGameId / targetGameId` 호환 키를 실제로 제거하고, 프런트 화면도 `topicId / topicName / sourceTopicId / targetTopicId`만 읽도록 정리했다.
|
||||
- 관리자 전체 티어표 관리와 템플릿 요청 관리, 나의 티어표/즐겨찾기/검색 결과 이동, 티어표 편집기 저장·요청 payload도 `topicId` 기준으로 맞춰, 화면과 요청 바디에서 보이는 `game` 흔적을 더 줄였다.
|
||||
- 관리자 템플릿 정렬 저장과 템플릿 아이템/요청 반영 API body도 `topicIds / topicId` 기준으로 옮겼고, 남은 `gameId`는 이제 레거시 주소 호환용 `/games/:gameId`와 관리자 alias route path 쪽에만 남도록 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.24
|
||||
- 공개 주제 API는 이제 `topics` 목록과 `topic` 상세만 기본 응답으로 내려주고, 즐겨찾기 토글도 `topic`만 반환하도록 정리했다. 관리자 템플릿 생성/공개 상태 저장도 `template`만 기본 응답으로 맞췄다.
|
||||
- 홈, 주제 상세, 티어표 편집기, 관리자 템플릿 관리 화면도 이 변경에 맞춰 `data.topics`, `data.topic`, `data.template`를 직접 읽도록 바꿨다.
|
||||
- 관리자 내부 상태는 `api.getTopic()` 응답을 받아도 `selectedTemplate.game`에 한 번 정규화하도록 보강해, UI 구조를 크게 흔들지 않으면서 응답 호환 키는 더 줄일 수 있게 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.23
|
||||
- 프런트 `api.js`에서 더 이상 쓰지 않는 `listGames / getGame / favoriteGame / updateAdminGame* / listPublicTierLists` 같은 레거시 별칭 메서드를 정리해, 공개/관리자 호출부가 실제로 쓰는 `topic/template` API만 남기도록 정리했다.
|
||||
- 관리자 템플릿 요청 상태와 전체 티어표 관리 카드도 `sourceTopicId / targetTopicId / topicName`을 우선 읽도록 더 당겨, 화면에서 `game` 키를 보는 범위를 줄였다.
|
||||
- 티어표 저장/템플릿 요청 백엔드는 이제 내부적으로 `sourceTopicId / targetTopicId / topicId`만 넘기도록 정리하고, 기존 `sourceGameId / gameId`는 저장 경로에서 한 단계 더 덜어냈다.
|
||||
|
||||
## 2026-04-02 v1.4.22
|
||||
- 백엔드 공개 주제 라우트 파일을 [topics.js](/Users/bicute/Desktop/zenn.dev/tier-cursor/backend/src/routes/topics.js)로 옮기고, 진입점도 이 이름으로 읽히게 정리했다. 이제 서버 코드에서 `games.js` 파일명이 남아 있던 마지막 큰 표면도 실제 의미에 더 가깝게 맞춰졌다.
|
||||
- 공개 주제 라우트의 path 파라미터도 `:topicId` 기준으로 읽히게 바꿔, 내부 구현에서 더 이상 `req.params.gameId`를 기본 전제로 보지 않도록 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.21
|
||||
- 프런트의 실제 소비 지점도 `topic/template` 응답 키를 우선 읽도록 옮겼다. 홈의 즐겨찾기 토글, 주제 상세 헤더, 티어표 편집기 템플릿 로딩, 나의 티어표/즐겨찾기/검색 결과의 에디터 이동이 이제 `topic`, `topicId`, `template`를 먼저 사용한다.
|
||||
- 관리자 템플릿 공개 상태 저장과 신규 템플릿 생성 흐름도 `data.template`를 우선 읽고, 기존 `data.game`은 fallback으로만 남겨 프런트와 백엔드의 의미 이름이 한 단계 더 가까워지게 맞췄다.
|
||||
|
||||
## 2026-04-02 v1.4.20
|
||||
- 백엔드 `db`와 라우트 내부 이름층을 한 단계 더 `topic` 기준으로 옮겼다. `listTopics / findTopicById / getTopicDetail / createTopic / updateTopicThumbnail / updateTopicVisibility`, `createTopicItem / updateTopicItemLabel / updateTopicItemDisplayOrder / deleteTopicItem / deleteTopic` 같은 이름을 실제 export로 추가하고, 기존 `game` 이름은 호환 alias로만 남겼다.
|
||||
- 공개 주제 라우트는 이제 `listTopics`, `getTopicDetail`, `favoriteTopic` 기준으로 동작하고, 백엔드 진입점도 `gamesRoutes` 대신 `topicsRoutes`라는 이름으로 읽히도록 정리했다.
|
||||
- 관리자 라우트 역시 핵심 템플릿 흐름에서 `findTopicById`, `createTopic`, `createTopicItem`, `promoteSnapshotItemsToTemplate`, `createTemplateFromTierList` 같은 의미 이름을 직접 사용하도록 바꿔, 실제 저장 스키마와 코드 언어가 더 가까워지게 맞췄다.
|
||||
|
||||
## 2026-04-02 v1.4.19
|
||||
- 관리자 템플릿 기본 아이템 삭제는 이제 기존에 저장된 티어표의 그룹/대기풀 데이터를 건드리지 않고, 템플릿의 현재 기본 아이템 목록에서만 제거되도록 바꿨다. 그래서 이미 만들어진 티어표는 그대로 유지되고, 이후 새로 만드는 티어표에서만 해당 아이템이 빠진다.
|
||||
- 삭제 전에는 이 아이템이 이미 저장된 티어표 몇 개에서 사용 중인지(공개/비공개 포함) 확인 문구를 먼저 보여주도록 바꿔, 운영자가 영향 범위를 알고 삭제할 수 있게 했다.
|
||||
- 템플릿 썸네일이 이미 등록된 상태에서는 등록 아이콘이 겹쳐 보이지 않도록 정리했고, 기본 아이템 삭제 후 템플릿을 다시 불러와도 페이지가 맨 위로 튀지 않게 스크롤 위치를 복원하도록 보강했다.
|
||||
|
||||
## 2026-04-02 v1.4.18
|
||||
- 관리자 템플릿 요청 카드 썸네일 클릭은 `window.open(...)`을 템플릿 이벤트 안에서 직접 호출하던 구조 때문에 브라우저 새 창 열기 시 `Cannot read properties of undefined (reading 'open')`가 날 수 있었고, 이를 제거해 앵커의 기본 새 창 동작만 사용하도록 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.17
|
||||
- 주제 컬렉션에서 티어표 카드를 클릭할 때 `Maximum call stack size exceeded`가 나던 원인은 `editor` 레거시 redirect가 새 라우트와 동일한 URL 패턴을 다시 자기 자신에게 redirect하던 구조였고, 불필요한 `editor` redirect 레코드를 제거해 무한 라우팅 루프를 끊었다.
|
||||
|
||||
## 2026-04-02 v1.4.16
|
||||
- 백엔드나 DB 장애가 났을 때 일반 화면에서 계속 `연결할 수 없어요` 식으로 보이던 흐름을 정리하고, `api` 공통 요청 계층에서 `db_init_failed` 같은 500과 네트워크 실패를 감지해 앱 전체를 점검/연결 확인 화면으로 전환하도록 바꿨다.
|
||||
- 이제 데이터베이스 초기화 실패나 서버 내부 500은 `서비스 점검 중`, 네트워크 단절은 `서버 연결 확인 중`으로 구분되어 보이며, 사용자는 일반 페이지 대신 전용 안내 화면과 다시 시도 버튼을 보게 된다.
|
||||
|
||||
## 2026-04-02 v1.4.15
|
||||
- `db_init_failed`의 직접 원인은 MariaDB에서 `SHOW TABLES LIKE ?`, `SHOW COLUMNS ... LIKE ?` 플레이스홀더를 허용하지 않던 부분이었고, 이를 `information_schema` 조회 기반으로 바꿔 실제 운영 DB에서도 `ensureData()`가 정상 통과되게 고쳤다.
|
||||
- 중간 마이그레이션 상태에서 `template_requests.target_topic_id`가 이미 생긴 DB는 중복 컬럼 추가로 다시 실패할 수 있었으므로, 해당 확인도 `columnExists()` 기준으로 바꿔 부분 적용된 DB까지 안전하게 다시 기동되게 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.14
|
||||
- `/games/:gameId`, `/editor/:gameId/...` 레거시 주소는 Vue Router alias 대신 redirect로 정리해, `topicId` 기준 라우트와 섞일 때 뜨던 param mismatch 경고를 제거했다.
|
||||
- 운영 DB에서 바로 `RENAME/CHANGE`를 치던 초기 마이그레이션은 위험도가 높아, `topics / topic_items / favorite_topics / topic_id` 스키마를 안전하게 만들고 기존 `games` 계열 데이터를 복사해 오는 방식으로 바꿨다.
|
||||
- DB 초기화 실패 시 원인을 바로 확인할 수 있도록 백엔드에서 `db_init_failed` 응답 전에 실제 에러를 서버 로그에 남기도록 보강했다.
|
||||
|
||||
## 2026-04-02 v1.4.13
|
||||
- DB 실명 변경 마지막 단계로 `games / game_items / favorite_games`를 `topics / topic_items / favorite_topics` 기준으로 자동 마이그레이션하도록 정리하고, `tierlists.game_id`, `template_requests.source_game_id/target_game_id`도 각각 `topic_id`, `source_topic_id/target_topic_id`로 옮기게 했다.
|
||||
- 백엔드 저장/조회 쿼리는 이제 새 topic 스키마를 기준으로 동작하고, 응답에는 `topicId / topicName`을 기본으로 내려주되 기존 프런트가 바로 깨지지 않도록 `gameId / gameName`도 잠시 함께 유지했다.
|
||||
- 티어표 공개 목록, 관리자 전체 티어표 관리, 티어표 저장/요청 API는 `topicId`를 우선 받도록 정리하고 기존 `gameId`는 호환 입력으로만 남겨, 외부 표면과 실제 저장 스키마가 한 단계 더 가까워지게 맞췄다.
|
||||
|
||||
## 2026-04-02 v1.4.12
|
||||
- 백엔드에 `/api/topics`와 `/api/admin/templates` alias 경로를 추가하고, 주제/템플릿 응답도 `topic/topics`, `template/templates` 키를 함께 내려주도록 정리했다.
|
||||
- 프런트의 새 의미 이름은 이제 실제로도 `/api/topics`, `/api/admin/templates`를 타도록 연결해, 경로 이름과 호출 이름이 다시 어긋나지 않게 맞췄다.
|
||||
|
||||
## 2026-04-02 v1.4.11
|
||||
- 프런트 API 이름층을 한 단계 더 정리해 `listTopics / getTopic / favoriteTopic`, `updateAdminTemplate*`, `searchPublicTierListsByTopic` 같은 의미 기반 이름을 추가하고 실제 호출부도 이 기준으로 옮겼다.
|
||||
- 백엔드 경로와 응답 구조는 그대로 유지한 채 프런트에서 읽는 이름만 먼저 바꿔, 다음 단계의 API/모델 리네이밍 부담을 더 줄였다.
|
||||
|
||||
## 2026-04-02 v1.4.10
|
||||
- 주제 상세 라우트 이름을 `topicHub`로, 기본 경로 파라미터를 `topicId`로 바꾸고 기존 `gameId` 주소는 alias로 유지했다.
|
||||
- 앱 셸, 주제 상세, 티어표 편집기는 이제 내부에서 `topicId`를 우선 읽고, 레거시 주소로 들어온 경우에만 `gameId` fallback을 쓰도록 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.9
|
||||
- `frontend/src/lib/paths.js`를 추가해 주제 진입, 에디터 이동, 로그인 리다이렉트, 공유 프리뷰 주소 같은 사용자 표면 경로를 공용 함수로 모았다.
|
||||
- 홈, 주제 상세, 나의 티어표, 즐겨찾기, 검색 결과, 로그인, 설정, 관리자 미리보기, 티어표 편집기까지 이 경로 헬퍼를 쓰도록 바꿔 이후 `topics` 전환을 더 안전하게 이어갈 수 있는 기반을 만들었다.
|
||||
|
||||
## 2026-04-02 v1.4.8
|
||||
- 주제 상세 컬렉션 화면은 제목을 `topicId` fallback으로 먼저 노출하지 않도록 바꾸고, 주제 전환 시에는 로딩 문구를 거쳐 실제 이름으로 자연스럽게 바뀌게 정리했다.
|
||||
- 검색 결과 화면도 공통 `pageHead` 문법으로 맞춰 주요 목록 화면들의 상단 리듬을 한 번 더 통일했다.
|
||||
|
||||
## 2026-04-02 v1.4.7
|
||||
- 주제 선택 뒤에 들어가는 `Collection` 화면을 공통 `pageHead` 레이아웃으로 다시 맞추고, 검색 입력을 즐겨찾기 화면처럼 상단 우측 툴바로 정리했다.
|
||||
- `공개 티어표` 보조 설명 줄은 제거해 헤더 밀도를 줄였고, 사용자 진입 경로는 `/topics/:gameId`를 기본으로 전환하면서 기존 `/games/:gameId`는 alias로 유지했다.
|
||||
|
||||
## 2026-04-02 v1.4.6
|
||||
- 관리자 내부 리네이밍 2단계로 `AdminView`와 관련 composable/component의 핵심 상태명을 `selectedTemplate / templates / loadTemplate / refreshTemplates / createTemplate` 기준으로 정리했다.
|
||||
- 요청 검토, 템플릿 생성 모달, 아이템 추가/정렬, 템플릿 선택 모달 흐름도 같은 기준으로 맞춰, 관리자 화면을 읽을 때 내부 이름과 사용자 노출 용어가 덜 어긋나게 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.5
|
||||
- 내부 리네이밍 1단계를 시작해 홈, 주제 화면, 티어표 편집기, 앱 셸에서 `games / gameId / gameName` 중심의 로컬 상태명을 `templates / topicId / templateId / templateName` 계열로 먼저 정리했다.
|
||||
- 경로와 API는 그대로 둔 채 프런트 내부에서 자주 읽는 상태명부터 바꿔, 이후 `/games` 라우트와 관리자 상태를 손볼 때 의미 충돌이 덜 나도록 기반을 만들었다.
|
||||
|
||||
## 2026-04-02 v1.4.4
|
||||
- 홈 화면 `Topic Library`와 일부 영어 헤더를 `주제 선택 / 티어표 / 관리자 작업실 / 티어표 만들기 / 작업 공간`으로 정리해, 화면 타이틀과 상단 레이블까지 한국어 기준으로 거의 통일했다.
|
||||
|
||||
## 2026-04-02 v1.4.3
|
||||
- 관리자 토스트, 확인창, 요청 처리 안내처럼 실제로 자주 보이는 운영 문구까지 `주제 / 템플릿` 기준으로 한 번 더 정리해, 화면 제목뿐 아니라 작업 피드백도 더 일관되게 맞췄다.
|
||||
|
||||
## 2026-04-02 v1.4.2
|
||||
- 관리자 화면과 보조 모달에 남아 있던 사용자 노출 `게임` 문구를 추가로 걷어내고, `템플릿 / 주제` 기준 표현으로 더 통일했다.
|
||||
|
||||
## 2026-04-02 v1.4.1
|
||||
- 왼쪽 사이드 메뉴를 `주제 선택 / 나의 티어표 / 즐겨찾기 / 설정` 한글 문구로 통일하고, 해당 화면 진입 시 헤더 타이틀도 같은 이름 기준으로 맞췄다.
|
||||
|
||||
## 2026-04-02 v1.4.0
|
||||
- 사용자 노출 용어 1차 정리를 시작해 홈/좌측 레일/가이드/주제 화면에서는 `게임` 대신 `주제`, 관리자 핵심 화면에서는 `게임 관리` 대신 `템플릿 관리` 중심 표현으로 바꿨다.
|
||||
- 내부 데이터 모델과 API의 `gameId`, `/games` 구조는 아직 유지하고, 이번 단계는 화면 문구와 안내 텍스트를 먼저 정리하는 안전한 1차 리네이밍 범위로 제한했다.
|
||||
|
||||
## 2026-04-02 v1.3.93
|
||||
- 게임 목록, 티어표 리스트, 사용자 아바타 버튼 등 목록성 썸네일 이미지에 `draggable=\"false\"`를 적용해 브라우저 기본 이미지 드래그 프리뷰가 뜨지 않도록 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.92
|
||||
- 왼쪽 네비게이션의 활성 메뉴 배경은 개별 항목에 즉시 붙는 방식에서, 공용 인디케이터가 현재 메뉴 위치로 미끄러져 이동하는 토글형 인터랙션으로 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.91
|
||||
- 로그인 화면 상단의 `로그인 / 회원가입` 전환은 선택된 버튼 배경이 즉시 바뀌던 방식에서, 뒤쪽 하이라이트가 토글처럼 좌우로 미끄러져 이동하는 인터랙션으로 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.90
|
||||
- 관리자 화면 CSS 경고를 줄이기 위해 `display: block` 요소에 의미 없던 `vertical-align`을 제거하고, `line-clamp` 표준 속성을 함께 선언해 VS Code 진단을 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.89
|
||||
- 현재 코드에서 참조되지 않던 `frontend/public/icons.svg`, `frontend/src/assets/hero.png`, `frontend/src/assets/vite.svg`, `frontend/src/assets/vue.svg`를 삭제해 템플릿 잔재 자산을 정리함.
|
||||
- 홈페이지 공유용 `og-card.svg`, `og-card.png`는 이번 워크트리에서 직접 수정된 최신 이미지 상태를 그대로 반영해 함께 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.88
|
||||
- 중앙 워크스페이스 헤더의 `by zenn` 링크는 공통 카피라이트 footer가 이미 역할을 대신하므로 제거하고, 기본 서브타이틀도 서비스 설명 문구로 정리함.
|
||||
- 홈페이지 공유용 메타를 정리해 `title`, `description`, `canonical`, Open Graph, Twitter 카드 정보를 `tmaker.sori.studio` 기준으로 연결함.
|
||||
- 외부 공유용 `og-card.svg`와 실제 썸네일 `og-card.png`, 브라우저/모바일용 `favicon-32x32.png`, `apple-touch-icon.png`를 추가해 링크 공유와 파비콘 노출을 함께 보강함.
|
||||
|
||||
## 2026-04-02 v1.3.86
|
||||
- 티어표 편집의 `아이콘 크기`는 이제 임시 화면 상태가 아니라 저장 데이터에 함께 포함되며, 저장 후 다시 열기와 프리뷰 화면에서도 같은 크기로 복원되도록 정리함.
|
||||
- 이를 위해 티어표 저장 payload, 서버 검증, DB 저장/조회에 `iconSize`를 추가하고 기존 데이터는 기본값 `80`으로 안전하게 보정되게 맞춤.
|
||||
- 이후 공유 프리뷰 화면이 여전히 80으로 고정되던 문제는 `previewOnly` 레이아웃에서 `--thumb-size` 스타일 바인딩이 빠져 있던 탓이었고, 프리뷰 루트에도 같은 값을 전달해 저장된 크기가 그대로 반영되게 보정함.
|
||||
|
||||
## 2026-04-02 v1.3.83
|
||||
- 티어표 편집/프리뷰 화면에서 열을 여러 개 쓰는 경우, 모바일처럼 좁은 화면에서는 기존 상단 열 헤더만으로 각 칸의 의미를 읽기 어려웠으므로 각 칸 상단에 작은 열 이름 배지를 추가함.
|
||||
- 이 배지는 모바일 구간에서만 보이고 데스크톱 레이아웃은 그대로 유지되므로, 작은 화면에서는 `메인 / 밸런스 / 서포트` 같은 열 맥락을 스크롤 중에도 잃지 않게 정리함.
|
||||
- 이후 배지가 칸 기준이 아니라 화면 한쪽에 겹치던 문제를 바로잡기 위해 각 칸을 기준점으로 다시 잡았고, 배지가 보이는 구간에서는 기존 상단 열 제목을 함께 숨겨 중복 표기를 제거함.
|
||||
- 추가로 같은 미디어 구간 안에서 행/열 모바일 레이아웃을 다시 `140px/150px`로 덮어쓰던 중복 규칙을 제거해, 모바일에서는 행 라벨이 화면 절반을 차지하지 않고 실제로 한 줄 전체 폭 기준 레이아웃으로 정리되게 맞춤.
|
||||
|
||||
## 2026-04-02 v1.3.82
|
||||
- 프리뷰 전용 완성본 화면에도 이미지 다운로드 결과와 같은 하단 메타를 붙여, 작성자 이름과 마지막 저장 시각을 바로 확인할 수 있게 정리함.
|
||||
- 관리자 `티어표 관리 > 템플릿 요청 관리`에서는 더 이상 썸네일 클릭으로 요청 미리보기 모달을 열지 않고, 썸네일 자체가 `요청 티어표 보기` 새창 링크 역할을 하도록 바꿨으며, 하단의 중복 `요청 티어표 보기` 버튼은 제거함.
|
||||
|
||||
## 2026-04-02 v1.3.81
|
||||
- 티어표 만들기 화면에는 저장된 티어표에서만 보이는 `공유하기` 액션을 추가하고, 누르면 현재 티어표의 완성본 링크(`preview=1`)를 클립보드에 복사한 뒤 토스트로 안내하도록 정리함.
|
||||
- 공유 링크는 관리자가 새 창에서 보던 완성본 주소와 같은 문법을 사용하므로, 저장된 티어표를 그대로 외부에 전달하거나 다시 열람하는 흐름으로 바로 이어짐.
|
||||
|
||||
## 2026-04-02 v1.3.79
|
||||
- 우측 카피라이트는 관리자 전용 레이아웃에서 분리해 앱 공통 `rightRail` footer로 올렸고, 이제 관리자 페이지뿐 아니라 오른쪽 사이드가 보이는 모든 화면에서 같은 최하단 위치에 표시됨.
|
||||
- 따라서 관리자 패널 길이나 페이지별 로컬 사이드바 내용과 무관하게, 카피라이트는 항상 오른쪽 레일 전체 기준 바닥에 고정되는 공통 footer 역할로 정리됨.
|
||||
|
||||
## 2026-04-02 v1.3.78
|
||||
- 왼쪽 레일 축소 상태의 하단 액션 아이콘은 문맥에 따라 구분되도록 바꿔, 홈의 `커스텀 티어표 만들기`는 `dashboard_customize` 아이콘을 쓰고 게임 허브의 일반 `티어표 만들기`만 `add_notes` 아이콘을 유지하도록 정리함.
|
||||
- 관리자 우측 카피라이트 문구는 사이드바 바깥 형제로 밀려 보이지 않을 수 있었으므로, 다시 관리자 사이드바 `aside` 내부 최하단으로 옮겨 레이아웃 안에서 안정적으로 보이게 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.77
|
||||
- 왼쪽 사이드 레일을 축소했을 때도 홈과 게임 허브에서 바로 새 티어표를 만들 수 있도록, 최하단 액션 영역에 `add_notes` 아이콘 기반의 축소 전용 `티어표 만들기` 버튼을 추가함.
|
||||
- 펼친 상태에서는 기존 텍스트 버튼을 그대로 유지하고, 축소 상태에서는 같은 위치에 아이콘 버튼만 남기도록 분기해 하단 액션 위치 감각은 유지하면서도 좁은 레일 폭에 맞게 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.76
|
||||
- 앱 왼쪽 사이드 레일은 축소 상태에서 아바타, 검색 버튼, 네비게이션 아이콘 버튼 높이를 모두 50px 기준으로 맞추고 검색 아래 여백도 정리해, 아이콘만 보이는 상태에서도 각 줄 높이가 제각각처럼 보이지 않게 정리함.
|
||||
- 왼쪽 사이드 검색은 라우트에 따라 의미가 바뀌지 않도록 `게임 템플릿 검색`으로 고정하고, 축소 검색 모달 역시 같은 플레이스홀더와 같은 동작으로 홈 게임 목록 검색을 수행하도록 통일함.
|
||||
|
||||
## 2026-04-02 v1.3.75
|
||||
- 관리자 공용 모달 카드의 기본 `padding: 20px`는 그대로 두되, 아이템 상세처럼 내부 레이아웃이 이미 큰 셸을 가진 모달은 `modalCard--customItem`에서 다시 덮어쓰지 않도록 분리해 상세 모달 크기와 내부 배치가 무너지지 않게 정리함.
|
||||
- 관리자 우측 사이드바 최하단의 카피라이트 문구는 이제 별도 상수 URL을 참조하는 외부 링크로 바꿔 새 창에서 열리게 했고, 추후 주소를 바꿔야 할 때 한 곳만 수정하면 되도록 정리함.
|
||||
- 앱 왼쪽 사이드 레일의 접힘 상태는 메타 텍스트를 단순히 투명하게 남겨두는 대신 실제로 숨기고, 아바타/검색/내비 아이콘을 다시 중앙 정렬해 접었을 때 높이가 비정상적으로 늘어나거나 간격이 남아 보이던 레이아웃을 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.74
|
||||
- 아이템 관리 상세에서 템플릿 추가 대상 게임을 고를 때, 이미 해당 이미지가 연결된 게임은 공용 게임 선택 모달에서 `이미 추가됨`으로 표시하고 비활성화해 중복 추가 실수를 미리 막도록 정리함.
|
||||
- 관리자 우측 사이드바 최하단에는 작은 카피라이트 문구를 추가해, 헤더에 관리 정보만 남기고 프로젝트 표기는 하단에서 조용히 보이도록 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.73
|
||||
- 전체 티어표 관리 카드 썸네일은 `draggable="false"`로 바꿔, 미리보기 진입 시 브라우저 기본 이미지 드래그가 클릭을 방해하지 않도록 정리함.
|
||||
- 관리자 사이드바의 검색 입력과 검색 버튼은 한 줄로 묶어, 입력/선택/실행 버튼이 모두 같은 크기의 세로 스택처럼 보이던 답답함을 조금 줄이고 역할 구분을 더 분명하게 함.
|
||||
- 아이템 관리 상세 모달의 템플릿 추가 대상 선택도 내부 전용 게임 리스트 대신 공용 `게임 선택` 검색 모달을 쓰도록 바꿔, 향후 게임 수가 많아져도 같은 선택 문법으로 이어지게 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.72
|
||||
- 관리자 화면 초기화 중 `/admin/games?gameId=...` 경로를 즉시 처리하는 watcher가 `loadGame` 초기화보다 먼저 실행되어 브라우저 콘솔에 `Cannot access 'loadGame' before initialization` 오류가 나던 문제를 수정함.
|
||||
- 게임 라우트 진입 시 실제 게임 로딩 호출은 컴포넌트 초기화가 끝난 뒤 microtask로 미뤄 실행하도록 바꿔, 첫 진입/새로고침에서도 게임 선택 복원 흐름이 안전하게 이어지게 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.71
|
||||
- 관리자 아이템 모달은 최근 추가했던 선택 요약 카드를 다시 걷어내고, 더 단순한 `게임 선택 패널 + 상세 작업 영역` 구조로 되돌려 이전 흐름에 가깝게 정리함.
|
||||
- 관리자 `게임 관리`와 `전체 티어표 관리`의 게임 선택은 긴 셀렉트/목록 대신 공용 `게임 선택` 검색 모달로 바꿔, 게임 수가 많아져도 이름·ID 검색으로 바로 찾아 선택할 수 있게 함.
|
||||
- 전체 티어표 관리의 게임 필터 해제도 같은 모달 흐름에 맞춰 `모든 게임 보기`로 처리하고, 사이드바에는 현재 선택된 게임만 요약 카드로 보여줘 긴 리스트가 계속 쌓이지 않게 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.70
|
||||
- 관리자 `전체 티어표 관리`는 이제 게임별 필터를 지원해, 특정 게임에서 만들어진 티어표만 따로 골라 보며 공개/비공개 분포를 확인할 수 있게 함.
|
||||
- 전체 티어표 카드는 공개 여부를 텍스트 대신 다른 관리자 화면과 같은 배지 형식으로 표시하고, 카드의 `관리` 액션에서 제목·설명 수정, 공개/비공개 전환, 삭제를 바로 처리할 수 있게 보강함.
|
||||
- 이를 위해 관리자 전용 티어표 수정/삭제 API와 게임 기준/검색 기준 공개 집계 로직을 함께 추가해, 관리자 화면에서 비공개 개입과 운영성 검수가 한 흐름으로 이어지게 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.69
|
||||
- 관리자 아이템 라이브러리는 참조 수/공유 기록 UI가 실제 운영 판단에 비해 노이즈가 커 보여 카드 수치, 상세 모달의 같은 이미지 참조 섹션, 삭제 확인 문구의 참조 강조를 걷어내고 다시 항목 자체 관리 흐름 위주로 정리함.
|
||||
- 관리자 게임 관리 상단 요약은 더 이상 `선택 상태`처럼 추상적인 문구를 보여주지 않고, 선택된 게임 기준으로 만들어진 티어표의 `전체 / 공개 / 비공개` 개수를 바로 보여주도록 바꿈.
|
||||
- 전체 티어표 관리 상단에도 검색 결과 기준 `전체 / 공개 / 비공개` 수치를 함께 노출하고, 이를 위해 관리자 티어표 집계 API를 별도로 추가해 페이지 단위가 아니라 실제 전체 결과 기준 숫자를 안정적으로 표시함.
|
||||
|
||||
## 2026-04-02 v1.3.68
|
||||
- 관리자 아이템 상세 모달은 같은 이미지를 왼쪽 선택 카드와 오른쪽 본문에서 두 번 보여주던 중복 미리보기를 제거해, 한 모달 안에서 정보가 겹쳐 보이던 문제를 정리함.
|
||||
- 왼쪽 게임 선택 패널과 오른쪽 상세 정보 패널은 각각 독립 스크롤이 되도록 바꾸고, 스크롤바도 다시 보이게 조정해 긴 목록이나 긴 참조 정보가 있어도 레이아웃이 깨지지 않고 탐색할 수 있게 함.
|
||||
- 현재 선택한 이미지 요약 카드에는 별도 배경과 테두리를 추가해, 기존 클릭 모달의 “선택 대상”과 오른쪽 작업 영역이 한눈에 구분되도록 시각 계층을 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.67
|
||||
- 관리자 아이템 관리 카드에는 이제 같은 `src`를 공유하는 참조 수와 연결 게임 수를 함께 표시해, 같은 이미지가 얼마나 넓게 쓰이는지 목록 단계에서 바로 파악할 수 있게 함.
|
||||
- 아이템 상세 모달은 왼쪽 패널 상단에 현재 선택한 이미지와 `총 참조 / 사용자 업로드 / 템플릿 항목 / 보관 자산` 요약을 보여주고, 오른쪽에는 같은 이미지를 가리키는 다른 기록 목록을 함께 표시해 실제로 어떤 참조들이 묶여 있는지 모달 안에서 바로 확인할 수 있게 함.
|
||||
- 삭제 확인 문구도 이제 단순 타입 설명만 하지 않고 `같은 이미지 참조 n건 중 현재 항목만 다룬다`는 영향을 함께 보여, 삭제 전에 범위를 더 명확히 이해할 수 있게 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.66
|
||||
- `누락 참조 정리`는 처음엔 누락 커스텀 아이템이 있을 때만 티어표/요청 썸네일까지 함께 보던 조건 때문에, 누락 썸네일만 남아 있으면 수치가 줄지 않던 문제가 있었으므로 분기를 풀어 티어표 썸네일과 요청 썸네일도 항상 실제 파일 존재 여부를 확인해 정리하도록 수정함.
|
||||
- 정리 완료 메시지에도 `티어표 썸네일`, `요청 썸네일` 항목을 추가해 어떤 종류의 누락 참조가 실제로 정리됐는지 바로 알 수 있게 함.
|
||||
|
||||
## 2026-04-02 v1.3.65
|
||||
- 관리자 이미지 최적화 패널에 `누락 참조 정리` 액션을 추가해, 실제 파일이 없는 `/uploads/...` 참조만 대상으로 썸네일/아바타는 비우고 누락된 게임 아이템·커스텀 아이템은 관련 티어표/템플릿 요청 참조와 함께 정리할 수 있게 함.
|
||||
- 따라서 예전 수동 파일 정리나 레거시 데이터로 인해 쌓인 `누락 파일`은 단순 통계로만 남지 않고, 관리자 화면에서 실제로 줄일 수 있는 관리 도구를 갖게 됨.
|
||||
|
||||
## 2026-04-02 v1.3.64
|
||||
- 관리자 이미지 최적화 최근 작업에서 `기존 최적화 파일 재사용` 건은 이제 `이번 업로드 용량 · 재사용 자산 용량` 형태로 표시하고, 동일한 최적화 결과가 이미 있어 새 파일을 다시 만들지 않았다는 설명을 함께 보여 혼동을 줄임.
|
||||
- 관리자 아이템 관리는 기존의 `미사용 사용자 업로드만 보기` 체크박스 대신 `전체 / 사용자 업로드 / 템플릿 사용 이미지 / 관리자 보관 자산 / 미사용 사용자 / 미사용 관리자` 필터로 바꿔, 관리자 업로드 자산도 별도로 확인할 수 있게 정리함.
|
||||
- 게임 관리의 선택된 게임 설정은 더 이상 우측 사이드바 아래쪽에 쌓지 않고, 본문 상단에 썸네일과 공개 상태·썸네일 적용·게임 삭제 액션을 함께 둔 카드로 옮겨 게임 목록이 많아져도 작업 영역을 더 안정적으로 읽을 수 있게 조정함.
|
||||
|
||||
## 2026-04-02 v1.3.63
|
||||
- 관리자 이미지 최적화 최근 작업 목록은 더 이상 내부 카테고리 문자열 `custom / tierlists / games / avatars`를 그대로 노출하지 않고, 각각 `커스텀 아이템 / 티어표 썸네일 / 게임·템플릿 이미지 / 프로필 아바타`처럼 사람이 이해할 수 있는 이름으로 표시함.
|
||||
- 같은 이미지 해시를 다시 업로드해 기존 최적화 파일을 재사용한 경우에는 최근 작업 목록에 `기존 최적화 파일 재사용` 문구를 함께 보여, 새로 압축된 건지 중복 자산이 재사용된 건지 운영자가 바로 구분할 수 있게 함.
|
||||
|
||||
## 2026-04-02 v1.3.62
|
||||
- 티어표 저장과 템플릿 요청 전 커스텀 이미지 업로드에서는 더 이상 `itemsById` 객체 키 순서에 기대지 않고, 실제 화면에 보이는 `아이템 영역 + 보드 배치 순서` 기준으로 아이템 배열을 만들도록 바꿔 저장 중 이미지 목록이 흔들리던 현상을 줄임.
|
||||
- 따라서 커스텀 아이템 이름 정리 목록, 저장 payload, 템플릿 요청 payload 모두 같은 순서 기준을 공유하게 되어, 이미지를 여러 장 올린 뒤 저장해도 사용자가 보고 있던 흐름이 덜 흔들리도록 정리함.
|
||||
- 티어표 삭제 버튼은 이제 템플릿 요청과 같은 기준으로 `저장된 티어표`에서만 노출되며, 실제 삭제도 저장본 ID가 있을 때만 동작하도록 맞춰 저장 전 초안 상태의 어색한 삭제 액션을 제거함.
|
||||
|
||||
## 2026-04-02 v1.3.61
|
||||
- 관리자 게임 관리의 썸네일 드롭존, 관리자 기본 아이템 추가 드롭존, 티어표 에디터의 커스텀 이미지 드롭존에 `add_photo_alternate` 아이콘을 넣어 업로드 영역임을 더 빠르게 인식할 수 있게 정리함.
|
||||
- 관리자와 에디터 드롭존은 점선 보더 굵기, 라운드, 밝은 배경 톤, 활성화 상태 색 변화, 파일 선택 버튼 크기를 같은 계열로 맞춰 서로 다른 화면에서도 같은 업로드 컴포넌트처럼 읽히도록 통일함.
|
||||
- 썸네일 드롭존 역시 배경을 일반 입력 필드보다 더 밝고 넓은 업로드 박스처럼 보이게 조정해, 일반 폼 필드와 대표 이미지 교체 영역을 시각적으로 더 분명하게 구분함.
|
||||
|
||||
## 2026-04-02 v1.3.60
|
||||
- 관리자 게임 관리의 대표 썸네일 드롭존은 이제 썸네일이 없을 때는 `클릭 & 드래그`, 이미 등록된 썸네일이 있을 때는 `썸네일 변경`으로 문구가 바뀌어 현재 동작을 더 바로 읽을 수 있게 함.
|
||||
- 관리자 인증 상태는 라우터 가드와 앱 셸이 동시에 `/api/auth/me`를 호출할 때, 가드가 아직 끝나지 않은 요청을 기다리지 못해 새로고침 직후 홈으로 튕기던 흐름이 있었으므로 인증 스토어에서 진행 중인 `refresh` Promise를 재사용하도록 정리함.
|
||||
- 따라서 관리자 계정으로 로그인된 상태에서는 `/admin/...` 경로를 새로고침해도 세션 확인이 끝날 때까지 같은 요청을 기다린 뒤 관리자 화면에 남도록 안정성을 보강함.
|
||||
- 티어표 만들기 화면의 보드 드롭존은 점선 테두리, 더 높은 박스, 중앙 정렬된 안내 문구와 버튼을 적용해 커스텀 이미지 추가 영역임을 더 즉시 인식할 수 있게 조정함.
|
||||
|
||||
## 2026-04-02 v1.3.59
|
||||
- 관리자 템플릿 요청의 `promote-items` 처리에서는 잘못된 `z.record` 스키마 때문에 500이 나던 서버 파싱 버그를 수정하고, 요청 아이템 `src`까지 함께 받아 실제 요청 데이터와 더 안정적으로 매칭하도록 보강함.
|
||||
- 요청 아이템을 게임에 반영할 때는 이제 같은 게임 안에 동일한 `src`가 이미 있으면 새 기본 아이템을 다시 만들지 않도록 막고, 관리자 화면에서도 이미 반영된 요청 아이템은 드래프트에 다시 올리지 않게 정리함.
|
||||
- 신규 템플릿 요청으로 새 게임을 한 번 만들면 해당 요청과 새 게임을 연결해 저장하고, 이후 같은 요청에서 다시 `확인하기`를 눌렀을 때는 새 게임을 또 만들지 않고 기존에 연결된 게임으로 바로 복귀하도록 흐름을 정리함.
|
||||
- 따라서 요청 카드와 게임 관리 작업 패널에서는 `연결된 게임`, `이미 반영 n개` 같은 상태를 함께 보여, 처리 완료 전에도 현재 진행 정도와 재작업 위험을 더 쉽게 구분할 수 있게 함.
|
||||
|
||||
## 2026-04-02 v1.3.55
|
||||
- 관리자 요청 카드 오른쪽 상단의 `신규 템플릿 / 보유 템플릿` 배지는 서로 다른 색상으로 분리해, 카드 타입을 텍스트보다 더 빠르게 구분할 수 있게 조정함.
|
||||
- 게임 관리의 기본 아이템 추가 미리보기에서도 `요청 아이템 / 직접 추가 파일` 배지를 서로 다른 색상으로 구분해, 요청 반영분과 직접 업로드분이 한눈에 섞이지 않도록 정리함.
|
||||
- 신규 템플릿 요청에서 `새 게임 만들기`를 진행할 때는 요청 티어표 대표 썸네일도 함께 새 게임 썸네일로 복사되도록 보강해, 관리자가 이후 수정하더라도 초기 식별용 썸네일은 바로 이어받을 수 있게 함.
|
||||
|
||||
## 2026-04-02 v1.3.54
|
||||
- 관리자 `티어표 관리` 요청 카드에서는 사용법 힌트 문구와 중복 타입 텍스트를 제거해, 카드 본문이 관리 정보만 더 빠르게 읽히도록 정리함.
|
||||
- `신규 템플릿 / 보유 템플릿` 구분은 카드 오른쪽 상단의 별도 배지로 옮기고, 기존 `추가 아이템 / 확인함 여부` 배지는 그대로 유지해 정보 계층을 더 단순하게 맞춤.
|
||||
- `요청 티어표 보기` 링크는 하단 액션 줄의 왼쪽으로 옮기고 `확인하기 / 처리 완료` 버튼은 오른쪽에 정렬해, 실제 작업 버튼과 보조 링크의 역할이 한 줄 안에서도 분명하게 보이도록 조정함.
|
||||
|
||||
## 2026-04-02 v1.3.53
|
||||
- 관리자 리팩터링 4차로 `목록 관리` 정렬 로직과 `아이템 관리` 모달/삭제/승격 액션을 각각 `useAdminFeaturedGames`, `useAdminCustomItems` composable로 분리해 `AdminView.vue`의 직접 액션 코드를 더 줄임.
|
||||
- 따라서 관리자 메인 뷰는 섹션 연결과 공통 상태 중심으로 더 가까워졌고, 상단 고정 게임 정렬과 커스텀 아이템 처리 흐름은 각 영역 책임에 맞는 파일로 옮겨 유지보수 범위를 좁힘.
|
||||
|
||||
## 2026-04-02 v1.3.52
|
||||
- 관리자 본문 섹션을 컴포넌트로 나눈 뒤 `AdminView.vue` 스타일이 `scoped`에 묶여 자식 컴포넌트까지 제대로 닿지 않던 문제를 정리하고, 관리자 전용 공통 스타일을 `adminUiScope` 범위로 다시 묶어 각 페이지 CSS가 함께 살아나도록 보강함.
|
||||
- 템플릿 요청 카드의 신규 게임 입력 영역에는 `게임 이름 / 게임 ID` 필드 스타일을 다시 붙여, 요청 카드만 따로 풀린 것처럼 보이던 레이아웃을 복구함.
|
||||
- 관리자 사이드바의 `전체 티어표 관리` 모드는 내부 값이 `lists`와 `all`로 엇갈리던 상태를 `all` 기준으로 통일해, 버튼 활성 상태와 실제 목록 전환이 어긋나지 않게 정리함.
|
||||
- 운영 이력 정합성을 위해 누락돼 있던 릴리스 태그도 다시 점검하고, `v1.3.50`, `v1.3.51`, `v1.3.52` 흐름으로 이어서 관리함.
|
||||
|
||||
## 2026-04-02 v1.3.51
|
||||
- 관리자 리팩터링 3차로 회원 관리 액션을 `useAdminUsers` composable로 분리해, 아바타 변경, 회원 정보 수정, 비밀번호 초기화, 권한 변경, 삭제 모달 흐름을 `AdminView.vue` 밖으로 옮김.
|
||||
- 따라서 관리자 메인 뷰는 섹션 연결과 공통 상태에 더 집중하고, 회원 관리 로직은 다른 관리자 영역과 같은 composable 분리 기준으로 맞추기 시작함.
|
||||
- 이번 정리에서도 관리자 화면에 직접 반영돼 있던 텍스트와 게임 관리 CSS 수정분은 유지한 채 구조만 옮기도록 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.50
|
||||
- 관리자 `템플릿 요청 관리` 카드에서는 대표 썸네일을 다시 복구해 게임 이름/ID와 함께 요청 대상을 더 빠르게 식별할 수 있게 정리함.
|
||||
- `확인하기` 후 게임을 불러오면서 요청 아이템 임시 목록이 비워지던 흐름을 수정하고, 신규 게임 생성 직후에도 요청 아이템이 기본 아이템 추가 미리보기에 유지되도록 보강함.
|
||||
- 관리자 상단 작업 모드는 `/admin/featured`, `/admin/games`, `/admin/items`, `/admin/tierlists`, `/admin/users` 경로로 나눠 뒤로가기 시 관리자 밖으로 바로 이탈하던 흐름을 줄임.
|
||||
- 관리자 경로는 이제 라우터 가드에서 로그인/관리자 여부를 먼저 확인하고, 권한이 없으면 관리자 화면 자체에 접근하지 못하도록 홈으로 되돌림.
|
||||
- 티어표 에디터의 커스텀 아이템 이름 편집 목록은 입력 중 실시간 라벨 정렬을 제거해, 입력 도중 포커스가 풀리거나 글자가 끊기던 현상을 막음.
|
||||
- 게임 기본 아이템은 최신 추가 항목이 앞에 오도록 기본 정렬 기준을 바꾸고, 관리자 게임 관리 화면에서 현재 목록을 그대로 드래그해 순서를 저장할 수 있게 함.
|
||||
- 관리자 대형 단일 뷰 정리를 시작하면서 `목록/게임/아이템/티어표/회원 관리` 본문을 섹션 컴포넌트로 분리해, `AdminView.vue`는 상태·모달·사이드바 중심 셸로 가볍게 정리함.
|
||||
- 관리자 리팩터링 2차로 `게임 관리`와 `템플릿 요청 처리` 로직을 `useAdminGameManager`, `useAdminTemplateRequests` composable로 분리해, `AdminView.vue` 스크립트에서도 섹션별 책임이 더 명확해지도록 정리함.
|
||||
|
||||
## 2026-04-01 v1.3.49
|
||||
- 티어표 에디터의 템플릿 요청 흐름은 저장된 티어표를 기준으로만 요청을 보낼 수 있도록 다시 정리하고, 요청 모달의 `내 티어리스트에도 저장` 분기는 제거함. 제목이 비어 있는 상태에서 저장하면 `직접 티어표 만들기` 대신 랜덤한 고유 제목을 먼저 부여해 저장본을 만들고, 그 이후에만 템플릿 요청 버튼이 노출되도록 맞춤.
|
||||
- 관리자 `사용자 템플릿 요청` 카드는 `전체 티어표 관리`와 같은 카드 문법으로 유지하되, 썸네일은 상단에 고정된 클릭 진입점으로 다시 정리하고 카드 본문과 별도 입력 영역의 밀도를 맞춤.
|
||||
- 템플릿 요청 미리보기는 일반 티어표 완성본과 같은 보드 문법으로 다시 구성하고, `cells` 기반 배치 아이템도 남은 아이템 계산에 정확히 반영해 요청 미리보기와 일반 완성본 보기의 차이를 줄임.
|
||||
## 2026-04-01 v1.3.48
|
||||
- 관리자 화면은 새로고침 직후에도 `티어표 관리 / 회원 관리` 목록이 비지 않도록, 관리자 인증이 확정되거나 탭이 바뀔 때 해당 목록을 다시 불러오는 흐름으로 보강함.
|
||||
- 관리자 아이템 모달은 내부 스크롤바를 숨기고 스크롤 체인을 끊어 배경이 함께 움직이지 않게 했고, 게임 선택 패널과 본문 패널의 상단 정렬도 다시 맞춤.
|
||||
- 템플릿 요청 미리보기는 누락돼 있던 `requestPreview__frame / __header` 스타일을 보강해 일반 티어표 완성본과 더 비슷한 내부 프레임 구조와 보드 밀도로 다시 정리함.
|
||||
## 2026-04-01 v1.3.47
|
||||
- 관리자 `사용자 템플릿 요청` 카드는 별도 요청 전용 레이아웃 대신 `전체 티어표 관리`와 같은 카드 문법으로 맞추고, 왼쪽 썸네일 클릭으로 같은 미리보기 모달이 열리도록 정리함.
|
||||
- 새 템플릿 요청에는 썸네일 아래에 `게임 이름 / 게임 ID` 입력을 두고, 초기 `게임 ID`는 `new-template` 대신 요청 ID 기반의 임시 고유값으로 채워 나중에 수정하기 쉽게 바꿈.
|
||||
- 요청 카드 오른쪽에는 제목, 설명, 요청 메타, 추가 아이템 목록, 승인/반려 버튼을 같은 정보 계층으로 배치해 전체 티어표 관리와 읽는 흐름을 통일함.
|
||||
|
||||
## 2026-04-01 v1.3.46
|
||||
- 관리자 `전체 티어표 관리`의 썸네일 영역은 카드 좌측 전체가 눌리는 버튼처럼 보이지 않도록 이미지 영역만 상단에 붙여 클릭 진입점으로 유지하고, 카드 본문과의 시각적 분리를 다시 다듬음.
|
||||
- `템플릿 요청 관리` 미리보기는 별도 썸네일 요약형이 아니라, 제목·설명·행/열 보드·남은 아이템이 하나의 내부 프레임 안에서 이어지는 실제 티어표 완성본형 레이아웃으로 다시 정리함.
|
||||
|
||||
## 2026-04-01 v1.3.45
|
||||
- 템플릿 요청에서 `내 티어리스트에도 저장`이 켜져 있을 때 발생하던 500 오류는 새 저장본 생성 시 `tierlists.id`에 `undefined`가 들어가던 문제였고, 이제 `saveTierList()`가 생성 시 자동으로 `nanoid()`를 부여하도록 고쳐 저장 분기 자체를 안정화함.
|
||||
- 사용자에게 노출되던 `백엔드를 재시작해주세요` 문구는 제거하고, 저장 분기 실패 시에도 일반적인 재시도 안내만 보이도록 조정함.
|
||||
@@ -736,6 +1209,19 @@
|
||||
- **아이템 카드 레이아웃 개선**: 아이템 목록과 추가 미리보기를 1:1 비율 기준으로 재구성하고 더 촘촘한 카드 그리드로 조정
|
||||
- **레거시 파일 역할 정리**: `db.json`과 lowdb 관련 코드는 현재 MariaDB 기본 런타임에는 필수가 아니며, 마이그레이션/예외 fallback 용도임을 문서에 명시
|
||||
|
||||
## 2026-04-03 v1.4.65
|
||||
- **파비콘 403 재발 차단**: 운영 환경에서 `/favicon.svg`, `/favicon-32x32.png` 정적 요청이 계속 `403 Forbidden`으로 떨어지던 문제를 피하기 위해, 브라우저 탭 파비콘을 다시 `index.html` 인라인 SVG 데이터 URL로 전환하고 해당 정적 favicon 링크를 제거
|
||||
- **광고 스크립트 외부 DNS 오류 분리**: `e.dlx.addthis.com ... net::ERR_NAME_NOT_RESOLVED`는 애드센스/광고 네트워크에서 발생한 외부 도메인 해석 실패 로그로, 서비스 파비콘/관리자 API 오류와는 별개 현상으로 분리
|
||||
|
||||
## 2026-04-03 v1.4.64
|
||||
- **신규 DB 관리자 페이지 500 수정**: 빈 DB를 새로 만든 직후 `/admin/...` 진입 시 `GET /api/admin/template-requests`와 `GET /api/admin/image-assets/stats`가 500으로 터지던 문제를 수정
|
||||
- **`template_requests` 초기 스키마 보정**: 새 테이블 생성 정의에 누락돼 있던 `groups_json`, `board_items_json`, `show_character_names_snapshot` 컬럼을 추가하고, `source_tierlist_id`는 요청 종류에 따라 비어 있을 수 있도록 `NULL` 허용으로 정리
|
||||
- **빈 DB 재현 검증**: 로컬 MariaDB를 `DROP DATABASE → CREATE DATABASE → ensureData()`로 다시 초기화한 뒤 `listAdminTemplateRequests()`가 `[]`, `getImageAssetStats()`가 0값 통계를 반환하는 것까지 직접 확인
|
||||
|
||||
## 2026-04-03 v1.4.63
|
||||
- **우측 레일 Teleport 전환 안정화**: 관리자/에디터 전용 우측 패널이 사용하는 `#local-right-rail-root` DOM을 라우트에 따라 생성/삭제하지 않고 항상 유지하도록 바꿔, `/admin/...`에서 설정/다른 페이지로 이동하거나 새로고침 후 화면을 바꿀 때 Vue가 `nextSibling`/`emitsOptions` 기준점을 잃고 크래시하는 문제를 방지
|
||||
- **정적 favicon 403 분리 확인**: 프런트 빌드 기준 `favicon.svg`, `favicon-32x32.png`, `apple-touch-icon.png` 파일은 레포와 Vite `public/` 출력에 존재함을 확인했고, 운영 환경의 favicon `403 Forbidden`은 코드 누락보다 컨테이너/정적 서빙/프록시 권한 쪽 후속 점검 항목으로 분리
|
||||
|
||||
## 2026-03-19 v0.1.7
|
||||
- **AI 작업 규칙 보강**: `ai-rules.md`에 Git 작성자 정보, 한국어 커밋 메시지, 버전/태그 동기화, 민감 정보 확인 규칙 추가
|
||||
- **관리자 화면 재구성**: `/admin`을 좌우 병렬 구조에서 `모드 선택 → 게임 선택/생성 → 선택된 게임 상세 관리` 흐름으로 재구성
|
||||
@@ -768,6 +1254,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`)
|
||||
|
||||
@@ -1,13 +1,45 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tier Maker | 템플릿으로 쉽게 만드는 티어표</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="템플릿과 커스텀 이미지로 티어표를 만들고 저장하고 공유하세요. 관리자 요청과 템플릿 업데이트 흐름까지 한 번에 관리할 수 있습니다."
|
||||
/>
|
||||
<meta name="theme-color" content="#090d16" />
|
||||
<meta name="application-name" content="Tier Maker" />
|
||||
|
||||
<link rel="canonical" href="https://tmaker.sori.studio/" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%230b1220'/%3E%3Cpath d='M18 18h28v8H36v20h-8V26H18z' fill='%23f8fafc'/%3E%3C/svg%3E"
|
||||
type="image/svg+xml"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='18' fill='%23090d16'/%3E%3Cpath d='M17 15h30v10H36v24H26V25H17V15Z' fill='%237fe7d6'/%3E%3Cpath d='M39 31h8v18h-8V31Z' fill='%235fcaff' opacity='.9'/%3E%3C/svg%3E"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tier Maker</title>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
|
||||
<meta property="og:site_name" content="Tier Maker" />
|
||||
<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:description"
|
||||
content="템플릿과 커스텀 이미지로 티어표를 만들고 저장하고 공유하세요. 관리자 요청과 템플릿 업데이트 흐름까지 한 번에 관리할 수 있습니다."
|
||||
/>
|
||||
<meta property="og:image" content="https://tmaker.sori.studio/og-card.png" />
|
||||
<meta property="og:image:alt" content="Tier Maker 공유 썸네일" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<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:description"
|
||||
content="템플릿과 커스텀 이미지로 티어표를 만들고 저장하고 공유하세요."
|
||||
/>
|
||||
<meta name="twitter:image" content="https://tmaker.sori.studio/og-card.png" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
client_max_body_size 1024m;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
BIN
frontend/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
frontend/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
@@ -1,24 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB |
BIN
frontend/public/og-card.png
Normal file
|
After Width: | Height: | Size: 613 KiB |
69
frontend/public/og-card.svg
Normal file
|
After Width: | Height: | Size: 32 KiB |
@@ -2,6 +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, 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'
|
||||
@@ -9,8 +10,11 @@ import iconDockToRight from './assets/icons/dock_to_right.svg'
|
||||
import iconGridView from './assets/icons/grid_view.svg'
|
||||
import iconFavorite from './assets/icons/favorite.svg'
|
||||
import iconLists from './assets/icons/lists.svg'
|
||||
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'
|
||||
@@ -19,23 +23,30 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const { toasts, dismissToast } = useToast()
|
||||
const RIGHT_RAIL_COPYRIGHT_URL = 'https://x.com/zennbox'
|
||||
const currentTopicId = computed(() => route.params.topicId || '')
|
||||
|
||||
const leftRailCollapsed = ref(false)
|
||||
const rightRailOpen = ref(true)
|
||||
const searchQuery = ref('')
|
||||
const searchPlaceholder = computed(() => (route.name === 'home' ? '게임 템플릿 검색' : '전체 티어표 검색'))
|
||||
const leftRailSearchPlaceholder = '주제 템플릿 검색'
|
||||
const isCollapsedSearchOpen = ref(false)
|
||||
const isGuideModalOpen = ref(false)
|
||||
const themeMode = ref('dark')
|
||||
const guideStepIndex = ref(0)
|
||||
const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1440)
|
||||
const backendState = ref('online')
|
||||
const backendMessage = ref('')
|
||||
provide('rightRailOpen', rightRailOpen)
|
||||
provide('localRightRailTarget', '#local-right-rail-root')
|
||||
|
||||
const authReady = computed(() => auth.hydrated)
|
||||
const isAdmin = computed(() => authReady.value && !!auth.user?.isAdmin)
|
||||
const isPreviewMode = computed(() => route.query.preview === '1')
|
||||
const usesLocalRightRail = computed(() => ['editEditor', 'newEditor', 'admin'].includes(String(route.name || '')))
|
||||
const isAdminRoute = computed(() => String(route.name || '').startsWith('admin'))
|
||||
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) : ''))
|
||||
@@ -56,22 +67,24 @@ const shellStyle = computed(() => ({
|
||||
}))
|
||||
const leftNavItems = computed(() => {
|
||||
const items = [
|
||||
{ key: 'home', label: 'Games', path: '/', iconSrc: iconGridView },
|
||||
{ key: 'me', label: 'My Lists', path: '/me', iconSrc: iconLists, requiresAuth: true },
|
||||
{ key: 'favorites', label: 'Favorites', path: '/favorites', iconSrc: iconFavorite, requiresAuth: true },
|
||||
{ key: 'profile', label: 'Settings', path: '/profile', iconSrc: iconSettings, requiresAuth: true },
|
||||
{ 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))
|
||||
})
|
||||
const activeLeftNavIndex = computed(() => leftNavItems.value.findIndex((item) => isRouteActive(item.path)))
|
||||
const showRightRailAction = computed(() => false)
|
||||
const showSettingsGuideButton = computed(() => route.name === 'profile')
|
||||
const guideSteps = [
|
||||
{
|
||||
id: 'select-game',
|
||||
title: '게임 또는 양식 선택',
|
||||
summary: '게임 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작합니다.',
|
||||
id: 'select-topic',
|
||||
title: '주제 또는 양식 선택',
|
||||
summary: '주제 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작합니다.',
|
||||
description:
|
||||
'홈 화면에서는 게임 템플릿을 선택하거나 커스텀 티어표 만들기로 바로 새 보드를 열 수 있어요. 게임을 먼저 고르면 해당 게임의 공개 티어표도 같이 살펴볼 수 있어서, 완전히 처음 만드는지 기존 흐름을 참고할지 결정하기 쉽습니다.',
|
||||
'홈 화면에서는 주제 템플릿을 선택하거나 커스텀 티어표 만들기로 바로 새 보드를 열 수 있어요. 원하는 주제를 먼저 고르면 해당 주제의 공개 티어표도 같이 살펴볼 수 있어서, 완전히 처음 만드는지 기존 흐름을 참고할지 결정하기 쉽습니다.',
|
||||
},
|
||||
{
|
||||
id: 'arrange-board',
|
||||
@@ -85,7 +98,7 @@ const guideSteps = [
|
||||
title: '아이템 배치와 커스텀 추가',
|
||||
summary: '프리셋 아이템과 직접 올린 이미지를 드래그로 배치합니다.',
|
||||
description:
|
||||
'오른쪽 아이템 영역의 이미지를 원하는 칸으로 끌어다 놓으면 바로 배치됩니다. 게임 템플릿에 없는 이미지는 커스텀 이미지로 추가해 같이 쓸 수 있고, 이름 표시 옵션을 켜면 결과 이미지를 더 설명적으로 정리할 수 있어요.',
|
||||
'오른쪽 아이템 영역의 이미지를 원하는 칸으로 끌어다 놓으면 바로 배치됩니다. 주제 템플릿에 없는 이미지는 커스텀 이미지로 추가해 같이 쓸 수 있고, 이름 표시 옵션을 켜면 결과 이미지를 더 설명적으로 정리할 수 있어요.',
|
||||
},
|
||||
{
|
||||
id: 'save-share',
|
||||
@@ -104,23 +117,23 @@ const guideSteps = [
|
||||
{
|
||||
id: 'request-template-update',
|
||||
title: '템플릿 업그레이드 요청',
|
||||
summary: '현재 게임 템플릿에 공통 아이템을 추가해 달라고 관리자에게 요청합니다.',
|
||||
summary: '현재 템플릿에 공통 아이템을 추가해 달라고 관리자에게 요청합니다.',
|
||||
description:
|
||||
'직접 추가한 아이템 중 여러 사람이 함께 써도 좋을 것 같은 항목이 있다면 템플릿 업데이트 요청을 보낼 수 있어요. 요청 모달에서는 현재 티어표 제목과 설명을 기본값으로 가져오고, 필요하면 요청 제목과 설명을 더 다듬어 공통 템플릿에 왜 필요한지 설명할 수 있습니다.',
|
||||
},
|
||||
{
|
||||
id: 'request-new-template',
|
||||
title: '새 템플릿 추가 요청',
|
||||
summary: '아직 없는 게임이나 새로운 양식을 관리자에게 제안합니다.',
|
||||
summary: '아직 없는 주제나 새로운 양식을 관리자에게 제안합니다.',
|
||||
description:
|
||||
'원하는 게임 템플릿이 아직 없다면 새 템플릿 추가 요청으로 관리자에게 직접 제안할 수 있어요. 이때는 제목과 설명에 어떤 게임인지, 어떤 캐릭터나 항목이 기본으로 필요할지 적어두면 검토 속도가 훨씬 빨라집니다.',
|
||||
'원하는 주제 템플릿이 아직 없다면 새 템플릿 추가 요청으로 관리자에게 직접 제안할 수 있어요. 이때는 제목과 설명에 어떤 주제인지, 어떤 캐릭터나 항목이 기본으로 필요할지 적어두면 검토 속도가 훨씬 빨라집니다.',
|
||||
},
|
||||
{
|
||||
id: 'manage-library',
|
||||
title: '즐겨찾기와 내 티어표 관리',
|
||||
summary: '마음에 드는 템플릿과 저장한 결과물을 나중에 다시 쉽게 찾습니다.',
|
||||
description:
|
||||
'게임 템플릿은 즐겨찾기로 상단에 고정해둘 수 있고, 저장한 보드는 내 티어표에서 다시 열어 이어서 수정할 수 있어요. 자주 보는 템플릿, 공개 티어표, 내가 만든 결과물을 각각 다른 화면에서 정리해두면 이후 작업이 훨씬 빨라집니다.',
|
||||
'주제 템플릿은 즐겨찾기로 상단에 고정해둘 수 있고, 저장한 보드는 내 티어표에서 다시 열어 이어서 수정할 수 있어요. 자주 보는 템플릿, 공개 티어표, 내가 만든 결과물을 각각 다른 화면에서 정리해두면 이후 작업이 훨씬 빨라집니다.',
|
||||
},
|
||||
]
|
||||
const currentGuideStep = computed(() => guideSteps[guideStepIndex.value] || guideSteps[0])
|
||||
@@ -128,17 +141,18 @@ const isGuidePrevDisabled = computed(() => guideStepIndex.value <= 0)
|
||||
const isGuideNextDisabled = computed(() => guideStepIndex.value >= guideSteps.length - 1)
|
||||
const isLightTheme = computed(() => themeMode.value === 'light')
|
||||
const themeToggleLabel = computed(() => (isLightTheme.value ? '다크 모드' : '라이트 모드'))
|
||||
const showSettingsThemePanel = computed(() => false && route.name === 'profile')
|
||||
const showGameHubViewToggle = computed(() => route.name === 'gameHub')
|
||||
const gameHubViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
|
||||
const showSettingsThemePanel = computed(() => route.name === 'profile')
|
||||
const showTopicViewToggle = computed(() => route.name === 'topicHub')
|
||||
const topicViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
|
||||
const showBackendFallback = computed(() => !isPreviewMode.value && ['maintenance', 'offline'].includes(backendState.value))
|
||||
const leftBottomPrimaryAction = computed(() => {
|
||||
if (!authReady.value) return null
|
||||
if (route.name === 'home' && auth.user) {
|
||||
return { label: '커스텀 티어표 만들기', to: '/editor/freeform/new' }
|
||||
return { label: '커스텀 티어표 만들기', to: editorNewPath('freeform'), iconSrc: iconDashboardCustomize }
|
||||
}
|
||||
if (route.name === 'gameHub') {
|
||||
const target = `/editor/${route.params.gameId}/new`
|
||||
return { label: '새 티어표 만들기', to: auth.user ? target : `/login?redirect=${target}` }
|
||||
if (route.name === 'topicHub') {
|
||||
const target = editorNewPath(currentTopicId.value)
|
||||
return { label: '새 티어표 만들기', to: auth.user ? target : loginPath(target), iconSrc: iconAddNotes }
|
||||
}
|
||||
return null
|
||||
})
|
||||
@@ -146,96 +160,116 @@ const leftBottomPrimaryAction = computed(() => {
|
||||
const routeMeta = computed(() => {
|
||||
if (route.name === 'home') {
|
||||
return {
|
||||
title: 'Tier Maker',
|
||||
subtitle: '게임 템플릿 선택과 커스텀 보드 시작',
|
||||
title: '주제 선택',
|
||||
subtitle: '주제 템플릿 선택과 커스텀 보드 시작',
|
||||
contextTitle: '빠른 시작',
|
||||
contextText: auth.user ? '커스텀 티어표를 만들거나 원하는 게임을 바로 선택할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
|
||||
contextText: auth.user ? '커스텀 티어표를 만들거나 원하는 주제를 바로 선택할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
|
||||
actionLabel: auth.user ? '커스텀 티어표 만들기' : '로그인하러 가기',
|
||||
action: () => {
|
||||
router.push(auth.user ? '/editor/freeform/new' : '/login')
|
||||
router.push(auth.user ? editorNewPath('freeform') : loginPath())
|
||||
},
|
||||
}
|
||||
}
|
||||
if (route.name === 'gameHub') {
|
||||
if (route.name === 'topicHub') {
|
||||
return {
|
||||
title: 'Game Boards',
|
||||
subtitle: '게임별 공개 티어표 탐색',
|
||||
title: '주제 티어표',
|
||||
subtitle: '주제별 공개 티어표 탐색',
|
||||
contextTitle: '작성 작업',
|
||||
contextText: auth.user ? '이 게임의 새 티어표를 만들거나 기존 공개 티어표를 확인할 수 있어요.' : '로그인 후 새 티어표를 만들 수 있어요.',
|
||||
contextText: auth.user ? '이 주제의 새 티어표를 만들거나 기존 공개 티어표를 확인할 수 있어요.' : '로그인 후 새 티어표를 만들 수 있어요.',
|
||||
actionLabel: auth.user ? '새 티어표 만들기' : '로그인하러 가기',
|
||||
action: () => {
|
||||
const target = `/editor/${route.params.gameId}/new`
|
||||
router.push(auth.user ? target : `/login?redirect=${target}`)
|
||||
const target = editorNewPath(currentTopicId.value)
|
||||
router.push(auth.user ? target : loginPath(target))
|
||||
},
|
||||
}
|
||||
}
|
||||
if (route.name === 'editEditor' || route.name === 'newEditor') {
|
||||
return {
|
||||
title: 'Deck Builder',
|
||||
title: '티어표 만들기',
|
||||
subtitle: '티어표 편집 및 공유',
|
||||
contextTitle: '편집 패널',
|
||||
contextText: '현재 편집 옵션은 중앙 화면 안에 유지되어 있습니다. 다음 단계에서 우측 패널로 정리해갈게요.',
|
||||
actionLabel: '게임 목록으로',
|
||||
action: () => router.push('/'),
|
||||
actionLabel: '주제 목록으로',
|
||||
action: () => router.push(homePath()),
|
||||
}
|
||||
}
|
||||
if (route.name === 'admin') {
|
||||
if (isAdminRoute.value) {
|
||||
return {
|
||||
title: 'Admin Workspace',
|
||||
subtitle: '게임·아이템·회원 관리',
|
||||
title: '관리자 작업실',
|
||||
subtitle: '템플릿·아이템·회원 관리',
|
||||
contextTitle: '운영 노트',
|
||||
contextText: '관리자 화면은 기능이 많아 우선 공통 셸 톤을 맞췄고, 세부 패널은 다음 단계에서 시안 방식으로 더 세밀하게 나눌 예정입니다.',
|
||||
actionLabel: '게임 목록으로',
|
||||
action: () => router.push('/'),
|
||||
actionLabel: '주제 목록으로',
|
||||
action: () => router.push(homePath()),
|
||||
}
|
||||
}
|
||||
if (route.name === 'me') {
|
||||
return {
|
||||
title: 'My Lists',
|
||||
subtitle: '내가 저장한 티어표',
|
||||
title: '나의 티어표',
|
||||
subtitle: '저장한 티어표 모아보기',
|
||||
contextTitle: '작성 이력',
|
||||
contextText: '최근 저장한 티어표를 열람하고 정리할 수 있어요.',
|
||||
actionLabel: '즐겨찾기 보기',
|
||||
action: () => router.push('/favorites'),
|
||||
action: () => router.push(favoritesPath()),
|
||||
}
|
||||
}
|
||||
if (route.name === 'favorites') {
|
||||
return {
|
||||
title: 'Favorites',
|
||||
title: '즐겨찾기',
|
||||
subtitle: '마음에 드는 티어표 모음',
|
||||
contextTitle: '정리 도구',
|
||||
contextText: '정렬과 검색으로 원하는 티어표를 빠르게 다시 찾을 수 있어요.',
|
||||
actionLabel: '내 티어표 보기',
|
||||
action: () => router.push('/me'),
|
||||
actionLabel: '나의 티어표 보기',
|
||||
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: 'Profile',
|
||||
title: '설정',
|
||||
subtitle: '프로필 및 계정 설정',
|
||||
contextTitle: '계정 관리',
|
||||
contextText: '아바타와 닉네임을 관리하고 표시 정보를 확인할 수 있어요.',
|
||||
actionLabel: '내 티어표 보기',
|
||||
action: () => router.push('/me'),
|
||||
actionLabel: '나의 티어표 보기',
|
||||
action: () => router.push(mePath()),
|
||||
}
|
||||
}
|
||||
if (route.name === 'search') {
|
||||
return {
|
||||
title: 'Search',
|
||||
title: '검색',
|
||||
subtitle: '전체 공개 티어표 검색 결과',
|
||||
contextTitle: '검색',
|
||||
contextText: '제목, 작성자 기준으로 공개 티어표를 전체 검색할 수 있어요.',
|
||||
actionLabel: '홈으로',
|
||||
action: () => router.push('/'),
|
||||
action: () => router.push(homePath()),
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: 'Tier Maker',
|
||||
subtitle: 'by zenn',
|
||||
contextTitle: 'Workspace',
|
||||
subtitle: '주제 템플릿으로 만드는 티어표',
|
||||
contextTitle: '작업 공간',
|
||||
contextText: '현재 화면에 맞는 도구와 안내를 여기에 배치할 수 있습니다.',
|
||||
actionLabel: '홈으로',
|
||||
action: () => router.push('/'),
|
||||
action: () => router.push(homePath()),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -244,6 +278,13 @@ function syncViewportWidth() {
|
||||
viewportWidth.value = window.innerWidth
|
||||
}
|
||||
|
||||
function handleBackendStatus(event) {
|
||||
const state = event?.detail?.state
|
||||
if (!state) return
|
||||
backendState.value = state
|
||||
backendMessage.value = typeof event?.detail?.message === 'string' ? event.detail.message : ''
|
||||
}
|
||||
|
||||
function applyTheme(mode) {
|
||||
themeMode.value = mode === 'light' ? 'light' : 'dark'
|
||||
if (typeof document !== 'undefined') document.documentElement.dataset.theme = themeMode.value
|
||||
@@ -258,11 +299,12 @@ onMounted(async () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedTheme = window.localStorage.getItem('tier-maker:theme')
|
||||
if (savedTheme === 'light' || savedTheme === 'dark') applyTheme(savedTheme)
|
||||
else applyTheme(window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark')
|
||||
else applyTheme('dark')
|
||||
}
|
||||
await auth.refresh()
|
||||
if (typeof window !== 'undefined') {
|
||||
syncViewportWidth()
|
||||
window.addEventListener('tier-maker:backend-status', handleBackendStatus)
|
||||
window.addEventListener('resize', syncViewportWidth)
|
||||
window.addEventListener('keydown', handleGlobalKeydown)
|
||||
const leftSaved = window.localStorage.getItem('tier-maker:left-rail-collapsed')
|
||||
@@ -285,6 +327,7 @@ function handleGlobalKeydown(event) {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('tier-maker:backend-status', handleBackendStatus)
|
||||
window.removeEventListener('resize', syncViewportWidth)
|
||||
window.removeEventListener('keydown', handleGlobalKeydown)
|
||||
}
|
||||
@@ -339,8 +382,8 @@ function toggleRightRail() {
|
||||
}
|
||||
}
|
||||
|
||||
function setGameHubViewMode(mode) {
|
||||
if (route.name !== 'gameHub') return
|
||||
function setTopicViewMode(mode) {
|
||||
if (route.name !== 'topicHub') return
|
||||
const nextQuery = { ...route.query }
|
||||
if (mode === 'list') nextQuery.view = 'list'
|
||||
else delete nextQuery.view
|
||||
@@ -390,11 +433,12 @@ function handleLeftRailSearch() {
|
||||
function submitGlobalSearch() {
|
||||
const query = (searchQuery.value || '').trim()
|
||||
isCollapsedSearchOpen.value = false
|
||||
if (route.name === 'home') {
|
||||
router.push(query ? `/?q=${encodeURIComponent(query)}` : '/')
|
||||
return
|
||||
}
|
||||
router.push(query ? `/search?q=${encodeURIComponent(query)}` : '/search')
|
||||
router.push(homePath(query))
|
||||
}
|
||||
|
||||
function reloadApp() {
|
||||
if (typeof window === 'undefined') return
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
|
||||
@@ -404,16 +448,29 @@ function submitGlobalSearch() {
|
||||
<div
|
||||
class="appShell"
|
||||
:class="{
|
||||
'appShell--preview': isPreviewMode,
|
||||
'appShell--leftCollapsed': leftRailCollapsed,
|
||||
'appShell--rightClosed': !rightRailOpen,
|
||||
'appShell--rightOverlay': isRightRailOverlay,
|
||||
}"
|
||||
:style="shellStyle"
|
||||
>
|
||||
<template v-if="isPreviewMode">
|
||||
<main class="appMain appMain--preview">
|
||||
<RouterView />
|
||||
<template v-if="showBackendFallback">
|
||||
<main class="backendFallback">
|
||||
<section class="backendFallback__card">
|
||||
<div class="backendFallback__eyebrow">{{ backendState === 'maintenance' ? 'Maintenance' : 'Connection' }}</div>
|
||||
<h1 class="backendFallback__title">{{ backendState === 'maintenance' ? '서비스 점검 중' : '서버 연결 확인 중' }}</h1>
|
||||
<p class="backendFallback__desc">
|
||||
{{
|
||||
backendMessage ||
|
||||
(backendState === 'maintenance'
|
||||
? '백엔드 또는 데이터베이스 작업으로 인해 잠시 이용이 어렵습니다. 잠시 후 다시 시도해주세요.'
|
||||
: '네트워크 또는 서버 연결 상태를 확인한 뒤 다시 시도해주세요.')
|
||||
}}
|
||||
</p>
|
||||
<div class="backendFallback__actions">
|
||||
<button class="backendFallback__button" type="button" @click="reloadApp">다시 시도</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -428,7 +485,7 @@ function submitGlobalSearch() {
|
||||
<div class="leftRail__content">
|
||||
<div v-if="authReady && auth.user" class="appUserCard">
|
||||
<div class="appUserCard__button">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" />
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" draggable="false" />
|
||||
<div v-else class="appUserCard__avatar appUserCard__avatar--fallback">{{ accountName[0]?.toUpperCase() || 'U' }}</div>
|
||||
<div class="appUserCard__meta">
|
||||
<div class="appUserCard__name">{{ accountName }}</div>
|
||||
@@ -443,10 +500,15 @@ function submitGlobalSearch() {
|
||||
<SvgIcon :src="iconSearch" :size="24" />
|
||||
</span>
|
||||
</button>
|
||||
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : searchPlaceholder" />
|
||||
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : leftRailSearchPlaceholder" />
|
||||
</form>
|
||||
|
||||
<nav class="leftNav">
|
||||
<nav
|
||||
class="leftNav"
|
||||
:class="{ 'leftNav--hasActive': activeLeftNavIndex >= 0 }"
|
||||
:style="{ '--left-nav-active-index': String(Math.max(activeLeftNavIndex, 0)) }"
|
||||
>
|
||||
<span class="leftNav__indicator" aria-hidden="true"></span>
|
||||
<RouterLink
|
||||
v-for="item in leftNavItems"
|
||||
:key="item.key"
|
||||
@@ -467,11 +529,20 @@ function submitGlobalSearch() {
|
||||
</div>
|
||||
<div class="leftRail__bottom">
|
||||
<RouterLink v-if="leftBottomPrimaryAction" :to="leftBottomPrimaryAction.to" class="adminButton">{{ leftBottomPrimaryAction.label }}</RouterLink>
|
||||
<RouterLink
|
||||
v-if="leftBottomPrimaryAction"
|
||||
:to="leftBottomPrimaryAction.to"
|
||||
class="leftRail__collapsedAction"
|
||||
:title="leftBottomPrimaryAction.label"
|
||||
:aria-label="leftBottomPrimaryAction.label"
|
||||
>
|
||||
<SvgIcon :src="leftBottomPrimaryAction.iconSrc || iconAddNotes" :size="24" />
|
||||
</RouterLink>
|
||||
<button v-if="showSettingsGuideButton" class="adminButton adminButton--icon" type="button" @click="openGuideModal()">
|
||||
<SvgIcon :src="iconMenuBook" :size="18" class="adminButton__icon" />
|
||||
<span>가이드 보기</span>
|
||||
</button>
|
||||
<RouterLink v-if="authReady && isAdmin" to="/admin" class="adminButton">관리자 메뉴</RouterLink>
|
||||
<RouterLink v-if="authReady && isAdmin" to="/admin/featured" class="adminButton">관리자 메뉴</RouterLink>
|
||||
<RouterLink v-else-if="authReady && !auth.user" to="/login" class="adminButton">로그인</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
@@ -482,22 +553,13 @@ function submitGlobalSearch() {
|
||||
<header class="workspaceHead railHeader">
|
||||
<div class="workspaceHead__brand" @click="$router.push('/')">
|
||||
<span class="workspaceHead__brandTitle">Tier Maker</span>
|
||||
<a
|
||||
class="workspaceHead__brandSub"
|
||||
href="https://zenn.town/@murabito"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
@click.stop
|
||||
>
|
||||
by zenn
|
||||
</a>
|
||||
</div>
|
||||
<div class="workspaceHead__actions">
|
||||
<div v-if="showGameHubViewToggle" class="viewToggle" role="group" aria-label="티어표 보기 방식">
|
||||
<button class="ghostIcon ghostIcon--iconOnly" :class="{ 'ghostIcon--active': gameHubViewMode === 'grid' }" type="button" aria-label="그리드 보기" @click="setGameHubViewMode('grid')">
|
||||
<div v-if="showTopicViewToggle" class="viewToggle" role="group" aria-label="티어표 보기 방식">
|
||||
<button class="ghostIcon ghostIcon--iconOnly" :class="{ 'ghostIcon--active': topicViewMode === 'grid' }" type="button" aria-label="그리드 보기" @click="setTopicViewMode('grid')">
|
||||
<SvgIcon :src="iconGridView" :size="24" />
|
||||
</button>
|
||||
<button class="ghostIcon ghostIcon--iconOnly" :class="{ 'ghostIcon--active': gameHubViewMode === 'list' }" type="button" aria-label="리스트 보기" @click="setGameHubViewMode('list')">
|
||||
<button class="ghostIcon ghostIcon--iconOnly" :class="{ 'ghostIcon--active': topicViewMode === 'list' }" type="button" aria-label="리스트 보기" @click="setTopicViewMode('list')">
|
||||
<SvgIcon :src="iconLists" :size="24" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -512,12 +574,12 @@ function submitGlobalSearch() {
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div v-if="isCollapsedSearchOpen" class="collapsedSearchModal" role="dialog" aria-modal="true" :aria-label="searchPlaceholder" @click.self="closeCollapsedSearch">
|
||||
<div v-if="isCollapsedSearchOpen" class="collapsedSearchModal" role="dialog" aria-modal="true" :aria-label="leftRailSearchPlaceholder" @click.self="closeCollapsedSearch">
|
||||
<form class="collapsedSearchBar" @submit.prevent="submitGlobalSearch">
|
||||
<span class="collapsedSearchBar__icon">
|
||||
<SvgIcon :src="iconSearch" :size="24" />
|
||||
</span>
|
||||
<input v-model="searchQuery" class="collapsedSearchBar__input" type="search" :placeholder="searchPlaceholder" autofocus />
|
||||
<input v-model="searchQuery" class="collapsedSearchBar__input" type="search" :placeholder="leftRailSearchPlaceholder" autofocus />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -597,8 +659,12 @@ function submitGlobalSearch() {
|
||||
</div>
|
||||
<div class="rightRail__body">
|
||||
<div class="rightRail__content">
|
||||
<div v-if="usesLocalRightRail" id="local-right-rail-root" class="localRightRailRoot"></div>
|
||||
<template v-else>
|
||||
<div
|
||||
id="local-right-rail-root"
|
||||
class="localRightRailRoot"
|
||||
:class="{ 'localRightRailRoot--hidden': !usesLocalRightRail }"
|
||||
></div>
|
||||
<template v-if="!usesLocalRightRail">
|
||||
<section v-if="showSettingsThemePanel" class="settingsThemePanel">
|
||||
<div class="settingsThemePanel__eyebrow">Appearance</div>
|
||||
<div class="settingsThemePanel__title">테마 설정</div>
|
||||
@@ -620,6 +686,11 @@ function submitGlobalSearch() {
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
<div class="rightRail__footer">
|
||||
<span>Copyright © 2026 </span>
|
||||
<a :href="RIGHT_RAIL_COPYRIGHT_URL" target="_blank" rel="noreferrer">zenn</a>
|
||||
<span>. All rights reserved.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -647,8 +718,64 @@ function submitGlobalSearch() {
|
||||
transition: grid-template-columns 220ms ease;
|
||||
}
|
||||
|
||||
.appShell--preview {
|
||||
display: block;
|
||||
.backendFallback {
|
||||
min-width: 100dvw;
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 32px;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(86, 153, 255, 0.14), transparent 38%),
|
||||
var(--theme-shell-bg);
|
||||
}
|
||||
|
||||
.backendFallback__card {
|
||||
width: min(100%, 560px);
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 28px;
|
||||
border-radius: 28px;
|
||||
border: 1px solid var(--theme-card-border);
|
||||
background: var(--theme-card-bg);
|
||||
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
|
||||
}
|
||||
|
||||
.backendFallback__eyebrow {
|
||||
color: var(--theme-accent-strong);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.backendFallback__title {
|
||||
margin: 0;
|
||||
font-size: clamp(28px, 4vw, 42px);
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.backendFallback__desc {
|
||||
margin: 0;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.backendFallback__actions {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.backendFallback__button {
|
||||
min-width: 128px;
|
||||
padding: 12px 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--theme-accent-soft-strong);
|
||||
background: var(--theme-accent-soft);
|
||||
color: var(--theme-text-strong);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.leftRail,
|
||||
@@ -740,8 +867,11 @@ function submitGlobalSearch() {
|
||||
}
|
||||
|
||||
.rightRail__content {
|
||||
flex: 0 0 auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ghostIcon {
|
||||
@@ -818,7 +948,7 @@ function submitGlobalSearch() {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -903,19 +1033,45 @@ function submitGlobalSearch() {
|
||||
}
|
||||
|
||||
.leftNav {
|
||||
--left-nav-gap: 8px;
|
||||
--left-nav-item-height: 50px;
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
gap: var(--left-nav-gap);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.leftNav__indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--left-nav-item-height);
|
||||
border-radius: 14px;
|
||||
background: var(--theme-surface-soft-3);
|
||||
transform: translateY(calc(var(--left-nav-active-index, 0) * (var(--left-nav-item-height) + var(--left-nav-gap))));
|
||||
transition: transform 240ms ease, opacity 200ms ease;
|
||||
opacity: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leftNav--hasActive .leftNav__indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.leftNav__item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: var(--left-nav-item-height);
|
||||
gap: 12px;
|
||||
padding: 11px 12px;
|
||||
border-radius: 14px;
|
||||
color: var(--theme-text-muted);
|
||||
text-decoration: none;
|
||||
transition: background 180ms ease, color 180ms ease, transform 180ms ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.leftNav__label {
|
||||
@@ -928,7 +1084,6 @@ function submitGlobalSearch() {
|
||||
|
||||
.leftNav__item--active,
|
||||
.leftNav__item.router-link-active {
|
||||
background: var(--theme-surface-soft-3);
|
||||
color: var(--theme-text-strong);
|
||||
}
|
||||
|
||||
@@ -952,21 +1107,24 @@ function submitGlobalSearch() {
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .appUserCard {
|
||||
margin-bottom: 10px;
|
||||
min-height: 50px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .appUserCard__button,
|
||||
.appShell--leftCollapsed .appUserCard__guest {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
min-height: 44px;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .appUserCard__meta,
|
||||
.appShell--leftCollapsed .leftNav__label,
|
||||
.appShell--leftCollapsed .searchStub__input {
|
||||
opacity: 0;
|
||||
max-width: 0;
|
||||
transform: translateX(-4px);
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .appUserCard__avatar {
|
||||
@@ -975,26 +1133,41 @@ function submitGlobalSearch() {
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .searchStub {
|
||||
height: 50px;
|
||||
margin-bottom: 0;
|
||||
padding: 11px 0;
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .searchStub__iconButton {
|
||||
width: auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .leftNav {
|
||||
gap: 10px;
|
||||
--left-nav-gap: 10px;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .leftNav__item {
|
||||
width: 100%;
|
||||
min-height: 50px;
|
||||
height: 50px;
|
||||
padding: 11px 0;
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .leftRail__bottom {
|
||||
display: none;
|
||||
.appShell--leftCollapsed .leftNav__glyph {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .leftRail__content {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
justify-items: stretch;
|
||||
gap: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -1006,6 +1179,10 @@ function submitGlobalSearch() {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.leftRail__collapsedAction {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.adminButton {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
@@ -1030,6 +1207,29 @@ function submitGlobalSearch() {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .leftRail__bottom {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .leftRail__bottom .adminButton {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .leftRail__bottom .leftRail__collapsedAction {
|
||||
width: 100%;
|
||||
min-height: 50px;
|
||||
height: 50px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--theme-border-strong);
|
||||
background: var(--theme-surface-soft);
|
||||
color: var(--theme-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.appMain {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
@@ -1039,10 +1239,6 @@ function submitGlobalSearch() {
|
||||
border-right: 1px solid var(--theme-border);
|
||||
}
|
||||
|
||||
.appMain--preview {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-rows: 56px minmax(0, 1fr);
|
||||
@@ -1075,18 +1271,6 @@ function submitGlobalSearch() {
|
||||
color: var(--theme-text-strong);
|
||||
}
|
||||
|
||||
.workspaceHead__brandSub {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--theme-text-muted);
|
||||
text-decoration: none;
|
||||
transition: color 180ms ease, opacity 180ms ease;
|
||||
}
|
||||
|
||||
.workspaceHead__brandSub:hover {
|
||||
color: var(--theme-text);
|
||||
}
|
||||
|
||||
.workspaceHead__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -1144,13 +1328,32 @@ function submitGlobalSearch() {
|
||||
}
|
||||
|
||||
.rightRail__bottom {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.rightRail__footer {
|
||||
padding: 0 4px 2px;
|
||||
font-size: 9px;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
color: var(--theme-text-faint);
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.rightRail__footer a {
|
||||
color: var(--theme-text-strong);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.rightRail__footer a:hover {
|
||||
color: var(--theme-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.settingsThemePanel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -1266,7 +1469,7 @@ function submitGlobalSearch() {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1574,12 +1777,17 @@ function submitGlobalSearch() {
|
||||
}
|
||||
|
||||
.localRightRailRoot {
|
||||
min-height: auto;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
flex: 1 1 auto;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.localRightRailRoot--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toastStack {
|
||||
position: fixed;
|
||||
top: 18px;
|
||||
|
||||
|
Before Width: | Height: | Size: 44 KiB |
1
frontend/src/assets/icons/add_notes.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v268q-19-9-39-15.5t-41-9.5v-243H200v560h242q3 22 9.5 42t15.5 38H200Zm0-120v40-560 243-3 280Zm80-40h163q3-21 9.5-41t14.5-39H280v80Zm0-160h244q32-30 71.5-50t84.5-27v-3H280v80Zm0-160h400v-80H280v80ZM720-40q-83 0-141.5-58.5T520-240q0-83 58.5-141.5T720-440q83 0 141.5 58.5T920-240q0 83-58.5 141.5T720-40Zm-20-80h40v-100h100v-40H740v-100h-40v100H600v40h100v100Z"/></svg>
|
||||
|
After Width: | Height: | Size: 566 B |
1
frontend/src/assets/icons/add_photo_alternate.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M480-480ZM200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h320v80H200v560h560v-320h80v320q0 33-23.5 56.5T760-120H200Zm40-160h480L570-480 450-320l-90-120-120 160Zm440-320v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80Z"/></svg>
|
||||
|
After Width: | Height: | Size: 347 B |
1
frontend/src/assets/icons/dashboard_customize.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M120-840h320v320H120v-320Zm80 80v160-160Zm320-80h320v320H520v-320Zm80 80v160-160ZM120-440h320v320H120v-320Zm80 80v160-160Zm440-80h80v120h120v80H720v120h-80v-120H520v-80h120v-120Zm-40-320v160h160v-160H600Zm-400 0v160h160v-160H200Zm0 400v160h160v-160H200Z"/></svg>
|
||||
|
After Width: | Height: | Size: 377 B |
1
frontend/src/assets/icons/share.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M680-80q-50 0-85-35t-35-85q0-6 3-28L282-392q-16 15-37 23.5t-45 8.5q-50 0-85-35t-35-85q0-50 35-85t85-35q24 0 45 8.5t37 23.5l281-164q-2-7-2.5-13.5T560-760q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35q-24 0-45-8.5T598-672L317-508q2 7 2.5 13.5t.5 14.5q0 8-.5 14.5T317-452l281 164q16-15 37-23.5t45-8.5q50 0 85 35t35 85q0 50-35 85t-85 35Zm0-80q17 0 28.5-11.5T720-200q0-17-11.5-28.5T680-240q-17 0-28.5 11.5T640-200q0 17 11.5 28.5T680-160ZM200-440q17 0 28.5-11.5T240-480q0-17-11.5-28.5T200-520q-17 0-28.5 11.5T160-480q0 17 11.5 28.5T200-440Zm508.5-291.5Q720-743 720-760t-11.5-28.5Q697-800 680-800t-28.5 11.5Q640-777 640-760t11.5 28.5Q663-720 680-720t28.5-11.5ZM680-200ZM200-480Zm480-280Z"/></svg>
|
||||
|
After Width: | Height: | Size: 810 B |
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 496 B |
@@ -67,6 +67,7 @@ onMounted(async () => {
|
||||
.rightRailAd {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding-top: 78px;
|
||||
}
|
||||
|
||||
.rightRailAd__eyebrow {
|
||||
|
||||
64
frontend/src/components/admin/AdminFeaturedSection.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
featuredTemplates: { type: Array, required: true },
|
||||
availableTemplatesForFeatured: { type: Array, required: true },
|
||||
featuredTemplateIds: { type: Array, required: true },
|
||||
featuredListRef: { type: Function, required: true },
|
||||
saveFeaturedOrder: { type: Function, required: true },
|
||||
moveFeaturedTemplate: { type: Function, required: true },
|
||||
removeFeaturedTemplate: { type: Function, required: true },
|
||||
addFeaturedTemplate: { type: Function, required: true },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div class="sectionHeader">
|
||||
<div>
|
||||
<div class="panel__title">홈 화면 상단 고정 순서</div>
|
||||
<div class="hint hint--tight">여기에 넣은 템플릿은 지정한 순서대로 먼저 노출되고, 나머지 템플릿은 최근 생성순으로 뒤에 이어집니다. 최대 50개까지 설정할 수 있어요.</div>
|
||||
</div>
|
||||
<button class="btn btn--primary" @click="props.saveFeaturedOrder">순서 저장</button>
|
||||
</div>
|
||||
|
||||
<div class="featuredOrderPanel">
|
||||
<div class="featuredOrderPanel__list">
|
||||
<div class="section__title">상단 고정 목록</div>
|
||||
<div v-if="!props.featuredTemplates.length" class="hint">아직 상단 고정 템플릿이 없어요.</div>
|
||||
<div v-else :ref="props.featuredListRef" class="featuredList">
|
||||
<article v-for="(template, index) in props.featuredTemplates" :key="template.id" class="featuredCard" :data-featured-id="template.id">
|
||||
<div class="featuredCard__meta">
|
||||
<span class="featuredCard__rank">{{ index + 1 }}</span>
|
||||
<div>
|
||||
<div class="featuredCard__title">{{ template.name }}</div>
|
||||
<div class="featuredCard__id">{{ template.slug || template.id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="featuredCard__actions">
|
||||
<button class="btn btn--ghost btn--small" data-featured-handle>드래그</button>
|
||||
<button class="btn btn--ghost btn--small" :disabled="index === 0" @click="props.moveFeaturedTemplate(template.id, -1)">위로</button>
|
||||
<button class="btn btn--ghost btn--small" :disabled="index === props.featuredTemplates.length - 1" @click="props.moveFeaturedTemplate(template.id, 1)">아래로</button>
|
||||
<button class="btn btn--danger btn--small" @click="props.removeFeaturedTemplate(template.id)">제외</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="featuredOrderPanel__picker">
|
||||
<div class="section__title">템플릿 추가</div>
|
||||
<div class="featuredPickerList">
|
||||
<button
|
||||
v-for="template in props.availableTemplatesForFeatured"
|
||||
:key="template.id"
|
||||
class="featuredPickerItem"
|
||||
:disabled="props.featuredTemplateIds.length >= 50"
|
||||
@click="props.addFeaturedTemplate(template.id)"
|
||||
>
|
||||
<span>{{ template.name }}</span>
|
||||
<span class="featuredPickerItem__id">{{ template.slug || template.id }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
39
frontend/src/components/admin/AdminItemsSection.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup>
|
||||
import { toApiUrl } from '../../lib/runtime'
|
||||
|
||||
const props = defineProps({
|
||||
customItems: { type: Array, required: true },
|
||||
openCustomItemModal: { type: Function, required: true },
|
||||
customItemPage: { type: Number, required: true },
|
||||
customItemPageCount: { type: Number, required: true },
|
||||
customItemTotal: { type: Number, required: true },
|
||||
moveCustomItemPage: { type: Function, required: true },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div v-if="!props.customItems.length" class="hint">조건에 맞는 관리 대상 아이템이 없어요.</div>
|
||||
<div v-else class="customItemGrid">
|
||||
<button v-for="item in props.customItems" :key="item.id" type="button" class="customItemCard" @click="props.openCustomItemModal(item)">
|
||||
<span
|
||||
class="customItemCard__badge"
|
||||
:class="{
|
||||
'customItemCard__badge--template': item.sourceType === 'template',
|
||||
'customItemCard__badge--asset': item.sourceType === 'asset',
|
||||
}"
|
||||
>
|
||||
{{ item.sourceLabel }}
|
||||
</span>
|
||||
<img class="customItemCard__image" :src="toApiUrl(item.src)" :alt="item.label" />
|
||||
<div class="customItemCard__title" :title="item.label">{{ item.label }}</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="pager">
|
||||
<button class="btn btn--ghost" :disabled="props.customItemPage <= 1" @click="props.moveCustomItemPage(-1)">이전</button>
|
||||
<div class="pager__info">{{ props.customItemPage }} / {{ props.customItemPageCount }} 페이지 · 총 {{ props.customItemTotal }}개</div>
|
||||
<button class="btn btn--ghost" :disabled="props.customItemPage >= props.customItemPageCount" @click="props.moveCustomItemPage(1)">다음</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
297
frontend/src/components/admin/AdminTemplatesSection.vue
Normal file
@@ -0,0 +1,297 @@
|
||||
<script setup>
|
||||
import { toApiUrl } from '../../lib/runtime'
|
||||
import SvgIcon from '../SvgIcon.vue'
|
||||
import addPhotoAlternateIcon from '../../assets/icons/add_photo_alternate.svg'
|
||||
|
||||
const props = defineProps({
|
||||
activeTemplateRequest: { type: Object, default: null },
|
||||
templateRequestSourceUrl: { type: Function, required: true },
|
||||
stagedRequestDraftCount: { type: Number, required: true },
|
||||
appliedRequestItemCount: { type: Number, required: true },
|
||||
openTemplateCreateModal: { type: Function, required: true },
|
||||
isTemplateLoading: { type: Boolean, required: true },
|
||||
hasSelectedTemplate: { type: Boolean, required: true },
|
||||
selectedTemplate: { type: Object, default: null },
|
||||
displayThumbnailUrl: { type: String, default: '' },
|
||||
templateMetaDraftName: { type: String, default: '' },
|
||||
templateMetaDraftSlug: { type: String, default: '' },
|
||||
templateMetaSaving: { type: Boolean, required: true },
|
||||
canSaveTemplateMeta: { type: Boolean, required: true },
|
||||
saveTemplateMeta: { type: Function, required: true },
|
||||
canApplyThumbnail: { type: Boolean, required: true },
|
||||
templateVisibilitySaving: { type: Boolean, required: true },
|
||||
thumbFileInputRef: { type: Function, required: true },
|
||||
openThumbFilePicker: { type: Function, required: true },
|
||||
onThumb: { type: Function, required: true },
|
||||
onThumbDragEnter: { type: Function, required: true },
|
||||
onThumbDragOver: { type: Function, required: true },
|
||||
onThumbDragLeave: { type: Function, required: true },
|
||||
onThumbDrop: { type: Function, required: true },
|
||||
isThumbDragOver: { type: Boolean, required: true },
|
||||
uploadThumbnail: { type: Function, required: true },
|
||||
removeTemplate: { type: Function, required: true },
|
||||
toggleSelectedTemplateVisibility: { type: Function, required: true },
|
||||
itemFileInputRef: { type: Function, required: true },
|
||||
onFile: { type: Function, required: true },
|
||||
isItemDragOver: { type: Boolean, required: true },
|
||||
onItemDragEnter: { type: Function, required: true },
|
||||
onItemDragOver: { type: Function, required: true },
|
||||
onItemDragLeave: { type: Function, required: true },
|
||||
onItemDrop: { type: Function, required: true },
|
||||
openItemFilePicker: { type: Function, required: true },
|
||||
uploadItemDrafts: { type: Array, required: true },
|
||||
clearItemFiles: { type: Function, required: true },
|
||||
canAddItem: { type: Boolean, required: true },
|
||||
uploadItem: { type: Function, required: true },
|
||||
removeUploadDraft: { type: Function, required: true },
|
||||
hasTemplateItemOrderChanges: { type: Boolean, required: true },
|
||||
saveTemplateItemOrder: { type: Function, required: true },
|
||||
templateItemListRef: { type: Function, required: true },
|
||||
saveTemplateItemLabel: { type: Function, required: true },
|
||||
removeTemplateItem: { type: Function, required: true },
|
||||
selectedTemplateId: { type: String, default: '' },
|
||||
})
|
||||
|
||||
defineEmits(['update:templateMetaDraftName', 'update:templateMetaDraftSlug'])
|
||||
|
||||
function setTemplateItemListElement(el) {
|
||||
props.templateItemListRef(el)
|
||||
}
|
||||
|
||||
function setThumbFileElement(el) {
|
||||
props.thumbFileInputRef(el)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="props.activeTemplateRequest" class="panel requestWorkspace">
|
||||
<div class="requestWorkspace__head">
|
||||
<div>
|
||||
<div class="panel__title">진행 중인 요청 작업</div>
|
||||
<div class="requestWorkspace__title">{{ props.activeTemplateRequest.sourceTierListTitle || '템플릿 요청' }}</div>
|
||||
<div class="hint hint--tight">
|
||||
{{
|
||||
props.activeTemplateRequest.type === 'create'
|
||||
? (props.activeTemplateRequest.targetTopicId
|
||||
? '이미 연결된 신규 템플릿이 있어요. 필요한 아이템만 골라 저장하세요.'
|
||||
: '새 템플릿을 한 번 만든 뒤 필요한 아이템만 골라 저장하세요.')
|
||||
: '필요한 아이템만 남긴 뒤 기본 아이템 추가 버튼으로 반영하세요.'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="requestWorkspace__stats">
|
||||
<span class="pill pill--accent">{{ props.activeTemplateRequest.type === 'create' ? '신규 템플릿 요청' : '기존 템플릿 업데이트' }}</span>
|
||||
<span class="pill">요청 아이템 {{ props.stagedRequestDraftCount }}개</span>
|
||||
<span v-if="props.appliedRequestItemCount" class="pill pill--soft">이미 반영 {{ props.appliedRequestItemCount }}개</span>
|
||||
<span v-if="props.activeTemplateRequest.type === 'create' && (props.activeTemplateRequest.targetTopicName || props.activeTemplateRequest.targetTopicId)" class="pill pill--soft">
|
||||
연결된 템플릿 · {{ props.activeTemplateRequest.targetTopicName || props.activeTemplateRequest.targetTopicId }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="requestWorkspace__actions">
|
||||
<a
|
||||
v-if="props.templateRequestSourceUrl(props.activeTemplateRequest)"
|
||||
class="btn btn--ghost btn--small"
|
||||
:href="props.templateRequestSourceUrl(props.activeTemplateRequest)"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
요청 티어표 보기
|
||||
</a>
|
||||
<button
|
||||
v-if="props.activeTemplateRequest.type === 'create' && !props.activeTemplateRequest.targetTopicId"
|
||||
class="btn btn--ghost btn--small"
|
||||
type="button"
|
||||
@click="props.openTemplateCreateModal"
|
||||
>
|
||||
새 템플릿 만들기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="props.isTemplateLoading" class="panel panel--empty">
|
||||
<div class="emptyState">
|
||||
<div class="emptyState__title">템플릿 정보를 불러오는 중이에요.</div>
|
||||
<div class="emptyState__desc">선택한 템플릿의 썸네일과 기본 아이템을 곧 표시합니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="props.hasSelectedTemplate" class="panel">
|
||||
<section class="adminCard templateSettingsCard">
|
||||
<div class="templateSettingsCard__media">
|
||||
<input :ref="setThumbFileElement" type="file" accept="image/*" class="srOnlyInput" @change="props.onThumb" />
|
||||
<button
|
||||
class="thumbDropZone"
|
||||
:class="{ 'thumbDropZone--active': props.isThumbDragOver }"
|
||||
type="button"
|
||||
@click="props.openThumbFilePicker"
|
||||
@dragenter="props.onThumbDragEnter"
|
||||
@dragover="props.onThumbDragOver"
|
||||
@dragleave="props.onThumbDragLeave"
|
||||
@drop="props.onThumbDrop"
|
||||
>
|
||||
<img v-if="props.displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="props.displayThumbnailUrl" :alt="props.selectedTemplate.template.name" />
|
||||
<div v-else class="selectedThumb selectedThumb--empty selectedThumb--sidebar">대표 썸네일</div>
|
||||
<div class="thumbDropZone__copy">
|
||||
<div class="thumbDropZone__title">{{ props.displayThumbnailUrl ? '썸네일 변경' : '클릭 & 드래그' }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="templateSettingsCard__body">
|
||||
<div class="panel__title">템플릿 설정</div>
|
||||
<div class="templateMetaForm">
|
||||
<label class="templateMetaField">
|
||||
<span class="templateMetaField__label">템플릿 이름</span>
|
||||
<input
|
||||
class="input input--dense"
|
||||
type="text"
|
||||
maxlength="60"
|
||||
:value="props.templateMetaDraftName"
|
||||
placeholder="템플릿 이름"
|
||||
@input="$emit('update:templateMetaDraftName', $event.target.value)"
|
||||
/>
|
||||
</label>
|
||||
<label class="templateMetaField">
|
||||
<span class="templateMetaField__label">템플릿 slug</span>
|
||||
<input
|
||||
class="input input--dense"
|
||||
type="text"
|
||||
maxlength="120"
|
||||
:value="props.templateMetaDraftSlug"
|
||||
placeholder="예: idol-rhythm"
|
||||
@input="$emit('update:templateMetaDraftSlug', $event.target.value)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="templateSettingsCard__meta">공개 URL: /topics/{{ props.selectedTemplate.template.slug || props.selectedTemplate.template.id }}</div>
|
||||
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': props.templateVisibilitySaving }">
|
||||
<input :checked="!!props.selectedTemplate.template.isPublic" type="checkbox" @change="props.toggleSelectedTemplateVisibility($event.target.checked)" />
|
||||
<span class="toggleSwitch__label">{{ props.selectedTemplate.template.isPublic ? '템플릿 공개중' : '비공개 상태' }}</span>
|
||||
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
|
||||
</label>
|
||||
<div class="templateSettingsCard__actions">
|
||||
<button class="btn btn--ghost" :disabled="!props.canSaveTemplateMeta || props.templateMetaSaving" @click="props.saveTemplateMeta">
|
||||
{{ props.templateMetaSaving ? '저장중...' : '이름/slug 저장' }}
|
||||
</button>
|
||||
<button class="btn" :disabled="!props.canApplyThumbnail" @click="props.uploadThumbnail">썸네일 적용</button>
|
||||
<button class="btn btn--danger" @click="props.removeTemplate">템플릿 삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="section">
|
||||
<section class="adminCard">
|
||||
<div class="section__title">기본 아이템 추가</div>
|
||||
<div class="itemComposer">
|
||||
<div class="itemComposer__form">
|
||||
<input :ref="props.itemFileInputRef" type="file" accept="image/*" multiple class="srOnlyInput" @change="props.onFile" />
|
||||
<div
|
||||
class="dropZone"
|
||||
:class="{ 'dropZone--active': props.isItemDragOver }"
|
||||
@click="props.openItemFilePicker"
|
||||
@dragenter="props.onItemDragEnter"
|
||||
@dragover="props.onItemDragOver"
|
||||
@dragleave="props.onItemDragLeave"
|
||||
@drop="props.onItemDrop"
|
||||
>
|
||||
<div class="dropZone__iconWrap">
|
||||
<SvgIcon :src="addPhotoAlternateIcon" alt="" class="dropZone__icon" />
|
||||
</div>
|
||||
<div class="dropZone__title">이미지를 드래그해서 기본 아이템으로 추가</div>
|
||||
<div class="dropZone__desc">
|
||||
여러 파일을 한 번에 올릴 수 있고, 저장 라벨은 파일명으로 자동 생성됩니다.
|
||||
<span v-if="props.stagedRequestDraftCount"> 현재 요청에서 가져온 아이템 {{ props.stagedRequestDraftCount }}개도 함께 검토 중이에요.</span>
|
||||
</div>
|
||||
<div class="dropZone__actions">
|
||||
<button class="btn btn--ghost btn--small dropZone__button" type="button" @click.stop="props.openItemFilePicker">파일 선택</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="itemPreviewCard">
|
||||
<div v-if="props.uploadItemDrafts.length" class="itemDraftList">
|
||||
<div
|
||||
v-for="draft in props.uploadItemDrafts"
|
||||
:key="draft.kind + ':' + (draft.itemId || draft.file?.name || draft.previewUrl)"
|
||||
class="itemDraftRow"
|
||||
>
|
||||
<div class="itemDraftRow__preview">
|
||||
<img class="itemPreviewImage" :src="draft.previewUrl" :alt="draft.sourceName || 'item preview'" />
|
||||
</div>
|
||||
<div class="itemDraftRow__body">
|
||||
<input v-model="draft.label" class="input input--labelEdit input--dense" maxlength="60" placeholder="아이템 이름" />
|
||||
<div class="hint hint--tight">{{ draft.sourceName }}</div>
|
||||
<div class="itemDraftRow__meta">
|
||||
<span class="pill" :class="draft.kind === 'request' ? 'pill--requestItem' : 'pill--directFile'">
|
||||
{{ draft.kind === 'request' ? '요청 아이템' : '직접 추가 파일' }}
|
||||
</span>
|
||||
<button class="btn btn--danger btn--small" type="button" @click="props.removeUploadDraft(draft)">제외</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="itemPreviewEmpty">등록한 기본 아이템 미리보기가 여기에 표시됩니다.</div>
|
||||
<button class="btn itemPreviewCard__submit" :disabled="!props.canAddItem" @click="props.uploadItem">
|
||||
{{ props.uploadItemDrafts.length ? `아이템 ${props.uploadItemDrafts.length}개 추가` : '아이템 추가' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="sectionHeader">
|
||||
<div>
|
||||
<div class="section__title">현재 기본 아이템 목록</div>
|
||||
<div class="hint hint--tight">드래그해서 기본 노출 순서를 바꿀 수 있어요. 저장 전까지는 화면에서만 순서가 바뀝니다.</div>
|
||||
</div>
|
||||
<button class="btn btn--primary btn--small" :disabled="!props.hasTemplateItemOrderChanges" @click="props.saveTemplateItemOrder">순서 저장</button>
|
||||
</div>
|
||||
<div v-if="!props.selectedTemplate?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div>
|
||||
<div v-else :ref="setTemplateItemListElement" class="thumbGrid">
|
||||
<div v-for="item in props.selectedTemplate.items" :key="item.id" class="thumbCard" :data-template-item-id="item.id">
|
||||
<img class="thumb thumb--template" :src="toApiUrl(item.src)" :alt="item.label" draggable="false" />
|
||||
<input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" data-no-drag />
|
||||
<div class="thumbCard__actions">
|
||||
<button
|
||||
class="btn btn--ghost btn--small"
|
||||
data-no-drag
|
||||
:disabled="item.isSavingLabel || !item.draftLabel?.trim() || item.draftLabel.trim() === item.label"
|
||||
@click="props.saveTemplateItemLabel(item)"
|
||||
>
|
||||
{{ item.isSavingLabel ? '저장중...' : '이름 저장' }}
|
||||
</button>
|
||||
<button class="btn btn--danger btn--small" data-no-drag @click="props.removeTemplateItem(item.id)">아이템 삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="panel panel--empty">
|
||||
<div class="emptyState">
|
||||
<div class="emptyState__title">템플릿을 선택해 주세요.</div>
|
||||
<div v-if="props.activeTemplateRequest?.type === 'create'" class="hint hint--tight">진행 중인 신규 템플릿 요청이 있어요. 위의 `새 템플릿 만들기`로 템플릿을 만든 뒤 아이템을 추가할 수 있습니다.</div>
|
||||
<div v-if="props.selectedTemplateId" class="hint hint--tight">선택한 템플릿을 찾지 못했거나 로딩 중 오류가 발생했어요. 다시 선택해보세요.</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.templateMetaForm {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.templateMetaField {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.templateMetaField__label {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: var(--theme-text-soft);
|
||||
}
|
||||
|
||||
.input--dense {
|
||||
padding: 11px 13px;
|
||||
}
|
||||
</style>
|
||||
204
frontend/src/components/admin/AdminTierlistsSection.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<script setup>
|
||||
import { toApiUrl } from '../../lib/runtime'
|
||||
|
||||
const props = defineProps({
|
||||
tierlistsMode: { type: String, required: true },
|
||||
templateRequests: { type: Array, required: true },
|
||||
openTemplateRequestPreview: { type: Function, required: true },
|
||||
fmt: { type: Function, required: true },
|
||||
templateRequestTargetLabel: { type: Function, required: true },
|
||||
templateRequestStatusLabel: { type: Function, required: true },
|
||||
templateRequestSourceUrl: { type: Function, required: true },
|
||||
startTemplateRequestReview: { type: Function, required: true },
|
||||
completeTemplateRequest: { type: Function, required: true },
|
||||
adminTierLists: { type: Array, required: true },
|
||||
tierListThumbUrl: { type: Function, required: true },
|
||||
openAdminTierList: { type: Function, required: true },
|
||||
tierListAuthorDisplayName: { type: Function, required: true },
|
||||
tierListVisibilityLabel: { type: Function, required: true },
|
||||
openTierListExtraItemModal: { type: Function, required: true },
|
||||
openTierListImportModal: { type: Function, required: true },
|
||||
adminTierListPage: { type: Number, required: true },
|
||||
adminTierListPageCount: { type: Number, required: true },
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div v-if="props.tierlistsMode === 'requests'" class="panel">
|
||||
<div class="sectionHeader">
|
||||
<div>
|
||||
<div class="panel__title">사용자 요청</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!props.templateRequests.length" class="hint">현재 처리 대기 중인 템플릿 요청이 없어요.</div>
|
||||
<div v-else class="templateRequestList">
|
||||
<article v-for="request in props.templateRequests" :key="request.id" class="tierAdminCard templateRequestCard templateRequestCard--aligned">
|
||||
<div class="templateRequestCard__side">
|
||||
<a
|
||||
class="tierAdminCard__preview templateRequestCard__preview"
|
||||
:href="props.templateRequestSourceUrl(request) || undefined"
|
||||
:target="props.templateRequestSourceUrl(request) ? '_blank' : undefined"
|
||||
:rel="props.templateRequestSourceUrl(request) ? 'noreferrer' : undefined"
|
||||
:aria-disabled="!props.templateRequestSourceUrl(request)"
|
||||
>
|
||||
<img v-if="request.thumbnailSrc" class="tierAdminCard__thumb" :src="toApiUrl(request.thumbnailSrc)" :alt="request.sourceTierListTitle" draggable="false" />
|
||||
<div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
|
||||
</a>
|
||||
<div class="templateRequestCard__thumbMeta">
|
||||
<template v-if="request.type === 'create'">
|
||||
<label class="templateRequestField">
|
||||
<span class="templateRequestField__label">템플릿 이름</span>
|
||||
<input v-model="request.draftTopicName" class="input" placeholder="새 템플릿 이름" />
|
||||
</label>
|
||||
<label class="templateRequestField">
|
||||
<span class="templateRequestField__label">템플릿 ID</span>
|
||||
<input v-model="request.draftTopicId" class="input" placeholder="임시 템플릿 ID" />
|
||||
</label>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="templateRequestCard__thumbLabel">템플릿 이름</div>
|
||||
<div class="templateRequestCard__thumbValue">{{ request.draftTopicName || request.sourceTopicName || '-' }}</div>
|
||||
<div class="templateRequestCard__thumbLabel">템플릿 ID</div>
|
||||
<div class="templateRequestCard__thumbValue">{{ request.draftTopicId || request.sourceTopicId || '-' }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tierAdminCard__body">
|
||||
<div class="tierAdminCard__head">
|
||||
<div>
|
||||
<span
|
||||
class="pill templateRequestCard__cornerBadge"
|
||||
:class="request.type === 'create' ? 'pill--create' : 'pill--owned'"
|
||||
>
|
||||
{{ request.type === 'create' ? '신규 템플릿' : '보유 템플릿' }}
|
||||
</span>
|
||||
<div class="tierAdminCard__title">{{ request.sourceTierListTitle }}</div>
|
||||
<div v-if="request.sourceDescription" class="tierAdminCard__desc">{{ request.sourceDescription }}</div>
|
||||
<div class="tierAdminCard__meta">
|
||||
{{ request.requesterName }} · {{ props.fmt(request.createdAt) }}
|
||||
</div>
|
||||
<div class="tierAdminCard__meta">{{ props.templateRequestTargetLabel(request) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tierAdminCard__stats">
|
||||
<span class="pill">추가 아이템 {{ request.items?.length || 0 }}개</span>
|
||||
<span v-if="request.type === 'create' && (request.targetTopicName || request.targetTopicId)" class="pill pill--soft">
|
||||
연결됨 · {{ request.targetTopicName || request.targetTopicId }}
|
||||
</span>
|
||||
<span class="pill" :class="{ 'pill--accent': request.status === 'reviewing' }">{{ props.templateRequestStatusLabel(request) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="request.items?.length" class="tierAdminItemList templateRequestCard__items">
|
||||
<button v-for="item in request.items" :key="item.id" class="tierAdminItem" type="button">
|
||||
<img class="tierAdminItem__thumb" :src="toApiUrl(item.src)" :alt="item.label" />
|
||||
<div class="tierAdminItem__title">{{ item.label }}</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="templateRequestCard__footer">
|
||||
<div class="templateRequestCard__footerLeft"></div>
|
||||
<div class="templateRequestCard__actions">
|
||||
<button class="btn btn--primary" :disabled="request.isHandling" @click="props.startTemplateRequestReview(request)">
|
||||
{{
|
||||
request.isHandling
|
||||
? '이동중...'
|
||||
: request.type === 'create' && (request.targetTopicName || request.targetTopicId)
|
||||
? '연결된 템플릿 열기'
|
||||
: '확인하기'
|
||||
}}
|
||||
</button>
|
||||
<button class="btn btn--ghost" :disabled="request.isHandling || request.status !== 'reviewing'" @click="props.completeTemplateRequest(request)">처리 완료</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="panel">
|
||||
<div class="sectionHeader">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!props.adminTierLists.length" class="hint">조건에 맞는 티어표가 없어요.</div>
|
||||
<div v-else class="tierAdminList">
|
||||
<article v-for="tierList in props.adminTierLists" :key="tierList.id" class="tierAdminCard">
|
||||
<button class="tierAdminCard__preview" type="button" @click="props.openAdminTierList(tierList)">
|
||||
<img v-if="props.tierListThumbUrl(tierList)" class="tierAdminCard__thumb" :src="props.tierListThumbUrl(tierList)" :alt="tierList.title" draggable="false" />
|
||||
<div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
|
||||
</button>
|
||||
|
||||
<div class="tierAdminCard__body">
|
||||
<div class="tierAdminCard__head">
|
||||
<div>
|
||||
<div class="tierAdminCard__title">{{ tierList.title }}</div>
|
||||
<div v-if="tierList.description" class="tierAdminCard__desc">{{ tierList.description }}</div>
|
||||
<div class="tierAdminCard__meta">
|
||||
{{ tierList.topicName || tierList.topicId }} · {{ props.tierListAuthorDisplayName(tierList) }}
|
||||
</div>
|
||||
<div class="tierAdminCard__meta">{{ props.fmt(tierList.updatedAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tierAdminCard__stats">
|
||||
<span class="pill" :class="tierList.isPublic ? 'pill--public' : 'pill--private'">{{ props.tierListVisibilityLabel(tierList) }}</span>
|
||||
<span v-if="tierList.isFeatured" class="pill pill--accent">추천 노출중</span>
|
||||
<span class="pill pill--soft">즐겨찾기 {{ tierList.favoriteCount || 0 }}개</span>
|
||||
<span class="pill">전체 아이템 {{ tierList.itemCount }}개</span>
|
||||
<span class="pill" :class="{ 'pill--accent': tierList.extraItemCount > 0 }">추가 아이템 {{ tierList.extraItemCount }}개</span>
|
||||
</div>
|
||||
|
||||
<div v-if="tierList.extraItems?.length" class="tierAdminSection">
|
||||
<div class="tierAdminSection__title">추가로 넣은 아이템</div>
|
||||
<div class="tierAdminItemList">
|
||||
<button v-for="item in tierList.extraItems" :key="item.id" class="tierAdminItem" @click="props.openTierListExtraItemModal(item, tierList)">
|
||||
<img class="tierAdminItem__thumb" :src="toApiUrl(item.src)" :alt="item.label" />
|
||||
<div class="tierAdminItem__title">{{ item.label }}</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tierAdminSection__actions">
|
||||
<button class="btn btn--ghost btn--small" @click="props.openTierListImportModal(tierList, tierList.extraItems)">추가 아이템 전체 가져오기</button>
|
||||
<button v-if="tierList.topicId === 'freeform'" class="btn btn--primary btn--small" @click="props.openTierListImportModal(tierList, tierList.extraItems)">
|
||||
새 템플릿으로 가져오기
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="pager">
|
||||
<button class="btn btn--ghost" :disabled="props.adminTierListPage <= 1" @click="props.moveAdminTierListPage(-1)">이전</button>
|
||||
<div class="pager__info">{{ props.adminTierListPage }} / {{ props.adminTierListPageCount }} 페이지 · 총 {{ props.adminTierListTotal }}개</div>
|
||||
<button class="btn btn--ghost" :disabled="props.adminTierListPage >= props.adminTierListPageCount" @click="props.moveAdminTierListPage(1)">다음</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
155
frontend/src/components/admin/AdminUsersSection.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import SvgIcon from '../SvgIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
userQuery: { type: String, required: true },
|
||||
userSort: { type: String, required: true },
|
||||
userSortDirection: { type: String, required: true },
|
||||
users: { type: Array, required: true },
|
||||
submitUserFilters: { type: Function, required: true },
|
||||
setUserAvatarInput: { type: Function, required: true },
|
||||
onUserAvatarChange: { type: Function, required: true },
|
||||
openUserAvatarPicker: { type: Function, required: true },
|
||||
userAvatarUrl: { type: Function, required: true },
|
||||
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 },
|
||||
canDeleteUser: { type: Function, required: true },
|
||||
roleLabelOf: { type: Function, required: true },
|
||||
fmt: { type: Function, required: true },
|
||||
openUserProfile: { type: Function, required: true },
|
||||
openUserDeleteModal: { type: Function, required: true },
|
||||
openUserEditModal: { type: Function, required: true },
|
||||
deleteIcon: { type: String, required: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:userQuery', 'update:userSort', 'update:userSortDirection'])
|
||||
|
||||
const userQueryModel = computed({
|
||||
get: () => props.userQuery,
|
||||
set: (value) => emit('update:userQuery', value),
|
||||
})
|
||||
const userSortModel = computed({
|
||||
get: () => props.userSort,
|
||||
set: (value) => emit('update:userSort', value),
|
||||
})
|
||||
const userSortDirectionModel = computed({
|
||||
get: () => props.userSortDirection,
|
||||
set: (value) => emit('update:userSortDirection', value),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div class="sectionHeader">
|
||||
<div>
|
||||
<div class="panel__title">회원 관리</div>
|
||||
<div class="hint hint--tight">팔로워·즐겨찾기 지표로 핵심 작성자를 확인하고, 회원 정보와 권한만 최소한으로 관리해요.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar toolbar--secondary">
|
||||
<input v-model="userQueryModel" class="input toolbar__search" placeholder="이메일, 닉네임 검색" @keydown.enter.prevent="props.submitUserFilters" />
|
||||
<select v-model="userSortModel" class="select toolbar__select" @change="props.submitUserFilters">
|
||||
<option value="recent">최근 콘텐츠 활동순</option>
|
||||
<option value="lastLogin">마지막 접속순</option>
|
||||
<option value="created">가입순</option>
|
||||
<option value="tierlists">작성 티어표 많은 순</option>
|
||||
<option value="followers">팔로워 많은 순</option>
|
||||
<option value="favorites">받은 즐겨찾기 많은 순</option>
|
||||
</select>
|
||||
<select v-model="userSortDirectionModel" class="select toolbar__select" @change="props.submitUserFilters">
|
||||
<option value="desc">내림차순</option>
|
||||
<option value="asc">오름차순</option>
|
||||
</select>
|
||||
<button class="btn btn--ghost toolbar__button" type="button" @click="props.submitUserFilters">조회</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!props.users.length" class="hint">아직 가입한 회원이 없어요.</div>
|
||||
<div v-else class="userList">
|
||||
<article v-for="user in props.users" :key="user.id" class="userCard">
|
||||
<div class="userCard__head">
|
||||
<div class="userCard__identity">
|
||||
<input
|
||||
:ref="(el) => props.setUserAvatarInput(user.id, el)"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="srOnlyInput"
|
||||
@change="props.onUserAvatarChange(user, $event)"
|
||||
/>
|
||||
<div class="userAvatarWrap">
|
||||
<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>
|
||||
</button>
|
||||
<button
|
||||
v-if="user?.avatarSrc"
|
||||
class="userAvatarRemoveButton"
|
||||
type="button"
|
||||
title="회원 썸네일 삭제"
|
||||
:disabled="user.isAvatarBusy || !props.canEditUserAvatar(user)"
|
||||
@click.stop="props.removeUserAvatar(user)"
|
||||
>
|
||||
<SvgIcon class="userAvatarRemoveIcon" :src="props.deleteIcon" :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="userCard__identityMeta">
|
||||
<div class="userCard__title">{{ props.userDisplayName(user) }}</div>
|
||||
<div class="userCard__meta">{{ user.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="user.isAdmin" class="roleBadge userCard__roleBadge">{{ props.roleLabelOf(user) }}</div>
|
||||
|
||||
<div class="userInfoList">
|
||||
<div class="userInfoLine"><span>가입일</span><strong>{{ props.fmt(user.createdAt) }}</strong></div>
|
||||
<div class="userInfoLine"><span>작성 티어표</span><strong>{{ user.tierListCount }}개</strong></div>
|
||||
<div class="userInfoLine"><span>팔로워</span><strong>{{ user.followerCount || 0 }}명</strong></div>
|
||||
<div class="userInfoLine"><span>받은 즐겨찾기</span><strong>{{ user.receivedFavoriteCount || 0 }}개</strong></div>
|
||||
<div class="userInfoLine"><span>최근 콘텐츠 활동</span><strong>{{ props.fmt(user.recentActivityAt || user.createdAt) }}</strong></div>
|
||||
<div class="userInfoLine"><span>마지막 접속일</span><strong>{{ user.lastLoginAt ? props.fmt(user.lastLoginAt) : '기록 없음' }}</strong></div>
|
||||
<div class="userInfoLine"><span>계정명</span><strong>{{ user.email }}</strong></div>
|
||||
<div class="userInfoLine"><span>닉네임</span><strong>{{ user.nickname || '미설정' }}</strong></div>
|
||||
<div class="userInfoLine"><span>권한</span><strong>{{ props.roleLabelOf(user) }}</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="userCard__actions userCard__actions--compact">
|
||||
<button
|
||||
class="iconActionButton 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"
|
||||
:disabled="!props.canEditUserInfo(user)"
|
||||
@click="props.openUserEditModal(user)"
|
||||
>
|
||||
회원 정보 수정
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--ghost userSaveButton"
|
||||
type="button"
|
||||
@click="props.openUserProfile(user)"
|
||||
>
|
||||
프로필 보기
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
209
frontend/src/composables/useAdminCustomItems.js
Normal file
@@ -0,0 +1,209 @@
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
export function useAdminCustomItems({
|
||||
api,
|
||||
toast,
|
||||
customItems,
|
||||
customItemPage,
|
||||
customItemLimit,
|
||||
customItemPageCount,
|
||||
customItemQuery,
|
||||
customItemFilter,
|
||||
customItemModalOpen,
|
||||
customItemDeleteModalOpen,
|
||||
customItemModalHistoryActive,
|
||||
modalTargetCustomItem,
|
||||
customItemModalDraftLabel,
|
||||
customItemModalLabelSaving,
|
||||
customItemModalTargetTemplateId,
|
||||
templates,
|
||||
selectedTemplateId,
|
||||
refreshCustomItems,
|
||||
loadTemplate,
|
||||
setTab,
|
||||
selectAdminTemplate,
|
||||
resetMessages,
|
||||
success,
|
||||
error,
|
||||
}) {
|
||||
function submitCustomItemSearch() {
|
||||
customItemPage.value = 1
|
||||
refreshCustomItems()
|
||||
}
|
||||
|
||||
function changeCustomItemFilter(filter) {
|
||||
customItemFilter.value = filter
|
||||
customItemPage.value = 1
|
||||
refreshCustomItems()
|
||||
}
|
||||
|
||||
function changeCustomItemLimit(limit) {
|
||||
customItemLimit.value = limit
|
||||
customItemPage.value = 1
|
||||
refreshCustomItems()
|
||||
}
|
||||
|
||||
function moveCustomItemPage(direction) {
|
||||
const nextPage = customItemPage.value + direction
|
||||
if (nextPage < 1 || nextPage > customItemPageCount.value) return
|
||||
customItemPage.value = nextPage
|
||||
refreshCustomItems()
|
||||
}
|
||||
|
||||
function pushCustomItemModalHistoryState() {
|
||||
if (typeof window === 'undefined') return
|
||||
window.history.pushState({ ...(window.history.state || {}), adminCustomItemModal: true }, '', window.location.href)
|
||||
customItemModalHistoryActive.value = true
|
||||
}
|
||||
|
||||
function openCustomItemModal(item) {
|
||||
modalTargetCustomItem.value = item || null
|
||||
customItemModalDraftLabel.value = item?.label || ''
|
||||
customItemModalTargetTemplateId.value = ''
|
||||
customItemModalOpen.value = true
|
||||
pushCustomItemModalHistoryState()
|
||||
}
|
||||
|
||||
function closeCustomItemModal({ fromPopState = false } = {}) {
|
||||
customItemModalOpen.value = false
|
||||
customItemDeleteModalOpen.value = false
|
||||
modalTargetCustomItem.value = null
|
||||
customItemModalDraftLabel.value = ''
|
||||
customItemModalLabelSaving.value = false
|
||||
customItemModalTargetTemplateId.value = ''
|
||||
|
||||
if (fromPopState) {
|
||||
customItemModalHistoryActive.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (customItemModalHistoryActive.value && typeof window !== 'undefined') {
|
||||
customItemModalHistoryActive.value = false
|
||||
window.history.back()
|
||||
}
|
||||
}
|
||||
|
||||
function openCustomItemDeleteModal(item) {
|
||||
if (!item) return
|
||||
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedTemplates.length > 0)) {
|
||||
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
|
||||
return
|
||||
}
|
||||
modalTargetCustomItem.value = item
|
||||
customItemDeleteModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeCustomItemDeleteModal() {
|
||||
customItemDeleteModalOpen.value = false
|
||||
}
|
||||
|
||||
function jumpToTemplateAdmin(templateId) {
|
||||
if (!templateId) return
|
||||
closeCustomItemModal()
|
||||
setTab('template-admin')
|
||||
nextTick(() => {
|
||||
selectAdminTemplate(templateId)
|
||||
})
|
||||
}
|
||||
|
||||
async function removeCustomItem(item = modalTargetCustomItem.value) {
|
||||
resetMessages()
|
||||
if (!item) return
|
||||
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedTemplates.length > 0)) {
|
||||
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await api.deleteAdminCustomItem(item.id)
|
||||
closeCustomItemDeleteModal()
|
||||
closeCustomItemModal()
|
||||
await refreshCustomItems()
|
||||
success.value =
|
||||
item.sourceType === 'template'
|
||||
? '선택한 템플릿 아이템을 제거했어요.'
|
||||
: item.sourceType === 'asset'
|
||||
? '선택한 이미지 자산을 삭제했어요.'
|
||||
: '사용자 업로드 이미지를 삭제했어요.'
|
||||
} catch (e) {
|
||||
error.value =
|
||||
item.sourceType === 'template'
|
||||
? '템플릿 아이템 제거에 실패했어요.'
|
||||
: item.sourceType === 'asset'
|
||||
? '이미지 자산 삭제에 실패했어요.'
|
||||
: '사용자 업로드 이미지 삭제에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
async function removeUnusedCustomItems() {
|
||||
resetMessages()
|
||||
const ok = window.confirm('현재 검색 조건에 맞는 미사용 커스텀 이미지를 모두 삭제할까요?')
|
||||
if (!ok) return
|
||||
|
||||
try {
|
||||
const data = await api.deleteAdminUnusedCustomItems({ q: customItemQuery.value })
|
||||
await refreshCustomItems()
|
||||
success.value = `${data.deletedCount || 0}개의 미사용 사용자 업로드 이미지를 삭제했어요.`
|
||||
} catch (e) {
|
||||
error.value = '미사용 커스텀 이미지 일괄 삭제에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCustomItemModalLabel() {
|
||||
const item = modalTargetCustomItem.value
|
||||
const nextLabel = customItemModalDraftLabel.value.trim().slice(0, 60)
|
||||
if (!item || !nextLabel || nextLabel === item.label || customItemModalLabelSaving.value) return
|
||||
|
||||
try {
|
||||
customItemModalLabelSaving.value = true
|
||||
const data = await api.updateAdminCustomItemLabel(item.id, { label: nextLabel, sourceType: item.sourceType })
|
||||
item.label = data.item?.label || nextLabel
|
||||
customItemModalDraftLabel.value = item.label
|
||||
customItems.value = customItems.value.map((entry) => (entry.id === item.id ? { ...entry, label: item.label } : entry))
|
||||
toast.success('아이템 이름을 변경했어요.')
|
||||
} catch (e) {
|
||||
error.value = '아이템 이름 변경에 실패했어요.'
|
||||
} finally {
|
||||
customItemModalLabelSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function promoteCustomItem(item) {
|
||||
resetMessages()
|
||||
if (!customItemModalTargetTemplateId.value) {
|
||||
error.value = '추가할 템플릿을 먼저 선택해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
item.isPromoting = true
|
||||
await api.promoteAdminTemplateItem(item.id, { topicId: customItemModalTargetTemplateId.value })
|
||||
const targetTemplateName =
|
||||
templates.value.find((template) => template.id === customItemModalTargetTemplateId.value)?.name || customItemModalTargetTemplateId.value
|
||||
if (selectedTemplateId.value === customItemModalTargetTemplateId.value) await loadTemplate()
|
||||
closeCustomItemModal()
|
||||
success.value = `"${item.label}" 이미지를 ${targetTemplateName} 템플릿으로 추가했어요.`
|
||||
} catch (e) {
|
||||
error.value = '선택한 이미지를 템플릿으로 추가하지 못했어요.'
|
||||
} finally {
|
||||
item.isPromoting = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
submitCustomItemSearch,
|
||||
changeCustomItemFilter,
|
||||
changeCustomItemLimit,
|
||||
moveCustomItemPage,
|
||||
pushCustomItemModalHistoryState,
|
||||
openCustomItemModal,
|
||||
closeCustomItemModal,
|
||||
openCustomItemDeleteModal,
|
||||
closeCustomItemDeleteModal,
|
||||
jumpToTemplateAdmin,
|
||||
removeCustomItem,
|
||||
removeUnusedCustomItems,
|
||||
saveCustomItemModalLabel,
|
||||
promoteCustomItem,
|
||||
}
|
||||
}
|
||||
93
frontend/src/composables/useAdminFeaturedTemplates.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import { nextTick } from 'vue'
|
||||
import Sortable from 'sortablejs'
|
||||
|
||||
export function useAdminFeaturedTemplates({
|
||||
api,
|
||||
featuredListEl,
|
||||
featuredSortable,
|
||||
featuredTemplateIds,
|
||||
templates,
|
||||
resetMessages,
|
||||
success,
|
||||
error,
|
||||
}) {
|
||||
function destroyFeaturedSortable() {
|
||||
if (featuredSortable.value) {
|
||||
featuredSortable.value.destroy()
|
||||
featuredSortable.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function syncFeaturedSortable() {
|
||||
await nextTick()
|
||||
destroyFeaturedSortable()
|
||||
if (!featuredListEl.value) return
|
||||
|
||||
featuredSortable.value = Sortable.create(featuredListEl.value, {
|
||||
animation: 160,
|
||||
draggable: '[data-featured-id]',
|
||||
handle: '[data-featured-handle]',
|
||||
ghostClass: 'ghost',
|
||||
chosenClass: 'chosen',
|
||||
onEnd: (evt) => {
|
||||
if (evt.oldIndex == null || evt.newIndex == null || evt.oldIndex === evt.newIndex) return
|
||||
const nextIds = [...featuredTemplateIds.value]
|
||||
const [moved] = nextIds.splice(evt.oldIndex, 1)
|
||||
nextIds.splice(evt.newIndex, 0, moved)
|
||||
featuredTemplateIds.value = nextIds
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function addFeaturedTemplate(templateId) {
|
||||
resetMessages()
|
||||
if (!templateId || featuredTemplateIds.value.includes(templateId)) return
|
||||
if (featuredTemplateIds.value.length >= 50) {
|
||||
error.value = '상단 고정 템플릿은 최대 50개까지만 설정할 수 있어요.'
|
||||
return
|
||||
}
|
||||
featuredTemplateIds.value = [...featuredTemplateIds.value, templateId]
|
||||
syncFeaturedSortable()
|
||||
}
|
||||
|
||||
function removeFeaturedTemplate(templateId) {
|
||||
resetMessages()
|
||||
featuredTemplateIds.value = featuredTemplateIds.value.filter((id) => id !== templateId)
|
||||
syncFeaturedSortable()
|
||||
}
|
||||
|
||||
function moveFeaturedTemplate(templateId, direction) {
|
||||
const currentIndex = featuredTemplateIds.value.indexOf(templateId)
|
||||
const nextIndex = currentIndex + direction
|
||||
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= featuredTemplateIds.value.length) return
|
||||
const nextIds = [...featuredTemplateIds.value]
|
||||
const [moved] = nextIds.splice(currentIndex, 1)
|
||||
nextIds.splice(nextIndex, 0, moved)
|
||||
featuredTemplateIds.value = nextIds
|
||||
syncFeaturedSortable()
|
||||
}
|
||||
|
||||
async function saveFeaturedOrder() {
|
||||
resetMessages()
|
||||
try {
|
||||
const data = await api.updateAdminTemplateDisplayOrder({ topicIds: featuredTemplateIds.value })
|
||||
templates.value = data.templates || []
|
||||
featuredTemplateIds.value = templates.value
|
||||
.filter((template) => template.displayRank != null)
|
||||
.sort((a, b) => a.displayRank - b.displayRank)
|
||||
.map((template) => template.id)
|
||||
success.value = '홈 화면 템플릿 순서를 저장했어요.'
|
||||
} catch (e) {
|
||||
error.value = '템플릿 순서 저장에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
destroyFeaturedSortable,
|
||||
syncFeaturedSortable,
|
||||
addFeaturedTemplate,
|
||||
removeFeaturedTemplate,
|
||||
moveFeaturedTemplate,
|
||||
saveFeaturedOrder,
|
||||
}
|
||||
}
|
||||
425
frontend/src/composables/useAdminTemplateManager.js
Normal file
@@ -0,0 +1,425 @@
|
||||
import { nextTick } from 'vue'
|
||||
import Sortable from 'sortablejs'
|
||||
|
||||
export function useAdminTemplateManager({
|
||||
api,
|
||||
toApiUrl,
|
||||
selectedTemplateId,
|
||||
selectedTemplate,
|
||||
uploadFiles,
|
||||
uploadItemDrafts,
|
||||
thumbFile,
|
||||
itemPreviewUrls,
|
||||
itemFileInput,
|
||||
templateItemListEl,
|
||||
templateItemSortable,
|
||||
savedTemplateItemOrderIds,
|
||||
isTemplateLoading,
|
||||
activeTemplateRequest,
|
||||
templateRequests,
|
||||
customItemModalOpen,
|
||||
customItemModalTargetTemplateId,
|
||||
newTemplateId,
|
||||
newTemplateName,
|
||||
newTemplateIsPublic,
|
||||
clearPreviewUrl,
|
||||
resetFileInput,
|
||||
resetUploadState,
|
||||
refreshTemplates,
|
||||
closeTemplateCreateModal,
|
||||
resetMessages,
|
||||
success,
|
||||
error,
|
||||
}) {
|
||||
function normalizeDraftSrc(src) {
|
||||
if (typeof src !== 'string') return ''
|
||||
const raw = src.trim()
|
||||
if (!raw) return ''
|
||||
if (raw.startsWith('/uploads/')) return raw
|
||||
try {
|
||||
const url = new URL(raw)
|
||||
return url.pathname || raw
|
||||
} catch (e) {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
function requestItemFilename(item = {}) {
|
||||
const src = typeof item.src === 'string' ? item.src : ''
|
||||
return src.split('/').pop() || item.file?.name || 'item'
|
||||
}
|
||||
|
||||
function destroyTemplateItemSortable() {
|
||||
if (templateItemSortable.value) {
|
||||
templateItemSortable.value.destroy()
|
||||
templateItemSortable.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function syncTemplateItemSortable() {
|
||||
await nextTick()
|
||||
destroyTemplateItemSortable()
|
||||
if (!templateItemListEl.value || !selectedTemplate.value?.items?.length) return
|
||||
|
||||
templateItemSortable.value = Sortable.create(templateItemListEl.value, {
|
||||
animation: 160,
|
||||
draggable: '[data-template-item-id]',
|
||||
forceFallback: true,
|
||||
fallbackOnBody: false,
|
||||
filter: '[data-no-drag]',
|
||||
preventOnFilter: false,
|
||||
fallbackClass: 'thumbCard--dragging',
|
||||
ghostClass: 'ghost',
|
||||
chosenClass: 'chosen',
|
||||
onEnd: (evt) => {
|
||||
if (evt.oldIndex == null || evt.newIndex == null || evt.oldIndex === evt.newIndex) return
|
||||
const nextItems = [...(selectedTemplate.value?.items || [])]
|
||||
const [moved] = nextItems.splice(evt.oldIndex, 1)
|
||||
nextItems.splice(evt.newIndex, 0, moved)
|
||||
selectedTemplate.value = {
|
||||
...selectedTemplate.value,
|
||||
items: nextItems,
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function mergeRequestItemsIntoDrafts(request) {
|
||||
const requestId = request?.id
|
||||
if (!requestId) return
|
||||
const existingTemplateSrcs = new Set((selectedTemplate.value?.items || []).map((item) => normalizeDraftSrc(item?.src)).filter(Boolean))
|
||||
const existingKeys = new Set(uploadItemDrafts.value.map((draft) => `${draft.kind}:${draft.requestId || ''}:${draft.itemId || draft.file?.name || ''}`))
|
||||
const nextRequestDrafts = (request.items || [])
|
||||
.filter((item) => item?.src)
|
||||
.map((item) => ({
|
||||
kind: 'request',
|
||||
requestId,
|
||||
itemId: item.id,
|
||||
previewUrl: toApiUrl(item.src),
|
||||
label: item.label || '',
|
||||
sourceName: requestItemFilename(item),
|
||||
src: item.src,
|
||||
}))
|
||||
.filter((draft) => !existingTemplateSrcs.has(normalizeDraftSrc(draft.src)))
|
||||
.filter((draft) => !existingKeys.has(`${draft.kind}:${draft.requestId}:${draft.itemId}`))
|
||||
|
||||
if (nextRequestDrafts.length) {
|
||||
uploadItemDrafts.value = [...uploadItemDrafts.value, ...nextRequestDrafts]
|
||||
}
|
||||
}
|
||||
|
||||
function removeUploadDraft(targetDraft) {
|
||||
const targetKey = `${targetDraft.kind}:${targetDraft.requestId || ''}:${targetDraft.itemId || targetDraft.file?.name || ''}:${targetDraft.previewUrl || ''}`
|
||||
uploadItemDrafts.value = uploadItemDrafts.value.filter((draft) => {
|
||||
const currentKey = `${draft.kind}:${draft.requestId || ''}:${draft.itemId || draft.file?.name || ''}:${draft.previewUrl || ''}`
|
||||
return currentKey !== targetKey
|
||||
})
|
||||
uploadFiles.value = uploadItemDrafts.value.filter((draft) => draft.kind === 'file').map((draft) => draft.file).filter(Boolean)
|
||||
}
|
||||
|
||||
async function loadTemplate(options = {}) {
|
||||
const preserveUploadState = !!options.preserveUploadState
|
||||
resetMessages()
|
||||
if (!preserveUploadState) resetUploadState()
|
||||
|
||||
if (!selectedTemplateId.value) {
|
||||
selectedTemplate.value = null
|
||||
savedTemplateItemOrderIds.value = []
|
||||
destroyTemplateItemSortable()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isTemplateLoading.value = true
|
||||
const data = await api.getTopic(selectedTemplateId.value)
|
||||
const loadedTemplate = data.template || data.topic || null
|
||||
selectedTemplate.value = {
|
||||
...data,
|
||||
template: loadedTemplate,
|
||||
items: (data.items || []).map((item) => ({
|
||||
...item,
|
||||
draftLabel: item.label,
|
||||
})),
|
||||
}
|
||||
savedTemplateItemOrderIds.value = (data.items || []).map((item) => item.id)
|
||||
await syncTemplateItemSortable()
|
||||
} catch (e) {
|
||||
selectedTemplate.value = null
|
||||
error.value = '템플릿 정보를 불러오지 못했어요.'
|
||||
} finally {
|
||||
isTemplateLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createTemplate(options = {}) {
|
||||
const nextTopicSlug =
|
||||
typeof options.topicId === 'string'
|
||||
? options.topicId.trim().toLowerCase()
|
||||
: newTemplateId.value.trim().toLowerCase()
|
||||
const nextTopicName = typeof options.topicName === 'string' ? options.topicName.trim() : newTemplateName.value.trim()
|
||||
const preserveUploadState = !!options.preserveUploadState
|
||||
resetMessages()
|
||||
try {
|
||||
const res = await fetch(toApiUrl('/api/admin/templates'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug: nextTopicSlug,
|
||||
name: nextTopicName,
|
||||
isPublic: !!newTemplateIsPublic.value,
|
||||
thumbnailSrc: activeTemplateRequest.value?.type === 'create' ? (activeTemplateRequest.value?.thumbnailSrc || '') : '',
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
const requestError = new Error('failed')
|
||||
requestError.data = data
|
||||
throw requestError
|
||||
}
|
||||
const createdTemplate = data.template || {}
|
||||
if (activeTemplateRequest.value?.type === 'create' && activeTemplateRequest.value?.id) {
|
||||
const linkData = await api.linkAdminTemplateRequestTemplate(activeTemplateRequest.value.id, {
|
||||
topicId: createdTemplate.id,
|
||||
})
|
||||
activeTemplateRequest.value = {
|
||||
...activeTemplateRequest.value,
|
||||
targetTopicId: linkData.request?.targetTopicId || createdTemplate.id,
|
||||
targetTopicName: linkData.request?.targetTopicName || createdTemplate.name || nextTopicName,
|
||||
}
|
||||
const requestIndex = templateRequests.value.findIndex((entry) => entry.id === activeTemplateRequest.value.id)
|
||||
if (requestIndex >= 0) {
|
||||
templateRequests.value.splice(requestIndex, 1, {
|
||||
...templateRequests.value[requestIndex],
|
||||
targetTopicId: linkData.request?.targetTopicId || createdTemplate.id,
|
||||
targetTopicName: linkData.request?.targetTopicName || createdTemplate.name || nextTopicName,
|
||||
})
|
||||
}
|
||||
}
|
||||
await refreshTemplates()
|
||||
selectedTemplateId.value = createdTemplate.id
|
||||
if (customItemModalOpen.value) customItemModalTargetTemplateId.value = createdTemplate.id
|
||||
closeTemplateCreateModal()
|
||||
await loadTemplate({ preserveUploadState })
|
||||
if (!preserveUploadState && activeTemplateRequest.value?.id) {
|
||||
const sourceRequest = templateRequests.value.find((entry) => entry.id === activeTemplateRequest.value.id) || activeTemplateRequest.value
|
||||
mergeRequestItemsIntoDrafts(sourceRequest)
|
||||
}
|
||||
success.value = '템플릿이 생성됐어요. 이어서 썸네일과 기본 아이템을 관리할 수 있어요.'
|
||||
} catch (e) {
|
||||
const errorCode = e?.data?.error || ''
|
||||
if (errorCode === 'topic_slug_taken') {
|
||||
error.value = '이미 사용 중인 템플릿 주소(slug)입니다.'
|
||||
return
|
||||
}
|
||||
if (errorCode === 'topic_slug_invalid') {
|
||||
error.value = '템플릿 주소(slug)는 영문 소문자, 숫자, 하이픈만 사용할 수 있어요.'
|
||||
return
|
||||
}
|
||||
error.value = '템플릿 생성 실패(관리자 권한/템플릿 주소 중복 확인)'
|
||||
}
|
||||
}
|
||||
|
||||
function handleItemFiles(fileList) {
|
||||
const files = Array.from(fileList || []).filter((file) => (file.type || '').startsWith('image/'))
|
||||
const requestDrafts = uploadItemDrafts.value.filter((draft) => draft.kind === 'request')
|
||||
const previousFileDrafts = uploadItemDrafts.value.filter((draft) => draft.kind === 'file')
|
||||
previousFileDrafts.forEach((draft) => {
|
||||
if (draft.previewUrl) URL.revokeObjectURL(draft.previewUrl)
|
||||
})
|
||||
itemPreviewUrls.value = []
|
||||
uploadFiles.value = files
|
||||
uploadItemDrafts.value = requestDrafts
|
||||
if (!files.length) {
|
||||
resetFileInput('item')
|
||||
return
|
||||
}
|
||||
itemPreviewUrls.value = files.map((file) => URL.createObjectURL(file))
|
||||
const fileDrafts = files.map((file, index) => ({
|
||||
kind: 'file',
|
||||
file,
|
||||
previewUrl: itemPreviewUrls.value[index],
|
||||
label: (file.name || '').replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 60),
|
||||
sourceName: file.name,
|
||||
}))
|
||||
uploadItemDrafts.value = [...requestDrafts, ...fileDrafts]
|
||||
resetFileInput('item')
|
||||
}
|
||||
|
||||
function onFile(event) {
|
||||
handleItemFiles(event.target.files)
|
||||
}
|
||||
|
||||
function openItemFilePicker() {
|
||||
itemFileInput.value?.click()
|
||||
}
|
||||
|
||||
function clearItemFiles() {
|
||||
uploadFiles.value = []
|
||||
uploadItemDrafts.value = []
|
||||
itemPreviewUrls.value.forEach((url) => {
|
||||
if (url) URL.revokeObjectURL(url)
|
||||
})
|
||||
itemPreviewUrls.value = []
|
||||
resetFileInput('item')
|
||||
}
|
||||
|
||||
async function uploadItem() {
|
||||
resetMessages()
|
||||
if (!uploadItemDrafts.value.length) {
|
||||
error.value = '아이템 파일을 선택해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (!selectedTemplateId.value && activeTemplateRequest.value?.type === 'create' && !activeTemplateRequest.value?.targetTopicId) {
|
||||
const draftTopicId = (activeTemplateRequest.value?.draftTopicId || '').trim()
|
||||
const draftTopicName = (activeTemplateRequest.value?.draftTopicName || '').trim()
|
||||
if (!draftTopicId || !draftTopicName) {
|
||||
error.value = '먼저 신규 템플릿의 이름과 템플릿 ID를 저장해주세요.'
|
||||
return
|
||||
}
|
||||
await createTemplate({
|
||||
topicId: draftTopicId,
|
||||
topicName: draftTopicName,
|
||||
preserveUploadState: true,
|
||||
})
|
||||
}
|
||||
|
||||
if (!selectedTemplateId.value) {
|
||||
error.value = '템플릿을 먼저 선택해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
fd.append('labels', entry.label.trim())
|
||||
})
|
||||
const res = await fetch(toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/images`), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: fd,
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
if (requestDrafts.length) {
|
||||
const requestIds = [...new Set(requestDrafts.map((entry) => entry.requestId).filter(Boolean))]
|
||||
for (const requestId of requestIds) {
|
||||
const draftsForRequest = requestDrafts.filter((entry) => entry.requestId === requestId)
|
||||
const result = await api.promoteAdminTemplateRequestItems(requestId, {
|
||||
topicId: selectedTemplateId.value,
|
||||
itemIds: draftsForRequest.map((entry) => entry.itemId).filter(Boolean),
|
||||
itemSrcs: draftsForRequest.map((entry) => entry.src).filter(Boolean),
|
||||
itemLabels: draftsForRequest.reduce((acc, entry) => {
|
||||
if (entry.itemId) acc[entry.itemId] = entry.label.trim()
|
||||
return acc
|
||||
}, {}),
|
||||
})
|
||||
uploadCount += Array.isArray(result?.items) ? result.items.length : 0
|
||||
}
|
||||
}
|
||||
|
||||
resetUploadState()
|
||||
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 = '추가할 요청 아이템이 없어요.'
|
||||
return
|
||||
}
|
||||
if (apiError === 'promote_items_failed') {
|
||||
const detail = e?.data?.detail ? ` (${e.data.detail})` : ''
|
||||
error.value = `요청 아이템을 템플릿 기본 아이템으로 옮기지 못했어요.${detail}`
|
||||
return
|
||||
}
|
||||
if (apiError === 'topic_not_found') {
|
||||
error.value = '선택한 템플릿을 찾지 못했어요.'
|
||||
return
|
||||
}
|
||||
if (e?.status === 413) {
|
||||
error.value = '한 번에 업로드한 파일 용량이 너무 커서 실패했어요.'
|
||||
return
|
||||
}
|
||||
error.value = '아이템 추가에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTemplateItemOrder() {
|
||||
resetMessages()
|
||||
if (!selectedTemplateId.value || !selectedTemplate.value?.items?.length) return
|
||||
|
||||
try {
|
||||
const data = await api.updateAdminTemplateItemDisplayOrder(selectedTemplateId.value, {
|
||||
itemIds: selectedTemplate.value.items.map((item) => item.id),
|
||||
})
|
||||
selectedTemplate.value = {
|
||||
...selectedTemplate.value,
|
||||
items: (data.items || []).map((item) => ({
|
||||
...item,
|
||||
draftLabel: item.label,
|
||||
})),
|
||||
}
|
||||
savedTemplateItemOrderIds.value = (data.items || []).map((item) => item.id)
|
||||
await syncTemplateItemSortable()
|
||||
success.value = '기본 아이템 순서를 저장했어요.'
|
||||
} catch (e) {
|
||||
error.value = '기본 아이템 순서 저장에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requestItemFilename,
|
||||
destroyTemplateItemSortable,
|
||||
syncTemplateItemSortable,
|
||||
mergeRequestItemsIntoDrafts,
|
||||
removeUploadDraft,
|
||||
loadTemplate,
|
||||
createTemplate,
|
||||
handleItemFiles,
|
||||
onFile,
|
||||
openItemFilePicker,
|
||||
clearItemFiles,
|
||||
uploadItem,
|
||||
saveTemplateItemOrder,
|
||||
}
|
||||
}
|
||||
116
frontend/src/composables/useAdminTemplateRequests.js
Normal file
@@ -0,0 +1,116 @@
|
||||
import { editorPath } from '../lib/paths'
|
||||
|
||||
export function useAdminTemplateRequests({
|
||||
api,
|
||||
activeTemplateRequest,
|
||||
refreshTemplateRequests,
|
||||
setTab,
|
||||
openTemplateCreateModal,
|
||||
newTemplateId,
|
||||
newTemplateName,
|
||||
selectAdminTemplate,
|
||||
mergeRequestItemsIntoDrafts,
|
||||
resetMessages,
|
||||
success,
|
||||
error,
|
||||
}) {
|
||||
function updateActiveTemplateRequest(request) {
|
||||
if (!request?.id) return
|
||||
activeTemplateRequest.value = {
|
||||
id: request.id,
|
||||
type: request.type,
|
||||
status: request.status,
|
||||
thumbnailSrc: request.thumbnailSrc || '',
|
||||
draftTopicId: request.draftTopicId || '',
|
||||
draftTopicName: request.draftTopicName || '',
|
||||
draftTopicIsPublic: !!request.draftTopicIsPublic,
|
||||
sourceTierListId: request.sourceTierListId || '',
|
||||
sourceTopicId: request.sourceTopicId || '',
|
||||
sourceTopicSlug: request.sourceTopicSlug || '',
|
||||
sourceTierListTitle: request.sourceTierListTitle || '',
|
||||
targetTopicId: request.targetTopicId || '',
|
||||
targetTopicSlug: request.targetTopicSlug || '',
|
||||
targetTopicName: request.targetTopicName || '',
|
||||
requesterName: request.requesterName || '',
|
||||
}
|
||||
}
|
||||
|
||||
function templateRequestStatusLabel(request) {
|
||||
return request.status === 'reviewing' ? '확인함' : '미확인'
|
||||
}
|
||||
|
||||
function templateRequestSourceUrl(request) {
|
||||
const topicRef = request?.sourceTopicSlug || request?.sourceTopicId || ''
|
||||
if (!topicRef || !request?.sourceTierListId) return ''
|
||||
return editorPath(topicRef, request.sourceTierListId, { preview: true })
|
||||
}
|
||||
|
||||
function templateRequestReviewHint(request) {
|
||||
if (request.type === 'create') return '템플릿 생성 후 필요한 아이템만 골라 추가하고, 끝나면 여기서 처리 완료하세요.'
|
||||
return '확인하기를 누르면 템플릿 관리 화면에 요청 아이템이 임시 추가되어 필요한 것만 선별 저장할 수 있어요.'
|
||||
}
|
||||
|
||||
async function startTemplateRequestReview(request) {
|
||||
resetMessages()
|
||||
try {
|
||||
request.isHandling = true
|
||||
const data = await api.startAdminTemplateRequestReview(request.id)
|
||||
const syncedRequest = {
|
||||
...request,
|
||||
...(data.request || {}),
|
||||
draftTopicId: request.draftTopicId || '',
|
||||
draftTopicName: request.draftTopicName || '',
|
||||
draftTopicIsPublic: !!request.draftTopicIsPublic,
|
||||
}
|
||||
Object.assign(request, syncedRequest)
|
||||
request.status = syncedRequest.status || 'reviewing'
|
||||
updateActiveTemplateRequest(syncedRequest)
|
||||
setTab('template-admin')
|
||||
|
||||
if (request.type === 'create') {
|
||||
const linkedTopicId = syncedRequest.targetTopicId || ''
|
||||
if (linkedTopicId) {
|
||||
await selectAdminTemplate(linkedTopicId)
|
||||
} else {
|
||||
openTemplateCreateModal()
|
||||
newTemplateId.value = (syncedRequest.draftTopicId || '').trim()
|
||||
newTemplateName.value = (syncedRequest.draftTopicName || '').trim()
|
||||
}
|
||||
mergeRequestItemsIntoDrafts(syncedRequest)
|
||||
} else {
|
||||
const nextTopicId = syncedRequest.targetTopicId || syncedRequest.sourceTopicId || ''
|
||||
if (nextTopicId) await selectAdminTemplate(nextTopicId)
|
||||
mergeRequestItemsIntoDrafts(syncedRequest)
|
||||
}
|
||||
success.value = '요청 아이템을 템플릿 관리 화면으로 가져왔어요. 필요한 항목만 골라서 추가해 주세요.'
|
||||
} catch (e) {
|
||||
error.value = '요청 확인 단계로 이동하지 못했어요.'
|
||||
} finally {
|
||||
request.isHandling = false
|
||||
}
|
||||
}
|
||||
|
||||
async function completeTemplateRequest(request) {
|
||||
resetMessages()
|
||||
try {
|
||||
request.isHandling = true
|
||||
await api.completeAdminTemplateRequest(request.id)
|
||||
if (activeTemplateRequest.value?.id === request.id) activeTemplateRequest.value = null
|
||||
await refreshTemplateRequests()
|
||||
success.value = '요청 카드를 처리 완료로 정리했어요.'
|
||||
} catch (e) {
|
||||
error.value = '요청 완료 처리에 실패했어요.'
|
||||
} finally {
|
||||
request.isHandling = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
updateActiveTemplateRequest,
|
||||
templateRequestStatusLabel,
|
||||
templateRequestSourceUrl,
|
||||
templateRequestReviewHint,
|
||||
startTemplateRequestReview,
|
||||
completeTemplateRequest,
|
||||
}
|
||||
}
|
||||
297
frontend/src/composables/useAdminUsers.js
Normal file
@@ -0,0 +1,297 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
export function useAdminUsers({
|
||||
api,
|
||||
auth,
|
||||
users,
|
||||
userQuery,
|
||||
userSort,
|
||||
userSortDirection,
|
||||
userAvatarInputs,
|
||||
modalTargetUser,
|
||||
modalPasswordDraft,
|
||||
modalRoleNextAdmin,
|
||||
modalUserDraftEmail,
|
||||
modalUserDraftNickname,
|
||||
modalUserDraftIsAdmin,
|
||||
userEditModalOpen,
|
||||
userPasswordModalOpen,
|
||||
userDeleteModalOpen,
|
||||
userRoleModalOpen,
|
||||
resetMessages,
|
||||
refreshUsers,
|
||||
success,
|
||||
error,
|
||||
}) {
|
||||
function setUserAvatarInput(userId, el) {
|
||||
if (!userId) return
|
||||
if (!el) {
|
||||
delete userAvatarInputs.value[userId]
|
||||
return
|
||||
}
|
||||
userAvatarInputs.value[userId] = el
|
||||
}
|
||||
|
||||
const canManageModalRole = computed(() => {
|
||||
if (!auth.user?.isPrimaryAdmin) return false
|
||||
if (!modalTargetUser.value) return false
|
||||
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 (
|
||||
modalUserDraftEmail.value.trim() !== (modalTargetUser.value.email || '') ||
|
||||
modalUserDraftNickname.value.trim() !== (modalTargetUser.value.nickname || '') ||
|
||||
!!modalUserDraftIsAdmin.value !== !!modalTargetUser.value.isAdmin
|
||||
)
|
||||
})
|
||||
|
||||
function roleLabelOf(user) {
|
||||
if (user?.isPrimaryAdmin) return '최고 관리자'
|
||||
if (user?.isAdmin) return '운영자'
|
||||
return '일반 회원'
|
||||
}
|
||||
|
||||
function openUserAvatarPicker(user) {
|
||||
if (!canEditUserAvatar(user)) return
|
||||
userAvatarInputs.value[user?.id]?.click()
|
||||
}
|
||||
|
||||
async function uploadUserAvatar(user, file, { remove = false } = {}) {
|
||||
resetMessages()
|
||||
if (!user?.id) return
|
||||
|
||||
try {
|
||||
user.isAvatarBusy = true
|
||||
const data = await api.updateAdminUserAvatar(user.id, { file, removeAvatar: remove })
|
||||
const updated = data.user
|
||||
users.value = users.value.map((entry) =>
|
||||
entry.id === updated.id
|
||||
? {
|
||||
...entry,
|
||||
...updated,
|
||||
isAvatarBusy: false,
|
||||
}
|
||||
: entry
|
||||
)
|
||||
if (modalTargetUser.value?.id === updated.id) {
|
||||
modalTargetUser.value = { ...modalTargetUser.value, ...updated }
|
||||
}
|
||||
if (updated.id === auth.user?.id) await auth.refresh()
|
||||
await refreshUsers()
|
||||
success.value = remove ? '회원 썸네일을 삭제했어요.' : '회원 썸네일을 업데이트했어요.'
|
||||
} catch (e) {
|
||||
error.value = remove ? '회원 썸네일 삭제에 실패했어요.' : '회원 썸네일 변경에 실패했어요.'
|
||||
} finally {
|
||||
const target = users.value.find((entry) => entry.id === user.id)
|
||||
if (target) target.isAvatarBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onUserAvatarChange(user, event) {
|
||||
const file = event.target.files && event.target.files[0] ? event.target.files[0] : null
|
||||
event.target.value = ''
|
||||
if (!file) return
|
||||
await uploadUserAvatar(user, file)
|
||||
}
|
||||
|
||||
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 || ''
|
||||
modalUserDraftNickname.value = user?.nickname || ''
|
||||
modalUserDraftIsAdmin.value = !!user?.isAdmin
|
||||
userEditModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeUserEditModal() {
|
||||
userEditModalOpen.value = false
|
||||
modalTargetUser.value = null
|
||||
modalUserDraftEmail.value = ''
|
||||
modalUserDraftNickname.value = ''
|
||||
modalUserDraftIsAdmin.value = false
|
||||
}
|
||||
|
||||
async function saveUserEdit() {
|
||||
resetMessages()
|
||||
if (!modalTargetUser.value?.id) return
|
||||
|
||||
try {
|
||||
const data = await api.updateAdminUser(modalTargetUser.value.id, {
|
||||
email: modalUserDraftEmail.value.trim(),
|
||||
nickname: modalUserDraftNickname.value.trim(),
|
||||
isAdmin: !!modalUserDraftIsAdmin.value,
|
||||
})
|
||||
const updated = data.user
|
||||
users.value = users.value.map((entry) =>
|
||||
entry.id === updated.id
|
||||
? {
|
||||
...entry,
|
||||
...updated,
|
||||
isAvatarBusy: entry.isAvatarBusy || false,
|
||||
}
|
||||
: entry
|
||||
)
|
||||
if (updated.id === auth.user?.id) await auth.refresh()
|
||||
closeUserEditModal()
|
||||
await refreshUsers()
|
||||
success.value = '회원 정보를 저장했어요.'
|
||||
} catch (e) {
|
||||
error.value = '회원 정보 저장에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
function openUserPasswordModal(user) {
|
||||
if (!canResetUserPassword(user)) return
|
||||
resetMessages()
|
||||
modalTargetUser.value = user ? { ...user } : null
|
||||
modalPasswordDraft.value = ''
|
||||
userPasswordModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeUserPasswordModal() {
|
||||
userPasswordModalOpen.value = false
|
||||
modalTargetUser.value = null
|
||||
modalPasswordDraft.value = ''
|
||||
}
|
||||
|
||||
function userDisplayName(user) {
|
||||
return user?.nickname || user?.email?.split('@')[0] || '알 수 없음'
|
||||
}
|
||||
|
||||
async function confirmUserPasswordReset() {
|
||||
resetMessages()
|
||||
if (!modalTargetUser.value?.id) return
|
||||
|
||||
const password = modalPasswordDraft.value.trim()
|
||||
if (!password) {
|
||||
error.value = '초기화할 비밀번호를 입력해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await api.updateAdminUserPassword(modalTargetUser.value.id, { password })
|
||||
success.value = `${userDisplayName(modalTargetUser.value)} 계정 비밀번호를 초기화했어요.`
|
||||
closeUserPasswordModal()
|
||||
} catch (e) {
|
||||
error.value = '비밀번호 초기화에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
function openUserDeleteModal(user) {
|
||||
if (!canDeleteUser(user)) return
|
||||
resetMessages()
|
||||
modalTargetUser.value = user ? { ...user } : null
|
||||
userDeleteModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeUserDeleteModal() {
|
||||
userDeleteModalOpen.value = false
|
||||
modalTargetUser.value = null
|
||||
}
|
||||
|
||||
async function confirmUserDelete() {
|
||||
resetMessages()
|
||||
if (!modalTargetUser.value?.id) return
|
||||
|
||||
try {
|
||||
const deletingSelf = modalTargetUser.value.id === auth.user?.id
|
||||
const deletedName = userDisplayName(modalTargetUser.value)
|
||||
await api.deleteAdminUser(modalTargetUser.value.id)
|
||||
users.value = users.value.filter((entry) => entry.id !== modalTargetUser.value.id)
|
||||
closeUserDeleteModal()
|
||||
success.value = `${deletedName} 계정을 삭제했어요.`
|
||||
if (deletingSelf) await auth.refresh()
|
||||
} catch (e) {
|
||||
error.value = '회원 삭제에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
function openUserRoleModal(user, nextIsAdmin = !modalUserDraftIsAdmin.value) {
|
||||
resetMessages()
|
||||
modalTargetUser.value = user ? { ...user } : null
|
||||
modalRoleNextAdmin.value = !!nextIsAdmin
|
||||
userRoleModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeUserRoleModal() {
|
||||
userRoleModalOpen.value = false
|
||||
if (!userEditModalOpen.value) modalTargetUser.value = null
|
||||
modalRoleNextAdmin.value = false
|
||||
}
|
||||
|
||||
function confirmUserRoleDraft() {
|
||||
if (!modalTargetUser.value?.id) return
|
||||
modalUserDraftIsAdmin.value = modalRoleNextAdmin.value
|
||||
const targetLabel = modalRoleNextAdmin.value ? '운영자 권한을 저장 대기 상태로 반영했어요.' : '운영자 권한 해제를 저장 대기 상태로 반영했어요.'
|
||||
closeUserRoleModal()
|
||||
success.value = targetLabel
|
||||
}
|
||||
|
||||
function submitUserFilters() {
|
||||
refreshUsers({
|
||||
q: userQuery.value,
|
||||
sort: userSort.value,
|
||||
direction: userSortDirection.value,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
setUserAvatarInput,
|
||||
canManageModalRole,
|
||||
canEditUserAvatar,
|
||||
canEditUserInfo,
|
||||
canResetUserPassword,
|
||||
canDeleteUser,
|
||||
isUserEditDirty,
|
||||
roleLabelOf,
|
||||
openUserAvatarPicker,
|
||||
onUserAvatarChange,
|
||||
removeUserAvatar,
|
||||
openUserEditModal,
|
||||
closeUserEditModal,
|
||||
saveUserEdit,
|
||||
openUserPasswordModal,
|
||||
closeUserPasswordModal,
|
||||
confirmUserPasswordReset,
|
||||
openUserDeleteModal,
|
||||
closeUserDeleteModal,
|
||||
confirmUserDelete,
|
||||
openUserRoleModal,
|
||||
closeUserRoleModal,
|
||||
confirmUserRoleDraft,
|
||||
submitUserFilters,
|
||||
userDisplayName,
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,96 @@
|
||||
import { toApiUrl } from './runtime'
|
||||
|
||||
function emitBackendStatus(detail) {
|
||||
if (typeof window === 'undefined') return
|
||||
window.dispatchEvent(new CustomEvent('tier-maker:backend-status', { detail }))
|
||||
}
|
||||
|
||||
async function request(path, { method = 'GET', body, headers } = {}) {
|
||||
const res = await fetch(toApiUrl(path), {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(headers || {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
let res
|
||||
try {
|
||||
res = await fetch(toApiUrl(path), {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(headers || {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
} catch (error) {
|
||||
emitBackendStatus({
|
||||
state: 'offline',
|
||||
message: '서버 연결을 확인할 수 없어 잠시 후 다시 시도해주세요.',
|
||||
path,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
|
||||
const contentType = res.headers.get('content-type') || ''
|
||||
const data = contentType.includes('application/json') ? await res.json() : await res.text()
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status >= 500 && data?.error === 'db_init_failed') {
|
||||
emitBackendStatus({
|
||||
state: 'maintenance',
|
||||
message: '서비스 점검 중이거나 데이터베이스 초기화 중입니다. 잠시 후 다시 이용해주세요.',
|
||||
path,
|
||||
})
|
||||
} else if (res.status >= 500) {
|
||||
emitBackendStatus({
|
||||
state: 'maintenance',
|
||||
message: '서비스 내부 점검이 필요합니다. 잠시 후 다시 이용해주세요.',
|
||||
path,
|
||||
})
|
||||
}
|
||||
const err = new Error('request_failed')
|
||||
err.status = res.status
|
||||
err.data = data
|
||||
throw err
|
||||
}
|
||||
emitBackendStatus({ state: 'online', path })
|
||||
return data
|
||||
}
|
||||
|
||||
export const api = {
|
||||
me: () => request('/api/auth/me'),
|
||||
authMeta: () => request('/api/auth/meta'),
|
||||
signup: ({ email, password }) => request('/api/auth/signup', { method: 'POST', body: { email, password } }),
|
||||
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' }),
|
||||
|
||||
listGames: () => request('/api/games'),
|
||||
getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`),
|
||||
favoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'POST' }),
|
||||
unfavoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'DELETE' }),
|
||||
updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }),
|
||||
updateAdminGameItem: (gameId, itemId, payload) =>
|
||||
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
|
||||
listAdminCustomItems: ({ q = '', page = 1, limit = 50, orphanOnly = false } = {}) =>
|
||||
listTopics: () => request('/api/topics'),
|
||||
getTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}`),
|
||||
favoriteTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}/favorite`, { method: 'POST' }),
|
||||
unfavoriteTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}/favorite`, { method: 'DELETE' }),
|
||||
updateAdminTemplateDisplayOrder: (payload) => request('/api/admin/templates/display-order', { method: 'PATCH', body: payload }),
|
||||
updateAdminTemplateItemDisplayOrder: (templateId, payload) =>
|
||||
request(`/api/admin/templates/${encodeURIComponent(templateId)}/items/display-order`, { method: 'PATCH', body: payload }),
|
||||
updateAdminTemplate: (templateId, payload) =>
|
||||
request(`/api/admin/templates/${encodeURIComponent(templateId)}`, { method: 'PATCH', body: payload }),
|
||||
updateAdminTemplateItem: (templateId, itemId, payload) =>
|
||||
request(`/api/admin/templates/${encodeURIComponent(templateId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
|
||||
listAdminCustomItems: ({ q = '', page = 1, limit = 50, filter = 'all' } = {}) =>
|
||||
request(
|
||||
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&orphanOnly=${encodeURIComponent(orphanOnly)}`
|
||||
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}`
|
||||
),
|
||||
listAdminTierLists: ({ q = '', page = 1, limit = 50 } = {}) =>
|
||||
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
|
||||
listAdminTierLists: ({ q = '', topicId = '', sort = 'recent', minFavorites = 0, page = 1, limit = 50 } = {}) =>
|
||||
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId)}&sort=${encodeURIComponent(sort)}&minFavorites=${encodeURIComponent(minFavorites)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
|
||||
getAdminTierListStats: ({ q = '', topicId = '', minFavorites = 0 } = {}) =>
|
||||
request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId)}&minFavorites=${encodeURIComponent(minFavorites)}`),
|
||||
updateAdminTierList: (tierListId, payload) =>
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'PATCH', body: payload }),
|
||||
updateAdminTierListFeatured: (tierListId, payload) =>
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/featured`, { method: 'PATCH', body: payload }),
|
||||
deleteAdminTierList: (tierListId) =>
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'DELETE' }),
|
||||
listAdminTemplateRequests: () => request('/api/admin/template-requests'),
|
||||
getAdminImageAssetStats: ({ month = '', limit = 12 } = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
@@ -51,16 +99,25 @@ export const api = {
|
||||
return request(`/api/admin/image-assets/stats?${query.toString()}`)
|
||||
},
|
||||
resetAdminImageAssetStats: (payload) => request('/api/admin/image-assets/stats/reset', { method: 'POST', body: payload || {} }),
|
||||
cleanupAdminMissingImageReferences: () => request('/api/admin/image-assets/missing/cleanup', { method: 'POST', body: {} }),
|
||||
listAdminUnusedImageAssets: ({ limit = 100, minAgeHours = 24 } = {}) => request(`/api/admin/image-assets/orphans?limit=${encodeURIComponent(limit)}&minAgeHours=${encodeURIComponent(minAgeHours)}`),
|
||||
cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }),
|
||||
promoteAdminCustomItem: (itemId, payload) =>
|
||||
promoteAdminTemplateItem: (itemId, payload) =>
|
||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
|
||||
updateAdminCustomItemLabel: (itemId, payload) =>
|
||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/label`, { method: 'PATCH', body: payload }),
|
||||
promoteAdminTierListItems: (tierListId, payload) =>
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/promote-items`, { method: 'POST', body: payload }),
|
||||
createAdminGameTemplateFromTierList: (tierListId, payload) =>
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-game-template`, { method: 'POST', body: payload }),
|
||||
createAdminTemplateFromTierList: (tierListId, payload) =>
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-template`, { method: 'POST', body: payload }),
|
||||
startAdminTemplateRequestReview: (requestId) =>
|
||||
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/review`, { method: 'POST', body: {} }),
|
||||
linkAdminTemplateRequestTemplate: (requestId, payload) =>
|
||||
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/link-template`, { method: 'POST', body: payload }),
|
||||
promoteAdminTemplateRequestItems: (requestId, payload) =>
|
||||
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/promote-items`, { method: 'POST', body: payload }),
|
||||
completeAdminTemplateRequest: (requestId) =>
|
||||
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/complete`, { method: 'POST', body: {} }),
|
||||
approveAdminTemplateRequest: (requestId, payload) =>
|
||||
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/approve`, { method: 'POST', body: payload || {} }),
|
||||
rejectAdminTemplateRequest: (requestId) => request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/reject`, { method: 'POST', body: {} }),
|
||||
@@ -90,14 +147,21 @@ export const api = {
|
||||
},
|
||||
deleteAdminUser: (userId) => request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }),
|
||||
|
||||
listPublicTierLists: (gameId) =>
|
||||
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}`),
|
||||
searchPublicTierLists: (gameId, q = '') =>
|
||||
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}&q=${encodeURIComponent(q || '')}`),
|
||||
listPublicTierListsByTopic: (topicId) =>
|
||||
request(`/api/tierlists/public?topicId=${encodeURIComponent(topicId || '')}`),
|
||||
searchPublicTierListsByTopic: (topicId, q = '') =>
|
||||
request(`/api/tierlists/public?topicId=${encodeURIComponent(topicId || '')}&q=${encodeURIComponent(q || '')}`),
|
||||
searchAllPublicTierLists: (q = '') => request(`/api/tierlists/public?q=${encodeURIComponent(q || '')}`),
|
||||
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' }),
|
||||
|
||||
46
frontend/src/lib/paths.js
Normal file
@@ -0,0 +1,46 @@
|
||||
function encodeSegment(value) {
|
||||
return encodeURIComponent(String(value || '').trim())
|
||||
}
|
||||
|
||||
export function homePath(query = '') {
|
||||
const normalized = String(query || '').trim()
|
||||
return normalized ? `/?q=${encodeURIComponent(normalized)}` : '/'
|
||||
}
|
||||
|
||||
export function loginPath(redirect = '') {
|
||||
const normalized = String(redirect || '').trim()
|
||||
return normalized ? `/login?redirect=${encodeURIComponent(normalized)}` : '/login'
|
||||
}
|
||||
|
||||
export function topicPath(topicId) {
|
||||
return `/topics/${encodeSegment(topicId)}`
|
||||
}
|
||||
|
||||
export function editorNewPath(topicId) {
|
||||
return `/editor/${encodeSegment(topicId)}/new`
|
||||
}
|
||||
|
||||
export function editorPath(topicId, tierListId, { preview = false } = {}) {
|
||||
const base = `/editor/${encodeSegment(topicId)}/${encodeSegment(tierListId)}`
|
||||
return preview ? `${base}?preview=1` : base
|
||||
}
|
||||
|
||||
export function mePath() {
|
||||
return '/me'
|
||||
}
|
||||
|
||||
export function favoritesPath() {
|
||||
return '/favorites'
|
||||
}
|
||||
|
||||
export function followingFeedPath() {
|
||||
return '/following'
|
||||
}
|
||||
|
||||
export function profilePath() {
|
||||
return '/profile'
|
||||
}
|
||||
|
||||
export function userProfilePath(userId) {
|
||||
return `/users/${encodeSegment(userId)}`
|
||||
}
|
||||
@@ -1,29 +1,53 @@
|
||||
import { createRouter as _createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import HomeView from '../views/HomeView.vue'
|
||||
import GameHubView from '../views/GameHubView.vue'
|
||||
import TopicHubView from '../views/TopicHubView.vue'
|
||||
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'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
export function createRouter() {
|
||||
return _createRouter({
|
||||
const router = _createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: HomeView },
|
||||
{ path: '/games/:gameId', name: 'gameHub', component: GameHubView },
|
||||
{ path: '/editor/:gameId/new', name: 'newEditor', component: TierEditorView },
|
||||
{ path: '/editor/:gameId/:tierListId', name: 'editEditor', component: TierEditorView },
|
||||
{ path: '/topics/:topicId', name: 'topicHub', component: TopicHubView },
|
||||
{ path: '/editor/:topicId/new', name: 'newEditor', component: TierEditorView },
|
||||
{ path: '/editor/:topicId/:tierListId', name: 'editEditor', component: TierEditorView },
|
||||
{ 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', name: 'admin', component: AdminView },
|
||||
{ path: '/admin', redirect: '/admin/featured' },
|
||||
{ path: '/admin/featured', name: 'adminFeatured', component: AdminView },
|
||||
{ path: '/admin/templates', name: 'adminTemplates', component: AdminView },
|
||||
{ path: '/admin/items', name: 'adminItems', component: AdminView },
|
||||
{ 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 },
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const routeName = String(to.name || '')
|
||||
if (!routeName.startsWith('admin')) return true
|
||||
|
||||
const auth = useAuthStore()
|
||||
if (!auth.hydrated) await auth.refresh()
|
||||
if (!auth.user?.isAdmin) {
|
||||
return { path: '/' }
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { api } from '../lib/api'
|
||||
|
||||
let refreshPromise = null
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
user: null,
|
||||
@@ -9,31 +11,47 @@ export const useAuthStore = defineStore('auth', {
|
||||
}),
|
||||
actions: {
|
||||
async refresh() {
|
||||
if (this.status === 'loading') return this.user
|
||||
if (refreshPromise) return refreshPromise
|
||||
this.status = 'loading'
|
||||
try {
|
||||
const data = await api.me()
|
||||
this.user = data.user
|
||||
return this.user
|
||||
} catch (error) {
|
||||
this.user = null
|
||||
return null
|
||||
} finally {
|
||||
this.status = 'idle'
|
||||
this.hydrated = true
|
||||
}
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const data = await api.me()
|
||||
this.user = data.user
|
||||
return this.user
|
||||
} catch (error) {
|
||||
this.user = null
|
||||
return null
|
||||
} finally {
|
||||
this.status = 'idle'
|
||||
this.hydrated = true
|
||||
refreshPromise = null
|
||||
}
|
||||
})()
|
||||
return refreshPromise
|
||||
},
|
||||
async signup(email, password) {
|
||||
const user = await api.signup({ email, password })
|
||||
this.user = user
|
||||
async signup(email, nickname, password) {
|
||||
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()
|
||||
@@ -42,4 +60,3 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import { editorPath, loginPath } from '../lib/paths'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
@@ -42,12 +43,12 @@ async function loadFavorites() {
|
||||
favorites.value = data.tierLists || []
|
||||
} catch (e) {
|
||||
toast.error('로그인이 필요해요.')
|
||||
router.push('/login?redirect=/favorites')
|
||||
router.push(loginPath('/favorites'))
|
||||
}
|
||||
}
|
||||
|
||||
function openTierList(tierList) {
|
||||
router.push(`/editor/${tierList.gameId}/${tierList.id}`)
|
||||
router.push(editorPath(tierList.topicSlug || tierList.topicId, tierList.id))
|
||||
}
|
||||
|
||||
onMounted(loadFavorites)
|
||||
@@ -58,11 +59,11 @@ onMounted(loadFavorites)
|
||||
<div class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Collection</div>
|
||||
<h2 class="pageHead__title">내 즐겨찾기</h2>
|
||||
<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="loadFavorites" />
|
||||
<input v-model="query" class="input" placeholder="제목, 주제, 작성자 검색" @keydown.enter.prevent="loadFavorites" />
|
||||
<select v-model="sort" class="select" @change="loadFavorites">
|
||||
<option value="favorited">즐겨찾기한 순</option>
|
||||
<option value="updated">최신 업데이트순</option>
|
||||
@@ -77,7 +78,7 @@ onMounted(loadFavorites)
|
||||
<article v-for="tierList in favorites" :key="tierList.id" class="boardCard">
|
||||
<button class="boardCard__body" @click="openTierList(tierList)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" />
|
||||
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" draggable="false" />
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="boardCard__head">
|
||||
@@ -87,7 +88,7 @@ onMounted(loadFavorites)
|
||||
</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<div class="boardCard__author">
|
||||
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" />
|
||||
<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>
|
||||
@@ -222,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
@@ -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.topicSlug || 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>
|
||||
@@ -5,22 +5,23 @@ import { api } from '../lib/api'
|
||||
import SvgIcon from '../components/SvgIcon.vue'
|
||||
import kidStarIcon from '../assets/icons/kid_star.svg'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { loginPath, topicPath } from '../lib/paths'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const items = ref([])
|
||||
const templateRecords = ref([])
|
||||
const error = ref('')
|
||||
const loadingFavoriteId = ref('')
|
||||
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim().toLowerCase() : ''))
|
||||
const games = computed(() => {
|
||||
const filtered = items.value
|
||||
const templates = computed(() => {
|
||||
const filtered = templateRecords.value
|
||||
.filter((item) => item.id !== 'freeform')
|
||||
.filter((item) => {
|
||||
if (!query.value) return true
|
||||
const haystack = `${item.name || ''} ${item.id || ''}`.toLowerCase()
|
||||
const haystack = `${item.name || ''} ${item.slug || ''}`.toLowerCase()
|
||||
return haystack.includes(query.value)
|
||||
})
|
||||
|
||||
@@ -29,38 +30,41 @@ const games = 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')
|
||||
})
|
||||
})
|
||||
|
||||
async function loadGames() {
|
||||
async function loadTemplates() {
|
||||
try {
|
||||
const data = await api.listGames()
|
||||
items.value = data.games || []
|
||||
const data = await api.listTopics()
|
||||
templateRecords.value = data.topics || []
|
||||
} catch (e) {
|
||||
error.value = '백엔드에 연결할 수 없어요. backend 서버가 실행 중인지 확인해주세요.'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadGames)
|
||||
watch(() => auth.user?.id, loadGames)
|
||||
onMounted(loadTemplates)
|
||||
watch(() => auth.user?.id, loadTemplates)
|
||||
|
||||
function goGame(gameId) {
|
||||
router.push(`/games/${gameId}`)
|
||||
function openTopic(template) {
|
||||
router.push(topicPath(template?.slug || template?.id || ''))
|
||||
}
|
||||
|
||||
async function toggleFavorite(game, event) {
|
||||
async function toggleFavorite(template, event) {
|
||||
event?.stopPropagation()
|
||||
if (!auth.user) {
|
||||
router.push(`/login?redirect=${encodeURIComponent(route.fullPath || '/')}`)
|
||||
router.push(loginPath(route.fullPath || '/'))
|
||||
return
|
||||
}
|
||||
if (!game?.id || loadingFavoriteId.value === game.id) return
|
||||
if (!template?.id || loadingFavoriteId.value === template.id) return
|
||||
|
||||
try {
|
||||
loadingFavoriteId.value = game.id
|
||||
const res = game.isFavorited ? await api.unfavoriteGame(game.id) : await api.favoriteGame(game.id)
|
||||
items.value = items.value.map((entry) => (entry.id === game.id ? { ...entry, ...res.game } : entry))
|
||||
loadingFavoriteId.value = template.id
|
||||
const res = template.isFavorited ? await api.unfavoriteTopic(template.id) : await api.favoriteTopic(template.id)
|
||||
templateRecords.value = templateRecords.value.map((entry) => (entry.id === template.id ? { ...entry, ...(res.topic || {}) } : entry))
|
||||
} catch (e) {
|
||||
error.value = '즐겨찾기 변경에 실패했어요.'
|
||||
} finally {
|
||||
@@ -68,46 +72,46 @@ async function toggleFavorite(game, event) {
|
||||
}
|
||||
}
|
||||
|
||||
function thumbUrl(g) {
|
||||
return g.thumbnailSrc ? toApiUrl(g.thumbnailSrc) : ''
|
||||
function templateThumbUrl(template) {
|
||||
return template.thumbnailSrc ? toApiUrl(template.thumbnailSrc) : ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Workspace</div>
|
||||
<h1 class="pageHead__title">Game Library</h1>
|
||||
<p class="pageHead__desc">자주 쓰는 게임 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 수 있어요.</p>
|
||||
<p v-if="query" class="pageHead__searchState">"{{ query }}"에 맞는 게임 템플릿만 보고 있어요.</p>
|
||||
<div class="pageHead__eyebrow">Topic</div>
|
||||
<h1 class="pageHead__title">주제 선택</h1>
|
||||
<p class="pageHead__desc">자주 쓰는 주제 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 수 있어요.</p>
|
||||
<p v-if="query" class="pageHead__searchState">"{{ query }}"에 맞는 주제 템플릿만 보고 있어요.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<TransitionGroup v-if="games.length" name="libraryCard" tag="section" class="libraryGrid">
|
||||
<article v-for="g in games" :key="g.id" class="libraryCard">
|
||||
<TransitionGroup v-if="templates.length" name="libraryCard" tag="section" class="libraryGrid">
|
||||
<article v-for="template in templates" :key="template.id" class="libraryCard">
|
||||
<button
|
||||
class="libraryCard__favorite"
|
||||
type="button"
|
||||
:class="{ 'libraryCard__favorite--active': g.isFavorited }"
|
||||
:disabled="loadingFavoriteId === g.id"
|
||||
@click.stop="toggleFavorite(g, $event)"
|
||||
:class="{ 'libraryCard__favorite--active': template.isFavorited }"
|
||||
:disabled="loadingFavoriteId === template.id"
|
||||
@click.stop="toggleFavorite(template, $event)"
|
||||
>
|
||||
<SvgIcon class="libraryCard__favoriteIcon" :src="kidStarIcon" :size="18" />
|
||||
</button>
|
||||
<button class="libraryCard__main" type="button" @click="goGame(g.id)">
|
||||
<button class="libraryCard__main" type="button" @click="openTopic(template)">
|
||||
<div class="libraryCard__thumbWrap">
|
||||
<img v-if="thumbUrl(g)" class="libraryCard__thumb" :src="thumbUrl(g)" :alt="g.name" />
|
||||
<img v-if="templateThumbUrl(template)" class="libraryCard__thumb" :src="templateThumbUrl(template)" :alt="template.name" draggable="false" />
|
||||
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="libraryCard__body">
|
||||
<div class="libraryCard__title">{{ g.name }}</div>
|
||||
<div class="libraryCard__meta">{{ g.id }}</div>
|
||||
<div class="libraryCard__title">{{ template.name }}</div>
|
||||
<div class="libraryCard__meta">{{ template.slug || template.id }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
</TransitionGroup>
|
||||
<div v-else class="libraryEmpty">{{ query ? '검색어에 맞는 게임 템플릿이 없어요.' : '표시할 게임 템플릿이 없어요.' }}</div>
|
||||
<div v-else class="libraryEmpty">{{ query ? '검색어에 맞는 주제 템플릿이 없어요.' : '표시할 주제 템플릿이 없어요.' }}</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -164,9 +168,9 @@ function thumbUrl(g) {
|
||||
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;
|
||||
@@ -176,16 +180,16 @@ function thumbUrl(g) {
|
||||
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%;
|
||||
|
||||
@@ -3,40 +3,104 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { api } from '../lib/api'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import { homePath, mePath } from '../lib/paths'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const email = ref('')
|
||||
const nickname = ref('')
|
||||
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)
|
||||
|
||||
watch(error, (message) => {
|
||||
if (!message) return
|
||||
toast.error(message)
|
||||
error.value = ''
|
||||
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 title = computed(() => (mode.value === 'signup' ? '회원가입' : '로그인'))
|
||||
const description = computed(() =>
|
||||
mode.value === 'signup'
|
||||
? '티어표를 저장하고 즐겨찾기, 개인 설정을 관리할 수 있도록 계정을 만들어요.'
|
||||
: '저장한 티어표와 즐겨찾기, 프로필 설정을 이어서 관리할 수 있어요.'
|
||||
)
|
||||
const submitLabel = computed(() => (mode.value === 'signup' ? '가입하기' : '로그인'))
|
||||
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 : '/me')
|
||||
router.replace(redirectPath.value)
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -51,23 +115,148 @@ watch(
|
||||
() => [auth.hydrated, auth.user],
|
||||
([hydrated, user]) => {
|
||||
if (!hydrated || !user) return
|
||||
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : '/me')
|
||||
if (verifyToken.value || resetToken.value) return
|
||||
router.replace(redirectPath.value)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(mode, () => {
|
||||
clearFormFeedback()
|
||||
})
|
||||
|
||||
watch(email, () => {
|
||||
emailError.value = ''
|
||||
if (error.value === '이메일이 이미 사용 중이에요.') error.value = ''
|
||||
})
|
||||
|
||||
watch(nickname, () => {
|
||||
nicknameError.value = ''
|
||||
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 = ''
|
||||
if (mode.value === 'signup' && password.value !== passwordConfirm.value) {
|
||||
clearFormFeedback()
|
||||
notice.value = ''
|
||||
if (mode.value === 'signup' && nickname.value.trim().length < 2) {
|
||||
nicknameError.value = '닉네임은 2자 이상 입력해주세요.'
|
||||
error.value = '닉네임을 확인해주세요.'
|
||||
return
|
||||
}
|
||||
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, password.value)
|
||||
else await auth.login(email.value, password.value)
|
||||
router.push(typeof route.query.redirect === 'string' ? route.query.redirect : '/me')
|
||||
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) {
|
||||
error.value = '로그인/회원가입에 실패했어요.'
|
||||
const code = e?.data?.error
|
||||
if (mode.value === 'signup') {
|
||||
if (code === 'email_taken') {
|
||||
emailError.value = '이미 사용 중인 이메일입니다.'
|
||||
error.value = '이메일이 이미 사용 중이에요.'
|
||||
return
|
||||
}
|
||||
if (code === 'nickname_taken') {
|
||||
nicknameError.value = '이미 사용 중인 닉네임입니다.'
|
||||
error.value = '닉네임이 이미 사용 중이에요.'
|
||||
return
|
||||
}
|
||||
if (code === 'nickname_reserved') {
|
||||
nicknameError.value = '운영자/관리자처럼 혼동될 수 있는 닉네임은 사용할 수 없어요.'
|
||||
error.value = '사용할 수 없는 닉네임이에요.'
|
||||
return
|
||||
}
|
||||
if (code === 'mail_not_configured') {
|
||||
error.value = '메일 발송 설정이 아직 완료되지 않아 이메일 인증을 보낼 수 없어요.'
|
||||
return
|
||||
}
|
||||
if (code === 'mail_send_failed') {
|
||||
error.value = '인증 메일 발송에 실패했어요. 잠시 후 다시 시도해주세요.'
|
||||
return
|
||||
}
|
||||
}
|
||||
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>
|
||||
@@ -87,36 +276,50 @@ async function submit() {
|
||||
</section>
|
||||
|
||||
<section v-else class="authScreen">
|
||||
<div class="authTabs" role="tablist" aria-label="로그인 또는 회원가입">
|
||||
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'login' }" @click="mode = 'login'">
|
||||
<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' || 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>
|
||||
|
||||
<label class="field">
|
||||
<span class="field__label">비밀번호</span>
|
||||
<label v-if="mode === 'signup'" class="field">
|
||||
<span class="field__label">닉네임</span>
|
||||
<input v-model="nickname" class="field__input" placeholder="사용할 닉네임" autocomplete="nickname" maxlength="40" />
|
||||
<span v-if="nicknameError" class="field__error">{{ nicknameError }}</span>
|
||||
<span class="field__hint">다른 사용자와 구분되는 이름으로 2~40자까지 입력할 수 있어요.</span>
|
||||
</label>
|
||||
|
||||
<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"
|
||||
@@ -129,11 +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('/')">취소</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>
|
||||
@@ -159,16 +379,37 @@ async function submit() {
|
||||
}
|
||||
|
||||
.authTabs {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
gap: 0;
|
||||
width: fit-content;
|
||||
padding: 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-pill-bg);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.authTabs__indicator {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
width: calc(50% - 6px);
|
||||
height: calc(100% - 12px);
|
||||
border-radius: 999px;
|
||||
background: rgba(76, 133, 245, 0.22);
|
||||
box-shadow: inset 0 0 0 1px rgba(120, 169, 255, 0.1);
|
||||
transform: translateX(0);
|
||||
transition: transform 220ms ease, background-color 220ms ease, box-shadow 220ms ease;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.authTabs--signup .authTabs__indicator {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.authTabs__button {
|
||||
position: relative;
|
||||
min-width: 112px;
|
||||
padding: 10px 16px;
|
||||
border: 0;
|
||||
@@ -177,10 +418,11 @@ async function submit() {
|
||||
color: var(--theme-text-muted);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: color 180ms ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.authTabs__button--active {
|
||||
background: rgba(76, 133, 245, 0.22);
|
||||
color: var(--theme-text-strong);
|
||||
}
|
||||
|
||||
@@ -220,6 +462,12 @@ async function submit() {
|
||||
color: var(--theme-text-soft);
|
||||
}
|
||||
|
||||
.field__error {
|
||||
font-size: 12px;
|
||||
color: #ff7b7b;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.roleBadge {
|
||||
width: fit-content;
|
||||
padding: 6px 10px;
|
||||
@@ -231,6 +479,50 @@ async function submit() {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.authError {
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(239, 68, 68, 0.28);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ff9b9b;
|
||||
font-size: 13px;
|
||||
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;
|
||||
@@ -252,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);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import { editorPath, loginPath } from '../lib/paths'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
@@ -54,22 +55,20 @@ onMounted(async () => {
|
||||
myLists.value = data.tierLists || []
|
||||
} catch (e) {
|
||||
toast.error('로그인이 필요해요.')
|
||||
router.push('/login?redirect=/me')
|
||||
router.push(loginPath('/me'))
|
||||
}
|
||||
})
|
||||
|
||||
function openList(t) {
|
||||
router.push(
|
||||
"/editor/" + t.gameId + "/" + t.id,
|
||||
)
|
||||
router.push(editorPath(t.topicSlug || t.topicId, t.id))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Library</div>
|
||||
<h2 class="pageHead__title">내 티어표</h2>
|
||||
<div class="pageHead__eyebrow">Tier Lists</div>
|
||||
<h2 class="pageHead__title">나의 티어표</h2>
|
||||
<div class="pageHead__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 수 있어요.</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -85,6 +84,7 @@ function openList(t) {
|
||||
class="boardCard__thumb"
|
||||
:src="tierListThumbnailUrl(t)"
|
||||
alt=""
|
||||
draggable="false"
|
||||
@error="handleThumbnailError(t.id)"
|
||||
/>
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
@@ -96,7 +96,7 @@ function openList(t) {
|
||||
</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<div class="boardCard__author">
|
||||
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
|
||||
<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>
|
||||
@@ -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,8 @@
|
||||
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'
|
||||
|
||||
@@ -11,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
|
||||
@@ -40,7 +49,7 @@ const displayInitial = computed(() => {
|
||||
onMounted(async () => {
|
||||
if (!auth.hydrated) await auth.refresh()
|
||||
if (!auth.user) {
|
||||
router.replace('/login')
|
||||
router.replace(loginPath())
|
||||
return
|
||||
}
|
||||
nickname.value = auth.user?.nickname || ''
|
||||
@@ -66,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
|
||||
@@ -79,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()
|
||||
@@ -91,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
|
||||
@@ -103,16 +134,64 @@ 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('로그아웃했어요.')
|
||||
router.push('/')
|
||||
router.push(homePath())
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -121,7 +200,7 @@ async function logout() {
|
||||
<header class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Account</div>
|
||||
<h2 class="pageHead__title">Settings</h2>
|
||||
<h2 class="pageHead__title">설정</h2>
|
||||
<div class="pageHead__desc">계정 정보를 간결하게 정리하고, 프로필 이미지를 클릭해서 바로 변경할 수 있어요.</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -131,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" />
|
||||
<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>
|
||||
@@ -189,8 +326,7 @@ async function logout() {
|
||||
<style scoped>
|
||||
.settingsScreen {
|
||||
display: grid;
|
||||
gap: 32px;
|
||||
max-width: 620px;
|
||||
gap: 24px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
@@ -211,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;
|
||||
@@ -354,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;
|
||||
@@ -372,6 +530,10 @@ async function logout() {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.settingsFields--password {
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.primaryAction,
|
||||
.secondaryAction {
|
||||
padding: 12px 18px;
|
||||
@@ -393,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;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { editorPath } from '../lib/paths'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -37,7 +38,7 @@ function tierListThumbnailUrl(tierList) {
|
||||
}
|
||||
|
||||
function openTierList(tierList) {
|
||||
router.push(`/editor/${tierList.gameId}/${tierList.id}`)
|
||||
router.push(editorPath(tierList.topicSlug || tierList.topicId, tierList.id))
|
||||
}
|
||||
|
||||
async function loadResults() {
|
||||
@@ -45,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 {
|
||||
@@ -65,13 +73,13 @@ watch(
|
||||
|
||||
<template>
|
||||
<section class="wrap">
|
||||
<div class="head">
|
||||
<div>
|
||||
<div class="head__eyebrow">Search</div>
|
||||
<h2 class="title">전체 티어표 검색</h2>
|
||||
<div class="desc">공개된 모든 티어표를 제목과 작성자 기준으로 찾아볼 수 있어요.</div>
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Search</div>
|
||||
<h2 class="pageHead__title">전체 티어표 검색</h2>
|
||||
<div class="pageHead__desc">공개된 티어표를 제목과 작성자 기준으로 다시 찾아볼 수 있어요.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div v-else-if="loading" class="empty">검색 중이에요.</div>
|
||||
@@ -80,7 +88,7 @@ watch(
|
||||
<article v-for="tierList in tierLists" :key="tierList.id" class="boardCard">
|
||||
<button class="boardCard__body" @click="openTierList(tierList)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" />
|
||||
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" draggable="false" />
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="boardCard__head">
|
||||
@@ -92,7 +100,7 @@ watch(
|
||||
</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<div class="boardCard__author">
|
||||
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" />
|
||||
<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>
|
||||
@@ -110,30 +118,6 @@ watch(
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px 2px 8px;
|
||||
}
|
||||
.head__eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--theme-text-soft);
|
||||
}
|
||||
.title {
|
||||
margin: 4px 0 0;
|
||||
font-size: 32px;
|
||||
color: var(--theme-text-strong);
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.desc {
|
||||
margin-top: 6px;
|
||||
color: var(--theme-text-muted);
|
||||
}
|
||||
.error {
|
||||
margin: 0 0 8px;
|
||||
padding: 10px 12px;
|
||||
@@ -238,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,22 +1,26 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { editorNewPath, editorPath, loginPath } from '../lib/paths'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const gameId = computed(() => route.params.gameId)
|
||||
|
||||
const gameName = ref('')
|
||||
const topicId = computed(() => route.params.topicId)
|
||||
const topicName = ref('')
|
||||
const featuredTierLists = ref([])
|
||||
const tierLists = ref([])
|
||||
const error = ref('')
|
||||
const query = ref('')
|
||||
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, {
|
||||
@@ -48,68 +52,129 @@ function handleThumbnailError(tierListId) {
|
||||
brokenThumbnailIds.value = { ...brokenThumbnailIds.value, [tierListId]: true }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadTierLists()
|
||||
})
|
||||
|
||||
async function loadTierLists() {
|
||||
isTopicLoading.value = true
|
||||
try {
|
||||
const [gameRes, listRes] = await Promise.all([
|
||||
api.getGame(gameId.value),
|
||||
api.searchPublicTierLists(gameId.value, query.value),
|
||||
const [topicRes, listRes] = await Promise.all([
|
||||
api.getTopic(topicId.value),
|
||||
api.searchPublicTierListsByTopic(topicId.value, query.value),
|
||||
])
|
||||
gameName.value = gameRes.game?.name || gameId.value
|
||||
topicName.value = topicRes.topic?.name || ''
|
||||
brokenThumbnailIds.value = {}
|
||||
featuredTierLists.value = listRes.featuredTierLists || []
|
||||
tierLists.value = listRes.tierLists || []
|
||||
} catch (e) {
|
||||
error.value = '게임 정보를 불러오지 못했어요.'
|
||||
error.value = '주제 정보를 불러오지 못했어요.'
|
||||
} finally {
|
||||
isTopicLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function createNew() {
|
||||
const target = editorNewPath(topicId.value)
|
||||
if (!auth.user) {
|
||||
router.push(`/login?redirect=/editor/${gameId.value}/new`)
|
||||
router.push(loginPath(target))
|
||||
return
|
||||
}
|
||||
router.push(`/editor/${gameId.value}/new`)
|
||||
router.push(target)
|
||||
}
|
||||
|
||||
function openTierList(id) {
|
||||
router.push(`/editor/${gameId.value}/${id}`)
|
||||
router.push(editorPath(topicId.value, id))
|
||||
}
|
||||
|
||||
function submitSearch() {
|
||||
loadTierLists()
|
||||
}
|
||||
|
||||
watch(
|
||||
topicId,
|
||||
() => {
|
||||
topicName.value = ''
|
||||
error.value = ''
|
||||
loadTierLists()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="dashboardHero">
|
||||
<div class="dashboardHero__left">
|
||||
<div class="dashboardHero__eyebrow">Collection</div>
|
||||
<h2 class="dashboardHero__title">{{ gameName || gameId }}</h2>
|
||||
<p class="dashboardHero__desc">이 게임의 공개 티어표를 탐색하고, 바로 새 보드를 만들어 같은 흐름으로 이어갈 수 있어요.</p>
|
||||
<section class="pageWrap">
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Collection</div>
|
||||
<h2 class="pageHead__title">{{ topicTitle }}</h2>
|
||||
<div class="pageHead__desc">이 주제의 공개 티어표를 같은 카드 레이아웃으로 살펴보고 이어서 새 티어표를 만들 수 있어요.</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="pageHead__aside toolbar">
|
||||
<input v-model="query" class="input" placeholder="제목 또는 작성자 검색" @keydown.enter.prevent="submitSearch" />
|
||||
<button class="btn" @click="submitSearch">검색</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<section class="panel">
|
||||
<div class="panel__head">
|
||||
<div>
|
||||
<div class="panel__title">공개 티어표</div>
|
||||
<div class="panel__sub">제목이나 작성자로 빠르게 좁혀볼 수 있어요.</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="searchBar">
|
||||
<input v-model="query" class="searchBar__input" placeholder="제목 또는 작성자 검색" @keydown.enter.prevent="submitSearch" />
|
||||
<button class="searchBar__button" @click="submitSearch">검색</button>
|
||||
<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>
|
||||
</div>
|
||||
<div v-if="tierLists.length === 0" class="empty">아직 공개 티어표가 없어요.</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="" @error="handleThumbnailError(t.id)" />
|
||||
<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">
|
||||
@@ -121,7 +186,7 @@ function submitSearch() {
|
||||
</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<div class="boardCard__author">
|
||||
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
|
||||
<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>
|
||||
@@ -131,76 +196,60 @@ function submitSearch() {
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboardHero {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px 2px 18px;
|
||||
}
|
||||
.dashboardHero__left {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.dashboardHero__eyebrow {
|
||||
font-size: 12px;
|
||||
color: var(--theme-text-soft);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.dashboardHero__title {
|
||||
margin: 4px 0 6px;
|
||||
font-size: 32px;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--theme-text-strong);
|
||||
}
|
||||
.dashboardHero__desc {
|
||||
margin: 0;
|
||||
color: var(--theme-text-muted);
|
||||
max-width: 720px;
|
||||
}
|
||||
.panel {
|
||||
/* border: 1px solid var(--theme-border); */
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.error {
|
||||
margin: 10px 0 14px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--theme-danger-border);
|
||||
background: var(--theme-danger-bg);
|
||||
.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);
|
||||
}
|
||||
.panel__title {
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
}
|
||||
.panel__sub {
|
||||
margin-top: 6px;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.panel__head {
|
||||
.featuredHead {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.searchBar {
|
||||
.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;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.searchBar__input {
|
||||
.input {
|
||||
min-width: 240px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
@@ -208,8 +257,8 @@ function submitSearch() {
|
||||
background: var(--theme-surface-soft);
|
||||
color: var(--theme-text);
|
||||
}
|
||||
.searchBar__button {
|
||||
padding: 11px 14px;
|
||||
.btn {
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-surface-soft-2);
|
||||
@@ -217,6 +266,13 @@ function submitSearch() {
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.error {
|
||||
margin: 10px 0 14px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--theme-danger-border);
|
||||
background: var(--theme-danger-bg);
|
||||
}
|
||||
.empty {
|
||||
opacity: 0.75;
|
||||
}
|
||||
@@ -246,6 +302,12 @@ function submitSearch() {
|
||||
background: var(--theme-card-bg-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.boardCard--featured {
|
||||
border-color: color-mix(in srgb, var(--theme-accent) 35%, var(--theme-card-border));
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--theme-accent) 7%, transparent), transparent 55%),
|
||||
var(--theme-card-bg);
|
||||
}
|
||||
.boardCard__body {
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
@@ -361,7 +423,7 @@ function submitSearch() {
|
||||
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;
|
||||
}
|
||||
@@ -401,11 +463,26 @@ function submitSearch() {
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
.searchBar__input {
|
||||
.toolbar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
466
frontend/src/views/UserProfileView.vue
Normal file
@@ -0,0 +1,466 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { editorPath, followingFeedPath, loginPath } from '../lib/paths'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const userId = computed(() => route.params.userId || '')
|
||||
const profile = ref(null)
|
||||
const tierLists = ref([])
|
||||
const query = ref('')
|
||||
const isLoading = ref(false)
|
||||
const isFollowBusy = ref(false)
|
||||
const error = ref('')
|
||||
const brokenThumbnailIds = ref({})
|
||||
|
||||
const profileAvatarUrl = computed(() => (profile.value?.avatarSrc ? toApiUrl(profile.value.avatarSrc) : ''))
|
||||
const profileDisplayName = computed(() => profile.value?.nickname || profile.value?.accountName || '알 수 없음')
|
||||
const profileFallback = computed(() => (profile.value?.accountName || 'u').trim().charAt(0).toUpperCase() || '?')
|
||||
const canFollow = computed(() => !!auth.user && !!profile.value && !profile.value.isSelf)
|
||||
|
||||
watch(error, (message) => {
|
||||
if (!message) return
|
||||
toast.error(message)
|
||||
error.value = ''
|
||||
})
|
||||
|
||||
function fmt(ts) {
|
||||
return new Date(ts).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function displayNameOf(tierList) {
|
||||
return tierList.authorName || profileDisplayName.value
|
||||
}
|
||||
|
||||
function avatarSrcOf(tierList) {
|
||||
return tierList.authorAvatarSrc ? toApiUrl(tierList.authorAvatarSrc) : profileAvatarUrl.value
|
||||
}
|
||||
|
||||
function avatarFallbackOf(tierList) {
|
||||
return (tierList.authorAccountName || profile.value?.accountName || 'u').trim().charAt(0).toUpperCase() || '?'
|
||||
}
|
||||
|
||||
function tierListThumbnailUrl(tierList) {
|
||||
if (!tierList?.id || brokenThumbnailIds.value[tierList.id]) return ''
|
||||
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
|
||||
}
|
||||
|
||||
function handleThumbnailError(tierListId) {
|
||||
if (!tierListId || brokenThumbnailIds.value[tierListId]) return
|
||||
brokenThumbnailIds.value = { ...brokenThumbnailIds.value, [tierListId]: true }
|
||||
}
|
||||
|
||||
async function loadProfile() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
if (!auth.hydrated) await auth.refresh()
|
||||
const [profileRes, tierListsRes] = await Promise.all([
|
||||
api.getUserProfile(userId.value),
|
||||
api.listUserPublicTierLists(userId.value, { q: query.value }),
|
||||
])
|
||||
profile.value = profileRes.user || null
|
||||
tierLists.value = tierListsRes.tierLists || []
|
||||
brokenThumbnailIds.value = {}
|
||||
} catch (e) {
|
||||
error.value = '작성자 프로필을 불러오지 못했어요.'
|
||||
profile.value = null
|
||||
tierLists.value = []
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFollow() {
|
||||
if (!canFollow.value || !profile.value?.id || isFollowBusy.value) return
|
||||
try {
|
||||
isFollowBusy.value = true
|
||||
const data = profile.value.isFollowing
|
||||
? await api.unfollowUser(profile.value.id)
|
||||
: await api.followUser(profile.value.id)
|
||||
profile.value = data.user || profile.value
|
||||
toast.success(profile.value.isFollowing ? '팔로우했어요.' : '팔로우를 해제했어요.')
|
||||
} catch (e) {
|
||||
if (e?.status === 401) {
|
||||
router.push(loginPath(route.fullPath))
|
||||
return
|
||||
}
|
||||
error.value = '팔로우 상태를 변경하지 못했어요.'
|
||||
} finally {
|
||||
isFollowBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openTierList(tierList) {
|
||||
router.push(editorPath(tierList.topicSlug || tierList.topicId, tierList.id))
|
||||
}
|
||||
|
||||
watch(userId, loadProfile, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pageWrap">
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">User Profile</div>
|
||||
<h2 class="pageHead__title">사용자 프로필</h2>
|
||||
<div class="pageHead__desc">
|
||||
이 사용자가 공개한 티어표를 모아볼 수 있어요.
|
||||
</div>
|
||||
</div>
|
||||
<div class="pageHead__aside profileActions">
|
||||
<button v-if="canFollow" class="btn btn--primary" :disabled="isFollowBusy" type="button" @click="toggleFollow">
|
||||
{{ profile?.isFollowing ? '팔로잉' : '팔로우' }}
|
||||
</button>
|
||||
<button v-if="auth.user" class="btn" type="button" @click="router.push(followingFeedPath())">팔로우 피드</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="profileHero">
|
||||
<div class="profileCard">
|
||||
<img v-if="profileAvatarUrl" class="profileAvatar" :src="profileAvatarUrl" :alt="profileDisplayName" draggable="false" />
|
||||
<div v-else class="profileAvatar profileAvatar--fallback">{{ profileFallback }}</div>
|
||||
<div class="profileMeta">
|
||||
<div class="profileMeta__name">{{ profileDisplayName }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profileStats">
|
||||
<article class="profileStat">
|
||||
<span class="profileStat__label">공개 티어표</span>
|
||||
<strong class="profileStat__value">{{ profile?.publicTierListCount || 0 }}</strong>
|
||||
</article>
|
||||
<article class="profileStat">
|
||||
<span class="profileStat__label">팔로워</span>
|
||||
<strong class="profileStat__value">{{ profile?.followerCount || 0 }}</strong>
|
||||
</article>
|
||||
<article class="profileStat">
|
||||
<span class="profileStat__label">팔로잉</span>
|
||||
<strong class="profileStat__value">{{ profile?.followingCount || 0 }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="listToolbar">
|
||||
<input v-model="query" class="input" placeholder="이 작성자의 공개 티어표 검색" @keydown.enter.prevent="loadProfile" />
|
||||
<button class="btn" :disabled="isLoading" type="button" @click="loadProfile">{{ isLoading ? '검색중...' : '검색' }}</button>
|
||||
</section>
|
||||
|
||||
<div v-if="isLoading" class="empty">작성자 티어표를 불러오고 있어요.</div>
|
||||
<div v-else-if="!tierLists.length" class="empty">아직 공개 티어표가 없어요.</div>
|
||||
<div v-else class="list">
|
||||
<article v-for="tierList in tierLists" :key="tierList.id" class="boardCard">
|
||||
<button class="boardCard__body" type="button" @click="openTierList(tierList)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img
|
||||
v-if="tierListThumbnailUrl(tierList)"
|
||||
class="boardCard__thumb"
|
||||
:src="tierListThumbnailUrl(tierList)"
|
||||
:alt="tierList.title"
|
||||
draggable="false"
|
||||
@error="handleThumbnailError(tierList.id)"
|
||||
/>
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="boardCard__head">
|
||||
<div class="boardCard__titleRow">
|
||||
<div class="boardCard__title">{{ tierList.title }}</div>
|
||||
<div class="favoriteStat">{{ tierList.isFavorited ? '♥' : '♡' }} {{ tierList.favoriteCount || 0 }}</div>
|
||||
</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<div class="boardCard__author">
|
||||
<img
|
||||
v-if="avatarSrcOf(tierList)"
|
||||
class="boardCard__avatar"
|
||||
:src="avatarSrcOf(tierList)"
|
||||
:alt="displayNameOf(tierList)"
|
||||
draggable="false"
|
||||
/>
|
||||
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
|
||||
<span class="boardCard__authorName">{{ displayNameOf(tierList) }}</span>
|
||||
</div>
|
||||
<div class="boardCard__date">{{ fmt(tierList.updatedAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profileActions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.btn {
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-surface-soft-2);
|
||||
color: var(--theme-text);
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn--primary {
|
||||
border: 0;
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
color: #fff;
|
||||
}
|
||||
.profileHero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(320px, 420px);
|
||||
gap: 18px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.profileCard,
|
||||
.profileStat {
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--theme-card-border);
|
||||
background: var(--theme-card-bg);
|
||||
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
|
||||
}
|
||||
.profileCard {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
}
|
||||
.profileAvatar {
|
||||
width: 82px;
|
||||
height: 82px;
|
||||
border-radius: 9999px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--theme-avatar-border);
|
||||
background: var(--theme-border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.profileAvatar--fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.profileMeta {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.profileMeta__name {
|
||||
font-size: 24px;
|
||||
font-weight: 900;
|
||||
color: var(--theme-text);
|
||||
word-break: break-word;
|
||||
}
|
||||
.profileStats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.profileStat {
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
align-content: center;
|
||||
}
|
||||
.profileStat__label {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: var(--theme-text-faint);
|
||||
}
|
||||
.profileStat__value {
|
||||
font-size: 26px;
|
||||
font-weight: 900;
|
||||
color: var(--theme-text);
|
||||
}
|
||||
.listToolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.input {
|
||||
min-width: 260px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-surface-soft);
|
||||
color: var(--theme-text);
|
||||
}
|
||||
.empty {
|
||||
opacity: 0.76;
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.boardCard {
|
||||
min-width: 0;
|
||||
border-radius: 22px;
|
||||
border: 1px solid var(--theme-card-border);
|
||||
background: var(--theme-card-bg);
|
||||
color: var(--theme-text);
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
background 0.16s ease;
|
||||
}
|
||||
.boardCard:hover {
|
||||
background: var(--theme-card-bg-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.boardCard__body {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
}
|
||||
.boardCard__thumbWrap {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
padding: 14px 14px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.boardCard__thumb,
|
||||
.boardCard__thumbPlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 18px;
|
||||
display: block;
|
||||
}
|
||||
.boardCard__thumb {
|
||||
object-fit: cover;
|
||||
}
|
||||
.boardCard__thumbPlaceholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--theme-thumb-fallback-bg);
|
||||
color: var(--theme-text-faint);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.boardCard__head {
|
||||
min-width: 0;
|
||||
padding: 16px 18px 18px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.boardCard__titleRow,
|
||||
.boardCard__metaRow {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
}
|
||||
.boardCard__titleRow {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.boardCard__metaRow {
|
||||
align-items: flex-end;
|
||||
}
|
||||
.boardCard__title {
|
||||
min-width: 0;
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
line-height: 1.35;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-word;
|
||||
}
|
||||
.boardCard__author {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
opacity: 0.86;
|
||||
overflow: hidden;
|
||||
}
|
||||
.boardCard__authorName {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.boardCard__avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 9999px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--theme-avatar-border);
|
||||
background: var(--theme-border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.boardCard__avatar--fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.boardCard__date,
|
||||
.favoriteStat {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-size: 13px;
|
||||
color: var(--theme-text-faint);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.boardCard__date {
|
||||
font-size: 10px;
|
||||
}
|
||||
@media (max-width: 1400px) {
|
||||
.list {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.profileHero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.profileCard {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.profileStats,
|
||||
.list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||