Compare commits

..

9 Commits

Author SHA1 Message Date
DUCK JIN
08ca42b5e5 Restore NAS production ports 2026-08-12 18:43:16 +09:00
DUCK JIN
db375a25ed Add external server stats API 2026-08-12 17:48:34 +09:00
DUCK JIN
26baef8448 fix: normalize frontend asset permissions 2026-06-23 18:53:57 +09:00
DUCK JIN
050b6a4457 fix: register new favicon assets 2026-06-23 18:46:37 +09:00
DUCK JIN
cb03983e96 Update favicon references to use public favicon files 2026-06-23 10:49:19 +09:00
DUCK JIN
49ae97141f 이미지 저장 시 이름 오버레이 말줄임 처리
티어표 PNG 내보내기 시 캐릭터 이름 슬라이드 효과를 비활성화하고, 왼쪽 정렬과 말줄임표가 안정적으로 적용되도록 보강했다.

Made-with: Cursor
2026-04-27 14:21:08 +09:00
73d198f5a9 로컬 환경 분리와 회원가입 중복 검증 보강
로컬 개발 환경은 .env.local을 우선 읽고 운영은 .env.production만 읽도록 분리해 인증 우회 설정이 운영에 섞이지 않게 정리했다. 회원가입/로그인 이메일 정규화와 중복 예외 처리를 보강해 대소문자·공백 변형 중복 가입을 안정적으로 차단했다.

Made-with: Cursor
2026-04-27 11:33:10 +09:00
069eda3544 이름 오버레이 슬라이드와 로컬 메일 인증 우회 추가
캐릭터 이름 표시가 긴 항목은 카드 하단 텍스트를 자동 무한 슬라이드로 보여주고, 넘치지 않는 이름은 기존 고정 표시를 유지하도록 정리했다. 개발 환경에서는 SKIP_EMAIL_VERIFICATION=true 설정으로 가입 시 메일 인증 단계를 건너뛸 수 있게 보강했다.

Made-with: Cursor
2026-04-27 11:10:59 +09:00
2559605318 목록관리 템플릿 추가 모달 정리 2026-04-08 09:50:19 +09:00
53 changed files with 591 additions and 67 deletions

19
.env.local.example Normal file
View File

@@ -0,0 +1,19 @@
MARIADB_ROOT_PASSWORD=tier_cursor_root_local
MARIADB_DATABASE=tier_cursor
MARIADB_USER=tier_cursor
MARIADB_PASSWORD=tier_cursor1234
DB_HOST=127.0.0.1
DB_PORT=3307
DB_USER=tier_cursor
DB_PASSWORD=tier_cursor1234
DB_NAME=tier_cursor
SESSION_SECRET=tmaker-local-session-secret
SERVER_STATS_TOKEN=tmaker-local-dashboard-token
APP_ORIGIN=http://localhost:5173
SMTP_HOST=smtp.gmail.com
SMTP_PORT=465
SMTP_SECURE=true
SMTP_USER=local-mailer@tmaker.local
SMTP_PASS=local-mailer-app-password
SMTP_FROM="Tier Maker <local-mailer@tmaker.local>"
SKIP_EMAIL_VERIFICATION=true

View File

@@ -3,6 +3,7 @@ MARIADB_DATABASE=tier_db
MARIADB_USER=zenn
MARIADB_PASSWORD=wps!xldj180204
SESSION_SECRET=291fbf7d5d112786a84d8ff62dd2839ce343a820c47d88da746ec78e11d640b3
SERVER_STATS_TOKEN=d36841db050c4050bfb875045516868c0097db5c6a176989c6d9eafc93b990be
APP_ORIGIN=https://tmaker.sori.studio
SMTP_HOST=smtp.gmail.com
SMTP_PORT=465
@@ -11,3 +12,5 @@ SMTP_USER=zenn.sori.studio@gmail.com
SMTP_PASS=kcasoehxcspqdoxz
SMTP_FROM="Tier Maker <zenn.sori.studio@gmail.com>"
NICKNAME_CHANGE_INTERVAL_DAYS=14
# SKIP_EMAIL_VERIFICATION=false
SERVER_STATS_TOKEN=d36841db050c4050bfb875045516868c0097db5c6a176989c6d9eafc93b990be

View File

@@ -1,12 +1,14 @@
MARIADB_ROOT_PASSWORD=change-this-root-password
MARIADB_ROOT_PASSWORD=tier_cursor_root_production
MARIADB_DATABASE=tier_cursor
MARIADB_USER=tier_cursor
MARIADB_PASSWORD=change-this-db-password
SESSION_SECRET=change-this-session-secret
MARIADB_PASSWORD=tier_cursor_production_password
SESSION_SECRET=tmaker-production-session-secret
SERVER_STATS_TOKEN=change-this-long-random-dashboard-token
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>"
SMTP_USER=mailer@tmaker.sori.studio
SMTP_PASS=mailer-app-password
SMTP_FROM="Tier Maker <mailer@tmaker.sori.studio>"
SKIP_EMAIL_VERIFICATION=false

View File

@@ -18,6 +18,7 @@ docker compose up -d mariadb phpmyadmin
### 2) 백엔드 실행
```bash
cp .env.local.example .env.local
cd backend
npm install
npm run dev

View File

@@ -5,10 +5,26 @@ const express = require('express')
const cors = require('cors')
const session = require('express-session')
const FileStoreFactory = require('session-file-store')
const crypto = require('crypto')
dotenv.config({ path: path.join(__dirname, '..', '.env.production') })
function loadEnvFileIfExists(fileName) {
const envPath = path.join(__dirname, '..', fileName)
if (!fs.existsSync(envPath)) return false
dotenv.config({ path: envPath })
return true
}
const { ensureData } = require('./src/db')
loadEnvFileIfExists('.env')
const nodeEnv = String(process.env.NODE_ENV || 'development').trim().toLowerCase()
if (nodeEnv === 'production') {
loadEnvFileIfExists('.env.production')
} else {
const loadedLocal = loadEnvFileIfExists('.env.local')
if (!loadedLocal) loadEnvFileIfExists('.env.production')
}
const { ensureData, getServerStats } = require('./src/db')
const authRoutes = require('./src/routes/auth')
const topicsRoutes = require('./src/routes/topics')
const tierListsRoutes = require('./src/routes/tierlists')
@@ -27,9 +43,30 @@ const allowedOrigins = (process.env.CORS_ORIGINS || '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean)
const SERVER_STATS_TOKEN = String(process.env.SERVER_STATS_TOKEN || '').trim()
const FileStore = FileStoreFactory(session)
function getBearerToken(req) {
const authorization = String(req.headers.authorization || '')
const match = authorization.match(/^Bearer\s+(.+)$/i)
if (match) return match[1].trim()
return String(req.query.token || '').trim()
}
function isEqualSecret(a, b) {
const left = Buffer.from(String(a || ''))
const right = Buffer.from(String(b || ''))
return left.length === right.length && crypto.timingSafeEqual(left, right)
}
function requireServerStatsToken(req, res, next) {
if (!SERVER_STATS_TOKEN) return res.status(503).json({ error: 'server_stats_token_not_configured' })
const token = getBearerToken(req)
if (!token || !isEqualSecret(token, SERVER_STATS_TOKEN)) return res.status(401).json({ error: 'unauthorized' })
next()
}
;['uploads/avatars', 'uploads/topics', 'uploads/custom', 'uploads/tierlists', '.sessions'].forEach((relativePath) => {
fs.mkdirSync(path.join(__dirname, relativePath), { recursive: true })
})
@@ -86,6 +123,10 @@ app.use(async (req, res, next) => {
})
app.use('/api/auth', authRoutes)
app.get('/api/server-stats', requireServerStatsToken, async (req, res) => {
const stats = await getServerStats()
res.json(stats)
})
app.use('/api/topics', topicsRoutes)
app.use('/api/tierlists', tierListsRoutes)
app.use('/api/comments', commentsRoutes)

View File

@@ -4,8 +4,8 @@
"description": "",
"main": "index.js",
"scripts": {
"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",
"dev": "NODE_ENV=development nodemon --legacy-watch --watch index.js --watch src index.js",
"start": "NODE_ENV=production 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",

View File

@@ -699,9 +699,16 @@ async function countUsers() {
}
async function findUserByEmail(email) {
const normalized = String(email || '').trim().toLowerCase()
if (!normalized) return null
const rows = await query(
'SELECT id, email, nickname, nickname_updated_at, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE email = ? LIMIT 1',
[email]
`
SELECT id, email, nickname, nickname_updated_at, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at
FROM users
WHERE TRIM(LOWER(email)) = TRIM(LOWER(?))
LIMIT 1
`,
[normalized]
)
const row = rows[0]
if (!row) return null
@@ -745,12 +752,14 @@ async function findUserById(id) {
async function createUser({ id, email, nickname, passwordHash, emailVerified = true, isAdmin }) {
const createdAt = now()
const normalizedEmail = String(email || '').trim().toLowerCase()
const normalizedNickname = String(nickname || '').trim()
await query(
`
INSERT INTO users (id, email, nickname, nickname_updated_at, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[id, email, nickname || '', createdAt, passwordHash, emailVerified ? 1 : 0, isAdmin ? 1 : 0, '', isAdmin ? createdAt : 0, createdAt]
[id, normalizedEmail, normalizedNickname, createdAt, passwordHash, emailVerified ? 1 : 0, isAdmin ? 1 : 0, '', isAdmin ? createdAt : 0, createdAt]
)
return findUserById(id)
}
@@ -3131,6 +3140,117 @@ async function summarizeAdminTierLists({ queryText = '', topicId = '', minFavori
}
}
async function getServerStats() {
const [
userRows,
tierListRows,
topicRows,
templateRequestRows,
customItemRows,
topicItemRows,
commentRows,
favoriteRows,
followRows,
] = await Promise.all([
query(
`SELECT
COUNT(*) AS total_count,
COALESCE(SUM(CASE WHEN is_admin = 1 THEN 1 ELSE 0 END), 0) AS admin_count,
COALESCE(SUM(CASE WHEN email_verified = 1 THEN 1 ELSE 0 END), 0) AS verified_count,
COALESCE(SUM(CASE WHEN last_login_at > 0 THEN 1 ELSE 0 END), 0) AS logged_in_count
FROM users`
),
query(
`SELECT
COUNT(*) AS total_count,
COALESCE(SUM(CASE WHEN is_public = 1 THEN 1 ELSE 0 END), 0) AS public_count,
COALESCE(SUM(CASE WHEN is_featured = 1 THEN 1 ELSE 0 END), 0) AS featured_count
FROM tierlists`
),
query(
`SELECT
COUNT(*) AS total_count,
COALESCE(SUM(CASE WHEN is_public = 1 THEN 1 ELSE 0 END), 0) AS public_count
FROM topics
WHERE id <> ?`,
[FREEFORM_TOPIC_ID]
),
query(
`SELECT
COUNT(*) AS total_count,
COALESCE(SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END), 0) AS pending_count,
COALESCE(SUM(CASE WHEN status = 'reviewing' THEN 1 ELSE 0 END), 0) AS reviewing_count,
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0) AS completed_count,
COALESCE(SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END), 0) AS rejected_count,
COALESCE(SUM(CASE WHEN request_type = 'create' THEN 1 ELSE 0 END), 0) AS create_count,
COALESCE(SUM(CASE WHEN request_type = 'update' THEN 1 ELSE 0 END), 0) AS update_count
FROM template_requests`
),
query('SELECT COUNT(*) AS total_count FROM custom_items'),
query('SELECT COUNT(*) AS total_count FROM topic_items'),
query('SELECT COUNT(*) AS total_count FROM tierlist_comments'),
query('SELECT COUNT(*) AS total_count FROM favorite_tierlists'),
query('SELECT COUNT(*) AS total_count FROM user_follows'),
])
const users = userRows[0] || {}
const tierLists = tierListRows[0] || {}
const topics = topicRows[0] || {}
const templateRequests = templateRequestRows[0] || {}
const customItems = customItemRows[0] || {}
const topicItems = topicItemRows[0] || {}
const comments = commentRows[0] || {}
const favorites = favoriteRows[0] || {}
const follows = followRows[0] || {}
const tierListTotal = Number(tierLists.total_count || 0)
const tierListPublicCount = Number(tierLists.public_count || 0)
const topicTotal = Number(topics.total_count || 0)
const topicPublicCount = Number(topics.public_count || 0)
const userCustomItemCount = Number(customItems.total_count || 0)
const templateItemCount = Number(topicItems.total_count || 0)
return {
ok: true,
generatedAt: now(),
users: {
total: Number(users.total_count || 0),
admins: Number(users.admin_count || 0),
verified: Number(users.verified_count || 0),
loggedInAtLeastOnce: Number(users.logged_in_count || 0),
},
tierLists: {
total: tierListTotal,
publicCount: tierListPublicCount,
privateCount: Math.max(0, tierListTotal - tierListPublicCount),
featuredCount: Number(tierLists.featured_count || 0),
favoriteCount: Number(favorites.total_count || 0),
commentCount: Number(comments.total_count || 0),
},
topics: {
total: topicTotal,
publicCount: topicPublicCount,
privateCount: Math.max(0, topicTotal - topicPublicCount),
},
templateRequests: {
total: Number(templateRequests.total_count || 0),
pending: Number(templateRequests.pending_count || 0),
reviewing: Number(templateRequests.reviewing_count || 0),
completed: Number(templateRequests.completed_count || 0),
rejected: Number(templateRequests.rejected_count || 0),
create: Number(templateRequests.create_count || 0),
update: Number(templateRequests.update_count || 0),
},
customItems: {
total: userCustomItemCount + templateItemCount,
userUploaded: userCustomItemCount,
template: templateItemCount,
},
social: {
follows: Number(follows.total_count || 0),
},
}
}
async function findTierListById(id, currentUserId = '') {
const rows = await query(
`
@@ -3580,6 +3700,7 @@ module.exports = {
listUserTierLists,
listAdminTierLists,
summarizeAdminTierLists,
getServerStats,
findTierListById,
updateAdminTierListMeta,
updateTierListFeaturedStatus,

View File

@@ -30,6 +30,11 @@ 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 SKIP_EMAIL_VERIFICATION = process.env.SKIP_EMAIL_VERIFICATION === 'true'
function shouldSkipEmailVerification() {
return SKIP_EMAIL_VERIFICATION && process.env.NODE_ENV !== 'production'
}
function resolveNicknameChangeIntervalMs() {
const rawHours = String(process.env.NICKNAME_CHANGE_INTERVAL_HOURS || '').trim()
@@ -140,6 +145,14 @@ function hashToken(token) {
return crypto.createHash('sha256').update(String(token || '')).digest('hex')
}
function normalizeEmail(value) {
return String(value || '').trim().toLowerCase()
}
function normalizeNickname(value) {
return String(value || '').trim()
}
function resolveAppOrigin(req) {
const envOrigin = String(process.env.APP_ORIGIN || process.env.PUBLIC_APP_ORIGIN || '').trim()
if (envOrigin) return envOrigin.replace(/\/+$/, '')
@@ -202,7 +215,9 @@ 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, nickname, password } = parsed.data
const email = normalizeEmail(parsed.data.email)
const nickname = normalizeNickname(parsed.data.nickname)
const { 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' })
@@ -211,20 +226,29 @@ router.post('/signup', async (req, res) => {
const passwordHash = await bcrypt.hash(password, 10)
const isAdmin = (await countUsers()) === 0
if (!isAdmin && !isMailerConfigured()) {
const skipEmailVerification = !isAdmin && shouldSkipEmailVerification()
if (!isAdmin && !skipEmailVerification && !isMailerConfigured()) {
return res.status(503).json({ error: 'mail_not_configured' })
}
const user = await createUser({
id: nanoid(),
email,
nickname,
passwordHash,
emailVerified: isAdmin,
isAdmin,
})
let user = null
try {
user = await createUser({
id: nanoid(),
email,
nickname,
passwordHash,
emailVerified: isAdmin || skipEmailVerification,
isAdmin,
})
} catch (err) {
if (err?.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: 'email_or_nickname_taken' })
}
throw err
}
if (!isAdmin) {
if (!isAdmin && !skipEmailVerification) {
try {
await issueEmailVerificationMail(req, user)
return res.json({
@@ -254,7 +278,8 @@ router.post('/login', async (req, res) => {
}).safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const { email, password } = parsed.data
const email = normalizeEmail(parsed.data.email)
const { password } = parsed.data
const user = await findUserByEmail(email)
if (!user) return res.status(401).json({ error: 'invalid_credentials' })
@@ -314,7 +339,7 @@ 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)
const user = await findUserByEmail(normalizeEmail(parsed.data.email))
if (!user || user.emailVerified) return res.json({ ok: true })
try {
@@ -330,7 +355,7 @@ 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)
const user = await findUserByEmail(normalizeEmail(parsed.data.email))
if (!user) return res.json({ ok: true })
try {

View File

@@ -37,6 +37,7 @@ services:
DB_PASSWORD: ${MARIADB_PASSWORD}
DB_NAME: ${MARIADB_DATABASE}
SESSION_SECRET: ${SESSION_SECRET}
SERVER_STATS_TOKEN: ${SERVER_STATS_TOKEN}
SESSION_COOKIE_SECURE: "true"
SESSION_COOKIE_SAME_SITE: "lax"
CORS_ORIGINS: https://tmaker.sori.studio
@@ -63,7 +64,7 @@ services:
depends_on:
- backend
ports:
- "18080:80"
- "44080:80"
phpmyadmin:
image: phpmyadmin:5.2-apache
@@ -79,7 +80,7 @@ services:
PMA_USER: ${MARIADB_USER}
PMA_PASSWORD: ${MARIADB_PASSWORD}
ports:
- "18081:80"
- "44081:80"
volumes:
tmaker_mariadb_data:

View File

@@ -14,6 +14,8 @@
- 정적 파일 URL 조합은 `toApiUrl()`로 처리한다.
- 화면 상태는 `ref`, `computed`, `onMounted` 중심의 단순한 Composition API 패턴을 유지한다.
- 설정/계정처럼 자주 바꾸지 않는 정보는 상시 입력 폼보다 `현재 상태 요약 + 필요 시 모달 편집` 흐름을 우선한다.
- 모달이 배경 화면 위에 떠 있고 모달 내부 선택이 주 작업인 경우, 열림 상태 동안 `body` 스크롤 잠금을 함께 적용해 뒤쪽 화면 위치가 바뀌지 않게 한다.
- 정렬 가능한 카드 목록을 버튼으로도 재정렬할 때는 즉시 재렌더링으로 깜빡이게 두지 않고, `TransitionGroup` move 전환 등으로 위치 이동을 부드럽게 표현한다.
## 백엔드
- 라우트 검증은 `zod`로 처리한다.

View File

@@ -1,5 +1,21 @@
# 의사결정 이력
## 2026-04-27 v1.1.32
- 이름 오버레이 마퀴는 “편집/열람 화면 가독성”에는 유효하지만, 내보내기 PNG는 정지된 결과물이므로 같은 애니메이션을 유지하면 프레임 시점에 따라 정렬이 깨진 상태가 캡처될 수 있다고 판단했다.
- 따라서 export 캡처 시점에는 UI 표시 규칙을 별도로 분리해, 마퀴를 끄고 왼쪽 정렬 + 말줄임표를 강제하는 쪽이 결과물 일관성 측면에서 더 안전하다고 정리했다.
## 2026-04-27 v1.1.31
- 캐릭터 이름 표시를 켠 상태에서 긴 라벨이 잘리는 문제는 글자 수 제한으로 자르기보다, 카드 안에서 overflow 시에만 자동 슬라이드시키는 편이 정보 전달과 가독성 모두에 유리하다고 판단했다.
- 짧은 라벨까지 항상 움직이면 오히려 시선 피로가 생기므로, 실제 렌더링 너비를 기준으로 overflow 여부를 계산해 마퀴 애니메이션을 조건부로 켜는 방식으로 정리했다.
- 로컬 개발에서는 SMTP가 없거나 불안정한 경우 가입/로그인 플로우 자체를 막아버리기 쉬우므로, 운영 환경과 분리된 개발 전용 스위치(`SKIP_EMAIL_VERIFICATION`)로 메일 인증 단계를 건너뛸 수 있게 두는 편이 낫다고 판단했다.
- 이메일/닉네임 중복은 DB unique 제약에만 기대기보다 입력 정규화(`trim + lowercase`)와 앱 레벨 중복 체크를 함께 두는 편이 재현 케이스(대소문자/공백 변형)에 더 안전하다고 정리했다.
- 로컬 개발과 운영이 같은 `.env.production` 파일을 공유하면 인증 우회 같은 개발용 옵션이 운영에 섞일 위험이 있으므로, 실행 모드(`NODE_ENV`) 기준으로 로컬 `.env.local`과 운영 `.env.production`을 분리해 읽는 편이 더 안전하다고 판단했다.
## 2026-04-08 v1.1.30
- 관리자 목록관리의 상단 고정 템플릿 후보가 많아지면 모든 후보를 화면에 상시 렌더링하는 방식은 탐색성이 떨어지고 관리 영역을 불필요하게 길게 만든다고 판단했다. 따라서 고정 목록은 현재 선택된 항목만 보여주고, 추가는 검색 모달로 분리하는 쪽으로 정리했다.
- 모달이 열린 상태에서 배경 스크롤이 움직이면 선택 작업 중 문맥 위치가 바뀌어 관리자가 다시 위치를 찾아야 하므로, 이 유형의 모달은 배경 스크롤을 잠그는 것이 맞다고 정리했다.
- 드래그 정렬과 버튼 정렬은 같은 “순서 변경” 동작이므로, 버튼 이동도 즉시 깜빡이는 교체보다 카드가 부드럽게 이동하는 전환을 적용해 같은 감각으로 맞추기로 했다.
## 2026-04-07 v1.1.29
- 프리뷰 여부는 페이지 맥락과 우측 뷰어 카드만으로 충분히 전달되므로, 헤더 아이브로우에 `Preview`를 한 줄 더 두는 것은 중복 정보라고 정리했다.
- `커스텀 티어표 만들기`는 버튼 문구로 다소 길고 반복될수록 무거워 보여, 주요 CTA 에서는 `커스텀 티어표`로 짧게 통일하는 편이 더 낫다고 판단했다.

View File

@@ -20,7 +20,13 @@ docker compose up -d mariadb phpmyadmin
- phpMyAdmin: `http://localhost:8081`
## 3. 백엔드 실행
아래 명령은 로컬 MariaDB 기준 환경변수를 포함한다.
로컬 실행은 루트 `.env.local`을 기준으로 동작한다. 처음 한 번 아래처럼 복사해 둔다.
```bash
cp .env.local.example .env.local
```
그 다음 백엔드를 실행한다.
```bash
cd backend

View File

@@ -17,7 +17,7 @@
## `/editor/:topicId/new`, `/editor/:topicId/:tierListId`
- 화면 파일: `frontend/src/views/TierEditorView.vue`
- 역할: 주제 slug 기반 에디터 진입, 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 아이템 클릭 선택 후 셀/풀 재배치, 아이템 우클릭 메뉴 기반 복제본 생성, 헤더는 공통 `pageHead` 문법을 따르며 편집 가능 상태에서는 `NEW/EDIT + 템플릿 이름 + 작업 가이드`, 보기 전용 상태에서는 `템플릿 이름 + 티어표 제목 + 설명` 구조를 사용하고 아이브로우 클릭 시 해당 주제 허브로 이동하며 별도 `Preview` 아이브로우는 두지 않음, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 읽기 전용 상태의 즐겨찾기 단독 CTA, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, 댓글 카드 표시, 데이터가 준비되기 전에는 편집 모드/프리뷰 모드 모두 전용 스켈레톤 레이아웃을 먼저 보여주고, 저장용 랜덤 제목이나 내부 ID는 화면 표시용 제목으로 직접 노출하지 않으며, 스켈레톤이 내려간 뒤 실제 보드/풀 DOM에서만 드래그 초기화를 실행하고, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰와 하단 댓글 카드를 렌더링하며, 우측 뷰어 카드(`공유 티어표 보기`)는 스폰서 카드 바로 아래에서 유지하고 즐겨찾기 CTA도 함께 노출
- 역할: 주제 slug 기반 에디터 진입, 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 아이템 클릭 선택 후 셀/풀 재배치, 아이템 우클릭 메뉴 기반 복제본 생성, 헤더는 공통 `pageHead` 문법을 따르며 편집 가능 상태에서는 `NEW/EDIT + 템플릿 이름 + 작업 가이드`, 보기 전용 상태에서는 `템플릿 이름 + 티어표 제목 + 설명` 구조를 사용하고 아이브로우 클릭 시 해당 주제 허브로 이동하며 별도 `Preview` 아이브로우는 두지 않음, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 읽기 전용 상태의 즐겨찾기 단독 CTA, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, 댓글 카드 표시, 데이터가 준비되기 전에는 편집 모드/프리뷰 모드 모두 전용 스켈레톤 레이아웃을 먼저 보여주고, 저장용 랜덤 제목이나 내부 ID는 화면 표시용 제목으로 직접 노출하지 않으며, 스켈레톤이 내려간 뒤 실제 보드/풀 DOM에서만 드래그 초기화를 실행하고, `캐릭터 이름 표시` 시 이름이 칸을 넘는 항목만 하단 오버레이에서 천천히 무한 슬라이드되며 이미지 다운로드 캡처 시에는 슬라이드를 끄고 왼쪽 정렬+말줄임표로 고정하고, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰와 하단 댓글 카드를 렌더링하며, 우측 뷰어 카드(`공유 티어표 보기`)는 스폰서 카드 바로 아래에서 유지하고 즐겨찾기 CTA도 함께 노출
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/:id`, `GET /api/tierlists/:id/comments`, `POST /api/tierlists/:id/comments`, `DELETE /api/tierlists/:id/comments/:commentId`, `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`
## `/comments`
@@ -57,7 +57,7 @@
## `/admin`
- 화면 파일: `frontend/src/views/AdminView.vue`
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `템플릿 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 템플릿 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 템플릿 이름/slug 수정, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/받은 즐겨찾기 수 표시/인기순 정렬/최소 즐겨찾기 필터/추천 지정 토글/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원의 작성 티어표·팔로워·받은 즐겨찾기·최근 콘텐츠 활동·마지막 접속일 확인과 회원 정보·권한 수정 및 공개 프로필 보기, 파일 입력 초기화, 아이템 삭제, 템플릿 삭제
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `목록 관리 / 템플릿 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 홈 상단 고정 템플릿 순서, 선택된 템플릿 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 목록 관리에서는 고정 템플릿을 드래그 또는 위/아래 버튼 애니메이션으로 정렬하고 `템플릿 추가` 검색 모달에서 후보를 선택, 템플릿 이름/slug 수정, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/받은 즐겨찾기 수 표시/인기순 정렬/최소 즐겨찾기 필터/추천 지정 토글/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원의 작성 티어표·팔로워·받은 즐겨찾기·최근 콘텐츠 활동·마지막 접속일 확인과 회원 정보·권한 수정 및 공개 프로필 보기, 파일 입력 초기화, 아이템 삭제, 템플릿 삭제
- 연동 API: `GET /api/admin/templates`, `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`

View File

@@ -104,6 +104,8 @@
- 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다. 템플릿 등록/업데이트 요청 버튼은 저장된 티어표가 있을 때만 노출하며, 제목이 비어 있는 상태에서 저장하면 랜덤 고유 제목을 먼저 부여해 저장본을 만든다.
- 보기 전용 상태에서 상단 아이브로우(템플릿 이름)는 해당 주제 허브로 이동하는 액션으로 사용하며, 편집 중 미저장 변경이 있으면 이동 전에 확인 모달을 띄운다.
- 보드 바로 옆에는 드래그용 아이템 풀을 별도 패널로 두고, 커스텀 아이템 이름 정리 목록은 우측 편집 패널 내부에서 관리한다.
- `캐릭터 이름 표시`를 켠 상태에서 카드 하단 이름 오버레이가 칸 너비를 넘으면, 넘치는 항목만 자동으로 천천히 무한 슬라이드한다. 너비 안에 들어오는 이름은 기존 고정 표시를 유지한다.
- 단, PNG 이미지 다운로드 캡처 시점(`exportBoard` 활성)에는 마퀴 애니메이션을 비활성화하고 왼쪽 정렬 + 말줄임표(`ellipsis`)만 적용해 정지 이미지 결과를 일관되게 유지한다.
- 관리자 화면
- 공통 우측 패널 대신 전용 로컬 운영 패널을 사용한다.
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 관리 목록은 메인 컬럼에, 우측 운영 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
@@ -242,6 +244,10 @@
- `GET /api/auth/me`
- 로그인 세션이 살아 있는 사용자의 `last_login_at`을 주기적으로 갱신해, 회원 관리에서 `마지막 접속일`을 따로 볼 수 있게 한다.
- `GET /api/auth/meta`
- `GET /api/server-stats`
- 외부 대시보드/모니터링 앱에서 주기적으로 서비스 현황을 읽는 API다.
- `Authorization: Bearer <SERVER_STATS_TOKEN>` 헤더 또는 `?token=` 쿼리로 인증한다.
- 회원 수, 티어표 수, 템플릿 수, 템플릿 요청 상태, 커스텀 아이템 수, 즐겨찾기/댓글/팔로우 수를 반환한다.
- `POST /api/auth/profile`
- `POST /api/auth/password`
- 로그인한 사용자가 현재 비밀번호를 확인한 뒤 새 비밀번호로 직접 변경한다.
@@ -331,7 +337,9 @@
- 기본 아이템 이름 저장 버튼은 값이 실제로 바뀐 경우에만 활성화된다.
- 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다.
- 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다.
- 게임 관리 탭에서는 홈 화면 상단에 먼저 노출할 게임을 최대 50개까지 지정하고, 드래그 또는 위/아래 버튼으로 순서를 저장할 수 있다.
- 목록 관리 탭에서는 홈 화면 상단에 먼저 노출할 템플릿을 최대 50개까지 지정한다. 새 고정 템플릿은 `템플릿 추가` 모달에서 이름/slug/ID로 검색해 선택하며, 이미 고정된 템플릿은 후보에서 제외한다.
- 상단 고정 템플릿 순서는 드래그 또는 위/아래 버튼으로 바꿀 수 있고, 버튼 이동도 카드 위치가 부드럽게 전환되는 애니메이션을 사용한다.
- 상단 고정 템플릿 추가 모달이 열려 있는 동안에는 배경 관리자 화면 스크롤을 잠가 모달 선택 흐름이 뒤쪽 화면 위치를 바꾸지 않도록 한다.
- 사용자 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
- 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다.
- 사용자 아이템은 사용 횟수(`usageCount`)를 표시하며, `미사용 아이템` 필터에서 저장 티어표/템플릿 참조가 더 이상 없는 항목만 개별/일괄 삭제할 수 있다. 사용자가 계정 탈퇴 등으로 삭제된 경우는 `custom_items.owner_id` 외래키로 레코드가 같이 사라지므로 보통 `미사용 아이템`으로 남지 않는다.
@@ -417,6 +425,7 @@
- 프런트엔드
- `VITE_API_ORIGIN`: API 및 업로드 파일 절대 기준 주소
- 백엔드
- 환경파일 로딩 기준: `NODE_ENV=production`이면 `.env.production`, 그 외 로컬 개발 기본은 `.env.local`을 우선 읽는다. 로컬 파일이 없으면 `.env.production`을 fallback으로 읽는다.
- `DB_HOST`: MariaDB 호스트
- `DB_PORT`: MariaDB 포트
- `DB_USER`: MariaDB 계정
@@ -424,6 +433,7 @@
- `DB_NAME`: 데이터베이스명
- `PORT`: 서버 포트
- `SESSION_SECRET`: 세션 서명 키
- `SERVER_STATS_TOKEN`: 외부 대시보드용 `GET /api/server-stats` 호출 토큰. 미설정 시 해당 API는 503을 반환한다.
- `CORS_ORIGINS`: 허용할 프런트 도메인 목록(쉼표 구분)
- `TRUST_PROXY`: 프록시 홉 수
- `SESSION_COOKIE_SECURE`: `true`면 HTTPS 전용 쿠키
@@ -435,10 +445,13 @@
- `SMTP_USER`: 발신용 Gmail 계정
- `SMTP_PASS`: Gmail 앱 비밀번호
- `SMTP_FROM`: 실제 메일 From 주소, 비워두면 `SMTP_USER`를 기본값으로 사용한다
- `SKIP_EMAIL_VERIFICATION`: `true`면 개발 환경(`NODE_ENV !== production`)에서 일반 회원도 메일 인증 없이 즉시 로그인한다. 운영에서는 `false` 유지
## 회원 인증 메모
- 첫 번째 가입 계정은 운영 초기 부트스트랩을 위해 이메일 인증 없이 바로 최고 관리자 계정으로 활성화한다.
- 두 번째 이후 일반 회원가입은 가입 직후 로그인 세션을 만들지 않고, 인증 메일 링크를 눌러 `email_verified=1`이 된 뒤에만 로그인할 수 있게 한다.
- 단, 개발 환경에서 `SKIP_EMAIL_VERIFICATION=true`를 주면 두 번째 이후 일반 회원도 메일 인증 단계를 건너뛰고 즉시 로그인한다.
- 이메일 식별은 가입/로그인/재전송/재설정 요청 모두 `trim + lowercase` 정규화 기준으로 처리한다.
- 인증 메일/비밀번호 재설정 메일 토큰은 원문을 DB에 저장하지 않고 SHA-256 해시만 저장하며, 새 토큰을 발급할 때는 같은 사용자의 이전 미사용 토큰을 먼저 만료 처리한다.
- 이메일 인증 토큰은 24시간, 비밀번호 재설정 토큰은 1시간 유효 기간을 사용한다.
- 비밀번호 재설정 링크로 새 비밀번호를 저장한 사용자는 같은 메일 주소를 확인한 것으로 보고, 기존에 미인증 상태였더라도 저장과 함께 이메일 인증을 완료 처리한다.
@@ -447,7 +460,7 @@
## 운영 배포 메모
- 프로덕션 컴포즈 파일은 [docker-compose.prod.yml](/Users/bicute/Desktop/zenn.dev/tier-cursor/docker-compose.prod.yml)이다.
- 외부 도메인 `tmaker.sori.studio`는 `frontend` 컨테이너의 `18080` 포트로 리버스 프록시하고, `/api`, `/uploads`, `/health`는 프런트 Nginx가 내부 `backend:5179`로 전달한다.
- 외부 도메인 `tmaker.sori.studio`는 `frontend` 컨테이너의 `44080` 포트로 리버스 프록시하고, `/api`, `/uploads`, `/health`는 프런트 Nginx가 내부 `backend:5179`로 전달한다.
- 운영 볼륨은 MariaDB 데이터, 업로드 파일, 세션 파일을 각각 분리해 유지한다.
- MariaDB healthcheck는 NAS 첫 기동 지연을 고려해 `root` 기준 ping과 긴 `start_period/retries`를 사용한다.

View File

@@ -1,6 +1,15 @@
# 할 일 및 이슈
## 단기 확인
- `v1.1.32` 이후 이미지 다운로드 시 카드 이름 오버레이가 더 이상 슬라이드되지 않고, 긴 이름이 왼쪽 정렬 + 말줄임표(`...`)로 안정적으로 저장되는지 확인한다.
- `v1.1.31` 이후 로컬 실행(`NODE_ENV=development`)에서 `.env.local` 값이 실제로 우선 적용되고, 운영 실행(`NODE_ENV=production`)에서는 `.env.production`만 적용되는지 확인한다.
- `v1.1.31` 이후 회원가입에서 같은 이메일을 대소문자만 바꾼 경우(`A@x.com` vs `a@x.com`)와 앞뒤 공백 케이스가 모두 중복으로 차단되는지 확인한다.
- `v1.1.31` 이후 같은 닉네임(대소문자/공백 변형 포함)으로 재가입 시 `nickname_taken` 또는 `email_or_nickname_taken`으로 안정적으로 막히는지 확인한다.
- `v1.1.31` 이후 로컬에서 `SKIP_EMAIL_VERIFICATION=true`를 적용했을 때 두 번째 가입 계정도 인증 메일 단계 없이 즉시 로그인되는지, 운영 환경(`false`)에서는 기존처럼 인증 메일 단계가 유지되는지 확인한다.
- `v1.1.31` 이후 티어표 편집 화면과 `preview=1` 화면에서 `캐릭터 이름 표시`를 켰을 때, 긴 이름만 천천히 무한 슬라이드되고 짧은 이름은 고정 표시로 유지되는지 확인한다.
- `v1.1.30` 이후 관리자 목록관리에서 `템플릿 추가` 모달을 열고 검색·선택·닫기 흐름이 자연스러운지, 이미 고정된 템플릿이 추가 후보에서 제외되는지 확인한다.
- `v1.1.30` 이후 상단 고정 템플릿 추가 모달이 열린 동안 마우스 휠/트랙패드/모바일 터치로 뒤쪽 관리자 화면이 스크롤되지 않는지 확인한다.
- `v1.1.30` 이후 상단 고정 목록의 `위로 / 아래로` 버튼을 눌렀을 때 카드가 깜빡이지 않고 부드럽게 이동하며, 드래그 정렬과 저장 순서가 계속 일치하는지 확인한다.
- `v1.1.29` 이후 보기 전용 티어표 헤더에서 아이브로우가 템플릿 이름 한 줄만 남고, 더 이상 `Preview`와 중복되지 않는지 확인한다.
- `v1.1.29` 이후 좌측 빠른 시작 버튼과 템플릿 화면 액션의 `커스텀 티어표` 문구가 전체 화면에서 같은 톤으로 보이는지 확인한다.
- `v1.1.29` 이후 편집 화면에서 `풀 → 보드`, `보드 → 다른 칸`, `보드 → 풀` 드래그가 모두 다시 동작하는지 최우선으로 확인한다.

View File

@@ -54,6 +54,7 @@ cp .env.production.example .env.production
- `MARIADB_ROOT_PASSWORD`
- `MARIADB_PASSWORD`
- `SESSION_SECRET`
- `SERVER_STATS_TOKEN`
예시:
@@ -63,6 +64,7 @@ MARIADB_DATABASE=tier_cursor
MARIADB_USER=tier_cursor
MARIADB_PASSWORD=very-strong-app-password
SESSION_SECRET=very-strong-random-session-secret
SERVER_STATS_TOKEN=very-strong-dashboard-token
```
## 3. 컨테이너 실행
@@ -87,7 +89,7 @@ docker compose --env-file .env.production -f docker-compose.prod.yml --profile a
```bash
git pull origin main
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build
sudo docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build
```
- Dockerfile, Nginx 설정, 프런트 소스, 백엔드 소스가 바뀐 경우에는 `--build`를 유지한다.
@@ -152,14 +154,14 @@ docker compose --env-file .env.production -f docker-compose.prod.yml up -d --bui
- 외부 도메인: `tmaker.sori.studio`
- 내부 대상 프로토콜: `http`
- 내부 대상 호스트: NAS IP 또는 `localhost`
- 내부 대상 포트: `18080`
- 내부 대상 포트: `44080`
즉 NAS 리버스 프록시는 `frontend` 컨테이너의 `18080`만 바라보면 된다.
즉 NAS 리버스 프록시는 `frontend` 컨테이너의 `44080`만 바라보면 된다.
## 5. HTTPS / 쿠키
- 현재 프로덕션 컴포즈는 `SESSION_COOKIE_SECURE=true`를 사용한다.
- 따라서 `tmaker.sori.studio`에는 HTTPS 인증서가 연결되어 있어야 한다.
- NAS 리버스 프록시가 HTTPS 종료를 하고 내부는 `http://frontend:80` 또는 `localhost:18080`으로 전달하면 된다.
- NAS 리버스 프록시가 HTTPS 종료를 하고 내부는 `http://frontend:80` 또는 `localhost:44080`으로 전달하면 된다.
- 최신 프런트 Nginx 설정은 백엔드로 `X-Forwarded-Proto: https`를 넘기므로, 로그인 직후 세션이 바로 풀리는 경우에는 프런트 이미지를 다시 빌드해 최신 설정이 반영됐는지 먼저 확인한다.
## 6. 데이터 위치
@@ -170,6 +172,8 @@ docker compose --env-file .env.production -f docker-compose.prod.yml up -d --bui
## 7. 점검 포인트
- 메인 접속: `https://tmaker.sori.studio`
- 헬스체크: `https://tmaker.sori.studio/health`
- 외부 대시보드 통계:
`curl -H "Authorization: Bearer $SERVER_STATS_TOKEN" https://tmaker.sori.studio/api/server-stats`
- 로그인 후 새로고침 또는 `내 티어표` 진입 시 세션이 유지되는지 확인
- 관리자 로그인 후:
- 게임 생성

View File

@@ -1,5 +1,29 @@
# 업데이트 로그
## 2026-04-27 v1.1.32
- 티어표 편집 화면(`TierEditorView`)의 캐릭터 이름 오버레이 마퀴 처리에 export 예외를 추가했다. 이제 화면에서는 기존처럼 넘치는 이름만 슬라이드되지만, 이미지 다운로드 캡처 중(`.exportBoard--active`)에는 마퀴 클래스를 강제로 해제한다.
- export 전용 CSS 가드를 함께 넣어, 저장 시점에 마퀴 클래스가 남아 있더라도 애니메이션/복제 텍스트를 끄고 왼쪽 정렬 + `text-overflow: ellipsis` 형태로 고정되게 보강했다.
- 결과적으로 PNG 저장본에서는 긴 이름이 슬라이드되지 않고 정렬이 흔들리지 않으며, 카드 폭을 넘는 텍스트는 말줄임표로 안정적으로 표시된다.
## 2026-04-27 v1.1.31
- 티어표 편집/뷰어 화면(`TierEditorView`)에서 `캐릭터 이름 표시`를 켰을 때 카드 하단 이름 오버레이가 박스를 넘으면 자동으로 천천히 무한 슬라이드되도록 보강했다.
- 이름 길이가 짧아 박스를 넘지 않는 경우에는 기존처럼 고정 표시(ellipsis)만 유지하고, 넘치는 경우에만 마퀴 애니메이션이 켜지도록 overflow 기준을 실제 DOM 너비로 계산하도록 정리했다.
- 같은 이름 오버레이를 쓰는 편집 보드와 `preview=1` 완성본 양쪽에 동일 동작을 적용했다.
- 백엔드 회원가입 흐름에 `SKIP_EMAIL_VERIFICATION` 환경변수를 추가했다. 개발 환경에서만 `true`일 때 일반 회원도 메일 인증을 건너뛰고 바로 로그인되도록 처리했다.
- `.env.production.example`에 `SKIP_EMAIL_VERIFICATION=false` 기본값을 추가해 로컬/운영 설정을 분리해 적용할 수 있게 했다.
- 이메일/닉네임 중복 검사를 보강했다. 회원가입/로그인/비밀번호 재설정 메일 요청에서 이메일을 `trim + lowercase`로 정규화해 비교하도록 맞췄고, 가입 시 DB `ER_DUP_ENTRY`도 409(`email_or_nickname_taken`)로 안전 처리한다.
- 사용자 생성 시에도 이메일/닉네임을 정규화 저장하도록 보강해, 대소문자/앞뒤 공백 차이로 중복 계정이 생기던 케이스를 줄였다.
- 환경파일 로딩을 로컬/운영으로 분리했다. 백엔드는 `NODE_ENV=production`일 때만 `.env.production`을 읽고, 로컬 개발(`npm run dev`)에서는 `.env.local`을 우선 읽도록 조정했다.
- `backend/package.json`의 `dev/start` 스크립트에서 DB/APP 값을 하드코딩하지 않도록 정리하고, `.env.local.example` 파일을 새로 추가해 로컬 기준값과 `SKIP_EMAIL_VERIFICATION=true` 예시를 분리했다.
- `.env.local.example`, `.env.production.example`의 값은 바로 복사해도 실행 가능한 형태로 기본값을 채워두고, 실제 운영 파일 `.env.production`에는 `SKIP_EMAIL_VERIFICATION=false`를 명시해 실수로 인증 우회가 켜지지 않게 했다.
- 확인: `frontend/src/views/TierEditorView.vue` lint 0건
## 2026-04-08 v1.1.30
- 관리자 목록관리의 홈 화면 상단 고정 템플릿 추가 방식을 전체 템플릿 목록 상시 노출에서 `템플릿 추가` 버튼 기반 검색 모달로 변경했다. 템플릿 이름, slug, ID로 검색한 뒤 선택한 항목만 고정 목록에 추가한다.
- 상단 고정 템플릿 추가 모달이 열려 있는 동안 배경 페이지가 휠/터치 스크롤로 움직이지 않도록 `body.modal-scroll-lock` 잠금 처리를 추가했다.
- 상단 고정 목록의 `위로 / 아래로` 버튼 순서 변경에 `TransitionGroup` move 애니메이션을 적용했다. 버튼으로 이동해도 카드가 즉시 깜빡이며 바뀌지 않고 드래그 정렬과 비슷하게 부드럽게 자리 이동한다.
- 확인: `npm run build`
## 2026-04-07 v1.1.29
- 보기 전용 티어표 헤더에서 중복이던 `Preview` 아이브로우를 제거했다. 이제 프리뷰 화면 헤더는 템플릿 이름 아이브로우 한 줄만 남고, 그 아래 티어표 제목과 설명이 이어진다.
- 앱 전반의 `커스텀 티어표 만들기` 문구를 `커스텀 티어표`로 짧게 통일했다. 좌측 빠른 시작 버튼, 템플릿 화면 우측 액션, 가이드 설명 문구까지 같은 표현을 사용한다.
@@ -1430,7 +1454,7 @@
- **티어표 썸네일 추가**: 티어표 편집 화면에서 별도 썸네일 이미지를 선택해 저장할 수 있도록 업로드 흐름을 추가하고, 게임별 공개 티어표/내 티어표 목록은 게임 카드처럼 상단 썸네일 + 하단 제목/작성자 정보 카드 구조로 변경
## 2026-03-26 v0.1.37
- **운영 포트 설정 반영**: 프로덕션 컴포즈의 `frontend/phpMyAdmin` 외부 포트를 `18080/18081` 기준으로 유지하고, NAS 배포 문서와 기술 명세의 리버스 프록시 포트 안내도 동일하게 정리
- **운영 포트 설정 반영**: 프로덕션 컴포즈의 `frontend/phpMyAdmin` 외부 포트를 `44080/44081` 기준으로 유지하고, NAS 배포 문서와 기술 명세의 리버스 프록시 포트 안내도 동일하게 정리
- **인증 라우트 정리**: NAS 로그인 문제를 확인하기 위해 넣었던 `auth` 디버그 로그를 제거하고, 실제 운영에 필요한 세션 저장 보강만 유지
- **이력 문서 정렬**: `docs/history.md`를 날짜/버전 흐름에 맞게 다시 정리해 추적성을 높임

View File

@@ -16,6 +16,7 @@ FROM nginx:1.27-alpine
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
RUN chmod -R a+rX /usr/share/nginx/html
EXPOSE 80

View File

@@ -12,12 +12,14 @@
<meta name="application-name" content="Tier Maker" />
<link rel="canonical" href="https://tmaker.sori.studio/" />
<link
rel="icon"
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"
/>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-icon-180x180.png" />
<link rel="manifest" href="/manifest.json" />
<meta name="msapplication-config" content="/browserconfig.xml" />
<meta name="msapplication-TileColor" content="#090d16" />
<meta property="og:site_name" content="Tier Maker" />
<meta property="og:locale" content="ko_KR" />

View File

@@ -6,6 +6,27 @@ server {
root /usr/share/nginx/html;
index index.html;
location = /favicon.ico {
try_files /favicon.ico =404;
access_log off;
expires 7d;
add_header Cache-Control "public";
}
location ~ ^/(?:favicon-(?:16x16|32x32|96x96)|apple-icon-(?:57x57|60x60|72x72|76x76|114x114|120x120|144x144|152x152|180x180)|android-icon-(?:36x36|48x48|72x72|96x96|144x144|192x192)|ms-icon-(?:70x70|144x144|150x150|310x310))\.png$ {
try_files $uri =404;
access_log off;
expires 7d;
add_header Cache-Control "public";
}
location ~ ^/(?:manifest\.json|browserconfig\.xml)$ {
try_files $uri =404;
access_log off;
expires 1h;
add_header Cache-Control "public";
}
location / {
try_files $uri $uri/ /index.html;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig><msapplication><tile><square70x70logo src="/ms-icon-70x70.png"/><square150x150logo src="/ms-icon-150x150.png"/><square310x310logo src="/ms-icon-310x310.png"/><TileColor>#090d16</TileColor></tile></msapplication></browserconfig>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -0,0 +1,46 @@
{
"name": "Tier Maker",
"short_name": "Tier Maker",
"start_url": "/",
"display": "standalone",
"background_color": "#090d16",
"theme_color": "#090d16",
"icons": [
{
"src": "\/android-icon-36x36.png",
"sizes": "36x36",
"type": "image\/png",
"density": "0.75"
},
{
"src": "\/android-icon-48x48.png",
"sizes": "48x48",
"type": "image\/png",
"density": "1.0"
},
{
"src": "\/android-icon-72x72.png",
"sizes": "72x72",
"type": "image\/png",
"density": "1.5"
},
{
"src": "\/android-icon-96x96.png",
"sizes": "96x96",
"type": "image\/png",
"density": "2.0"
},
{
"src": "\/android-icon-144x144.png",
"sizes": "144x144",
"type": "image\/png",
"density": "3.0"
},
{
"src": "\/android-icon-192x192.png",
"sizes": "192x192",
"type": "image\/png",
"density": "4.0"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -1,4 +1,6 @@
<script setup>
import { computed, onUnmounted, ref, watch } from 'vue'
const props = defineProps({
featuredTemplates: { type: Array, required: true },
availableTemplatesForFeatured: { type: Array, required: true },
@@ -9,6 +11,44 @@ const props = defineProps({
removeFeaturedTemplate: { type: Function, required: true },
addFeaturedTemplate: { type: Function, required: true },
})
const featuredTemplateSearchQuery = ref('')
const featuredTemplatePickerOpen = ref(false)
const filteredAvailableTemplatesForFeatured = computed(() => {
const query = featuredTemplateSearchQuery.value.trim().toLowerCase()
if (!query) return props.availableTemplatesForFeatured
return props.availableTemplatesForFeatured.filter((template) => {
const haystack = `${template.name || ''} ${template.slug || ''} ${template.id || ''}`.toLowerCase()
return haystack.includes(query)
})
})
function openFeaturedTemplatePicker() {
featuredTemplateSearchQuery.value = ''
featuredTemplatePickerOpen.value = true
}
function closeFeaturedTemplatePicker() {
featuredTemplatePickerOpen.value = false
}
function chooseFeaturedTemplate(templateId) {
props.addFeaturedTemplate(templateId)
closeFeaturedTemplatePicker()
}
function setFeaturedTransitionListRef(el) {
props.featuredListRef(el?.$el || el)
}
watch(featuredTemplatePickerOpen, (isOpen) => {
document.body.classList.toggle('modal-scroll-lock', isOpen)
})
onUnmounted(() => {
document.body.classList.remove('modal-scroll-lock')
})
</script>
<template>
@@ -18,14 +58,23 @@ const props = defineProps({
<div class="panel__title">홈 화면 상단 고정 순서</div>
<div class="hint hint--tight">여기에 넣은 템플릿은 지정한 순서대로 먼저 노출되고, 나머지 템플릿은 최근 생성순으로 뒤에 이어집니다. 최대 50개까지 설정할 수 있어요.</div>
</div>
<button class="btn btn--primary" @click="props.saveFeaturedOrder">순서 저장</button>
<div class="featuredOrderPanel__headActions">
<button
class="btn btn--ghost"
:disabled="props.featuredTemplateIds.length >= 50 || !props.availableTemplatesForFeatured.length"
@click="openFeaturedTemplatePicker"
>
템플릿 추가
</button>
<button class="btn btn--primary" @click="props.saveFeaturedOrder">순서 저장</button>
</div>
</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">
<TransitionGroup v-else :ref="setFeaturedTransitionListRef" tag="div" name="featured-list" 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>
@@ -41,22 +90,35 @@ const props = defineProps({
<button class="btn btn--danger btn--small" @click="props.removeFeaturedTemplate(template.id)">제외</button>
</div>
</article>
</div>
</TransitionGroup>
</div>
</div>
<div class="featuredOrderPanel__picker">
<div class="section__title">템플릿 추가</div>
<div class="featuredPickerList">
<div v-if="featuredTemplatePickerOpen" class="modalOverlay" @click.self="closeFeaturedTemplatePicker">
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__titleRow">
<div>
<div class="modalCard__title">상단 고정 템플릿 추가</div>
<div class="modalCard__desc">템플릿 이름, slug, ID로 검색한 뒤 고정 목록에 추가할 항목을 선택하세요.</div>
</div>
<button class="btn btn--ghost btn--small" @click="closeFeaturedTemplatePicker">닫기</button>
</div>
<div class="modalCard__form modalCard__form--search">
<input v-model="featuredTemplateSearchQuery" class="input" placeholder="템플릿 이름 또는 slug 검색" autofocus />
<span class="hint hint--tight">{{ filteredAvailableTemplatesForFeatured.length }}개 결과</span>
</div>
<div class="templatePickerModalList">
<button
v-for="template in props.availableTemplatesForFeatured"
v-for="template in filteredAvailableTemplatesForFeatured"
:key="template.id"
class="featuredPickerItem"
:disabled="props.featuredTemplateIds.length >= 50"
@click="props.addFeaturedTemplate(template.id)"
class="adminTemplatePicker__item"
type="button"
@click="chooseFeaturedTemplate(template.id)"
>
<span>{{ template.name }}</span>
<span class="featuredPickerItem__id">{{ template.slug || template.id }}</span>
<span class="adminTemplatePicker__name">{{ template.name }}</span>
<span class="adminTemplatePicker__meta">{{ template.slug || template.id }}</span>
</button>
<div v-if="!filteredAvailableTemplatesForFeatured.length" class="hint hint--tight">추가할 수 있는 템플릿이 없어요.</div>
</div>
</div>
</div>

View File

@@ -64,7 +64,6 @@ export function useAdminFeaturedTemplates({
const [moved] = nextIds.splice(currentIndex, 1)
nextIds.splice(nextIndex, 0, moved)
featuredTemplateIds.value = nextIds
syncFeaturedSortable()
}
async function saveFeaturedOrder() {

View File

@@ -3470,12 +3470,22 @@ function openUserProfile(user) {
color: var(--theme-text-muted);
line-height: 1.6;
}
body.modal-scroll-lock {
overflow: hidden;
overscroll-behavior: none;
}
.adminUiScope .featuredOrderPanel {
margin-top: 14px;
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(260px, 0.95fr);
grid-template-columns: minmax(0, 1fr);
gap: 16px;
}
.adminUiScope .featuredOrderPanel__headActions {
display: flex;
gap: 10px;
justify-content: flex-end;
flex-wrap: wrap;
}
.adminUiScope .featuredOrderPanel__list,
.adminUiScope .featuredOrderPanel__picker {
border: 1px solid rgba(255, 255, 255, 0.1);
@@ -3485,6 +3495,7 @@ function openUserProfile(user) {
}
.adminUiScope .featuredList,
.adminUiScope .featuredPickerList {
position: relative;
margin-top: 10px;
display: grid;
gap: 10px;
@@ -3501,6 +3512,20 @@ function openUserProfile(user) {
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
}
.adminUiScope .featured-list-move,
.adminUiScope .featured-list-enter-active,
.adminUiScope .featured-list-leave-active {
transition: transform 180ms ease, opacity 180ms ease;
}
.adminUiScope .featured-list-enter-from,
.adminUiScope .featured-list-leave-to {
opacity: 0;
transform: translateY(8px);
}
.adminUiScope .featured-list-leave-active {
position: absolute;
width: calc(100% - 32px);
}
.adminUiScope .featuredCard__meta {
display: flex;
gap: 12px;

View File

@@ -204,6 +204,48 @@ function normalizeSearchText(text) {
.toLowerCase()
}
function updateNameMarquee(el) {
if (!el) return
const textEl = el.querySelector('.itemNameOverlay__text')
if (!textEl) return
const isExportCapture = !!el.closest('.exportBoard--active')
const content = (textEl.textContent || '').trim()
textEl.setAttribute('data-text', content)
if (!content || isExportCapture) {
el.classList.remove('itemNameOverlay--marquee')
el.style.removeProperty('--marquee-distance')
el.style.removeProperty('--marquee-duration')
return
}
const overflowPx = Math.ceil(textEl.scrollWidth - textEl.clientWidth)
const needsMarquee = overflowPx > 0
if (!needsMarquee) {
el.classList.remove('itemNameOverlay--marquee')
el.style.removeProperty('--marquee-distance')
el.style.removeProperty('--marquee-duration')
return
}
const distancePx = overflowPx + 20
const duration = Math.min(24, Math.max(8, distancePx / 10))
el.classList.add('itemNameOverlay--marquee')
el.style.setProperty('--marquee-distance', `${distancePx}px`)
el.style.setProperty('--marquee-duration', `${duration}s`)
}
const vNameMarquee = {
mounted(el) {
updateNameMarquee(el)
},
updated(el) {
updateNameMarquee(el)
},
}
function createAutoTierListTitle() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
const pick = (size) => Array.from({ length: size }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
@@ -1520,7 +1562,9 @@ onUnmounted(() => {
<div v-if="columns.length > 1" class="previewOnly__columnBadge">{{ column.name || '열 ' + (columnIndex + 1) }}</div>
<div v-for="id in getGroupCellIds(g, columnIndex)" :key="id" class="previewOnly__cell">
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
<div v-if="showCharacterNames" v-name-marquee class="itemNameOverlay">
<span class="itemNameOverlay__text">{{ itemsById[id]?.label || id }}</span>
</div>
</div>
</div>
</div>
@@ -1832,7 +1876,9 @@ onUnmounted(() => {
:alt="itemsById[id]?.label || id"
draggable="false"
/>
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
<div v-if="showCharacterNames" v-name-marquee class="itemNameOverlay">
<span class="itemNameOverlay__text">{{ itemsById[id]?.label || id }}</span>
</div>
<button
v-if="canEdit && !isExporting"
class="cellRemoveBtn"
@@ -2981,14 +3027,48 @@ onUnmounted(() => {
font-size: 11px;
line-height: 1.2;
font-weight: 800;
text-align: center;
display: flex;
align-items: flex-end;
justify-content: flex-start;
pointer-events: none;
overflow: hidden;
}
.itemNameOverlay__text {
display: inline-block;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: flex-end;
justify-content: center;
pointer-events: none;
transform: translateX(0);
}
.itemNameOverlay--marquee .itemNameOverlay__text {
overflow: visible;
text-overflow: clip;
padding-right: 20px;
animation: itemNameMarquee var(--marquee-duration, 12s) linear infinite;
}
.itemNameOverlay--marquee .itemNameOverlay__text::after {
content: attr(data-text);
display: inline-block;
padding-left: 20px;
}
.exportBoard--active .itemNameOverlay--marquee .itemNameOverlay__text {
overflow: hidden;
text-overflow: ellipsis;
padding-right: 0;
animation: none;
transform: translateX(0);
}
.exportBoard--active .itemNameOverlay--marquee .itemNameOverlay__text::after {
content: none;
}
@keyframes itemNameMarquee {
0% {
transform: translateX(0);
}
100% {
transform: translateX(calc(var(--marquee-distance, 0px) * -1));
}
}
.cellRemoveBtn {
position: absolute;