Compare commits

...

5 Commits

26 changed files with 437 additions and 221 deletions

View File

@@ -24,7 +24,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 })
})

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ const DB_PASSWORD = process.env.DB_PASSWORD || ''
const DB_NAME = process.env.DB_NAME || 'tier_cursor'
const DB_CONNECTION_LIMIT = process.env.DB_CONNECTION_LIMIT ? Number(process.env.DB_CONNECTION_LIMIT) : 10
const FREEFORM_TOPIC_ID = 'freeform'
const FREEFORM_GAME_ID = FREEFORM_TOPIC_ID
let poolPromise = null
let initPromise = null
@@ -68,7 +67,7 @@ function mapUserRow(row) {
}
}
function mapGameRow(row) {
function mapTopicRow(row) {
if (!row) return null
return {
id: row.id,
@@ -82,7 +81,7 @@ function mapGameRow(row) {
}
}
function mapGameItemRow(row) {
function mapTopicItemRow(row) {
if (!row) return null
return {
id: row.id,
@@ -270,10 +269,6 @@ async function closePool() {
async function ensureSchema() {
if (initPromise) return initPromise
initPromise = (async () => {
const legacyGamesExists = await tableExists('games')
const legacyGameItemsExists = await tableExists('game_items')
const legacyFavoriteGamesExists = await tableExists('favorite_games')
await query(`
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(64) PRIMARY KEY,
@@ -297,8 +292,8 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const gameIsPublicColumns = await query("SHOW COLUMNS FROM topics LIKE 'is_public'")
if (!gameIsPublicColumns.length) {
const topicIsPublicColumns = await query("SHOW COLUMNS FROM topics LIKE 'is_public'")
if (!topicIsPublicColumns.length) {
await query('ALTER TABLE topics ADD COLUMN is_public TINYINT(1) NOT NULL DEFAULT 1 AFTER thumbnail_src')
await query('UPDATE topics SET is_public = 1 WHERE is_public IS NULL')
}
@@ -308,14 +303,6 @@ async function ensureSchema() {
await query('ALTER TABLE topics ADD COLUMN display_rank INT NULL DEFAULT NULL AFTER thumbnail_src')
}
if (legacyGamesExists) {
await query(`
INSERT IGNORE INTO topics (id, name, thumbnail_src, is_public, display_rank, created_at)
SELECT id, name, thumbnail_src, COALESCE(is_public, 1), display_rank, created_at
FROM games
`)
}
await query(`
CREATE TABLE IF NOT EXISTS topic_items (
id VARCHAR(64) PRIMARY KEY,
@@ -329,22 +316,11 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const gameItemDisplayOrderColumns = await query("SHOW COLUMNS FROM topic_items LIKE 'display_order'")
if (!gameItemDisplayOrderColumns.length) {
const topicItemDisplayOrderColumns = await query("SHOW COLUMNS FROM topic_items LIKE 'display_order'")
if (!topicItemDisplayOrderColumns.length) {
await query('ALTER TABLE topic_items ADD COLUMN display_order INT NULL DEFAULT NULL AFTER label')
}
if (legacyGameItemsExists) {
const legacyItemDisplayOrderColumns = await query("SHOW COLUMNS FROM game_items LIKE 'display_order'")
await query(
`
INSERT IGNORE INTO topic_items (id, topic_id, src, label, display_order, created_at)
SELECT id, game_id, src, label, ${legacyItemDisplayOrderColumns.length ? 'display_order' : 'NULL'}, created_at
FROM game_items
`
)
}
await query(`
CREATE TABLE IF NOT EXISTS custom_items (
id VARCHAR(64) PRIMARY KEY,
@@ -407,14 +383,6 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
if (legacyFavoriteGamesExists) {
await query(`
INSERT IGNORE INTO favorite_topics (user_id, topic_id, created_at)
SELECT user_id, game_id, created_at
FROM favorite_games
`)
}
await query(`
CREATE TABLE IF NOT EXISTS image_assets (
id VARCHAR(64) PRIMARY KEY,
@@ -487,16 +455,10 @@ async function ensureSchema() {
const hasSourceTopicId = await columnExists('template_requests', 'source_topic_id')
if (!hasSourceTopicId) {
await query("ALTER TABLE template_requests ADD COLUMN source_topic_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER source_tierlist_id")
if (await columnExists('template_requests', 'source_game_id')) {
await query('UPDATE template_requests SET source_topic_id = source_game_id WHERE source_topic_id = ?', [FREEFORM_TOPIC_ID])
}
}
const hasTargetTopicId = await columnExists('template_requests', 'target_topic_id')
if (!hasTargetTopicId) {
await query("ALTER TABLE template_requests ADD COLUMN target_topic_id VARCHAR(120) NOT NULL DEFAULT '' AFTER source_topic_id")
if (await columnExists('template_requests', 'target_game_id')) {
await query("UPDATE template_requests SET target_topic_id = target_game_id WHERE target_topic_id = ''")
}
}
const templateRequestStatusColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'status'")
if (!templateRequestStatusColumns.length) {
@@ -522,9 +484,6 @@ async function ensureSchema() {
const tierListTopicIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'topic_id'")
if (!tierListTopicIdColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN topic_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER author_id")
if (await columnExists('tierlists', 'game_id')) {
await query('UPDATE tierlists SET topic_id = game_id WHERE topic_id = ?', [FREEFORM_TOPIC_ID])
}
}
const tierListShowNamesColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'show_character_names'")
if (!tierListShowNamesColumns.length) {
@@ -568,7 +527,7 @@ async function ensureSchema() {
(?, ?, ?, ?),
(?, ?, ?, ?)
`,
['example-game', '예시 게임', '', createdAt, 'another-game', '다른 예시 게임', '', createdAt]
['example-topic', '예시 주제', '', createdAt, 'another-topic', '다른 예시 주제', '', createdAt]
)
await query(
@@ -580,12 +539,12 @@ async function ensureSchema() {
`,
[
'img-1',
'example-game',
'example-topic',
'/uploads/seeds/example1.png',
'샘플 1',
createdAt,
'img-2',
'example-game',
'example-topic',
'/uploads/seeds/example2.png',
'샘플 2',
createdAt,
@@ -616,6 +575,33 @@ async function findUserByEmail(email) {
return { ...mapUserRow(row), passwordHash: row.password_hash }
}
async function findUserByNickname(nickname, excludeUserId = '') {
const normalized = String(nickname || '').trim()
if (!normalized) return null
const rows = excludeUserId
? await query(
`
SELECT id, email, nickname, password_hash, is_admin, avatar_src, created_at
FROM users
WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?)) AND id <> ?
LIMIT 1
`,
[normalized, excludeUserId]
)
: await query(
`
SELECT id, email, nickname, password_hash, is_admin, avatar_src, created_at
FROM users
WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?))
LIMIT 1
`,
[normalized]
)
const row = rows[0]
if (!row) return null
return { ...mapUserRow(row), passwordHash: row.password_hash }
}
async function findUserById(id) {
const rows = await query(
'SELECT id, email, nickname, is_admin, avatar_src, created_at FROM users WHERE id = ? LIMIT 1',
@@ -746,7 +732,7 @@ async function listTopics(currentUserId = '', options = {}) {
`,
[FREEFORM_TOPIC_ID]
)
const topics = rows.map(mapGameRow)
const topics = rows.map(mapTopicRow)
if (!currentUserId) return topics.map((topic) => ({ ...topic, isFavorited: false }))
const favoriteRows = await query('SELECT topic_id FROM favorite_topics WHERE user_id = ?', [currentUserId])
@@ -759,7 +745,7 @@ async function listTopics(currentUserId = '', options = {}) {
async function findTopicById(id) {
const rows = await query('SELECT id, name, thumbnail_src, is_public, display_rank, created_at FROM topics WHERE id = ? LIMIT 1', [id])
return mapGameRow(rows[0])
return mapTopicRow(rows[0])
}
async function listTopicItems(topicId) {
@@ -776,12 +762,12 @@ async function listTopicItems(topicId) {
`,
[topicId]
)
return rows.map(mapGameItemRow)
return rows.map(mapTopicItemRow)
}
async function findTopicItemById(itemId) {
const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
return mapGameItemRow(rows[0])
return mapTopicItemRow(rows[0])
}
async function getTopicDetail(topicId) {
@@ -909,7 +895,7 @@ async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
const referencedSrcs = new Set()
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
const [userRows, topicRows, topicItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
query("SELECT avatar_src FROM users WHERE avatar_src <> ''"),
query("SELECT thumbnail_src FROM topics WHERE thumbnail_src <> ''"),
query("SELECT src FROM topic_items WHERE src <> ''"),
@@ -919,8 +905,8 @@ async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
])
for (const row of userRows) if (row.avatar_src) referencedSrcs.add(row.avatar_src)
for (const row of gameRows) if (row.thumbnail_src) referencedSrcs.add(row.thumbnail_src)
for (const row of gameItemRows) if (row.src) referencedSrcs.add(row.src)
for (const row of topicRows) if (row.thumbnail_src) referencedSrcs.add(row.thumbnail_src)
for (const row of topicItemRows) if (row.src) referencedSrcs.add(row.src)
for (const row of customItemRows) if (row.src) referencedSrcs.add(row.src)
for (const row of tierListRows) {
@@ -962,7 +948,7 @@ async function listReferencedUploadUsage() {
usageMap.get(src).add(role)
}
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
const [userRows, topicRows, topicItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
query("SELECT avatar_src FROM users WHERE avatar_src <> ''"),
query("SELECT thumbnail_src FROM topics WHERE thumbnail_src <> ''"),
query("SELECT src FROM topic_items WHERE src <> ''"),
@@ -972,8 +958,8 @@ async function listReferencedUploadUsage() {
])
for (const row of userRows) addUsage(row.avatar_src, 'avatar')
for (const row of gameRows) addUsage(row.thumbnail_src, 'topic-thumbnail')
for (const row of gameItemRows) addUsage(row.src, 'topic-item')
for (const row of topicRows) addUsage(row.thumbnail_src, 'topic-thumbnail')
for (const row of topicItemRows) addUsage(row.src, 'topic-item')
for (const row of customItemRows) addUsage(row.src, 'custom-item')
for (const row of tierListRows) {
@@ -1005,14 +991,14 @@ function replaceItemSrc(items, fromSrc, toSrc) {
async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
if (!fromSrc || !toSrc || fromSrc === toSrc) return { updatedRows: 0 }
const [userResult, gameResult, gameItemResult, customItemResult] = await Promise.all([
const [userResult, topicResult, topicItemResult, customItemResult] = await Promise.all([
query('UPDATE users SET avatar_src = ? WHERE avatar_src = ?', [toSrc, fromSrc]),
query('UPDATE topics SET thumbnail_src = ? WHERE thumbnail_src = ?', [toSrc, fromSrc]),
query('UPDATE topic_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
query('UPDATE custom_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
])
let updatedRows = Number(userResult.affectedRows || 0) + Number(gameResult.affectedRows || 0) + Number(gameItemResult.affectedRows || 0) + Number(customItemResult.affectedRows || 0)
let updatedRows = Number(userResult.affectedRows || 0) + Number(topicResult.affectedRows || 0) + Number(topicItemResult.affectedRows || 0) + Number(customItemResult.affectedRows || 0)
const tierListRows = await query('SELECT id, thumbnail_src, pool_json FROM tierlists')
for (const row of tierListRows) {
@@ -1161,16 +1147,16 @@ function stripMissingItems(items, missingItemIds, missingSrcs) {
async function cleanupMissingUploadReferences() {
const stats = {
clearedAvatars: 0,
clearedGameThumbnails: 0,
clearedTopicThumbnails: 0,
clearedTierListThumbnails: 0,
clearedTemplateRequestThumbnails: 0,
deletedGameItems: 0,
deletedTopicItems: 0,
updatedTierLists: 0,
updatedTemplateRequests: 0,
deletedCustomItems: 0,
}
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
const [userRows, topicRows, topicItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
query("SELECT id, avatar_src FROM users WHERE avatar_src <> ''"),
query("SELECT id, thumbnail_src FROM topics WHERE thumbnail_src <> ''"),
query("SELECT id, src FROM topic_items WHERE src <> ''"),
@@ -1185,16 +1171,16 @@ async function cleanupMissingUploadReferences() {
stats.clearedAvatars += 1
}
for (const row of gameRows) {
for (const row of topicRows) {
if (await fileExistsForUploadSrc(row.thumbnail_src)) continue
await query('UPDATE topics SET thumbnail_src = ? WHERE id = ?', ['', row.id])
stats.clearedGameThumbnails += 1
stats.clearedTopicThumbnails += 1
}
for (const row of gameItemRows) {
for (const row of topicItemRows) {
if (await fileExistsForUploadSrc(row.src)) continue
await deleteTopicItem(row.id)
stats.deletedGameItems += 1
stats.deletedTopicItems += 1
}
const missingCustomItemIds = new Set()
@@ -1354,13 +1340,13 @@ async function createTopicItem({ id, topicId, src, label }) {
createdAt,
])
const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [id])
return mapGameItemRow(rows[0])
return mapTopicItemRow(rows[0])
}
async function updateTopicItemLabel(itemId, label) {
await query('UPDATE topic_items SET label = ? WHERE id = ?', [label, itemId])
const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
return mapGameItemRow(rows[0])
return mapTopicItemRow(rows[0])
}
async function updateTopicItemDisplayOrder(topicId, itemIds) {
@@ -1562,7 +1548,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
const hasQuery = !!searchText
const search = `%${searchText}%`
const [customRows, gameItemRows, assetRows, usageMeta] = await Promise.all([
const [customRows, topicItemRows, assetRows, usageMeta] = await Promise.all([
query(
`
SELECT
@@ -1610,7 +1596,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
])
const templateLinkedBySrc = new Map()
gameItemRows.forEach((row) => {
topicItemRows.forEach((row) => {
if (!row?.src) return
if (!templateLinkedBySrc.has(row.src)) templateLinkedBySrc.set(row.src, new Map())
templateLinkedBySrc.get(row.src).set(row.topic_id, {
@@ -1637,7 +1623,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
}
})
const templateSrcSet = new Set(gameItemRows.map((row) => row.src).filter(Boolean))
const templateSrcSet = new Set(topicItemRows.map((row) => row.src).filter(Boolean))
const customSrcSet = new Set(customRows.map((row) => row.src).filter(Boolean))
const assetLibraryItems = assetRows
.filter((row) => row?.src && !templateSrcSet.has(row.src) && !customSrcSet.has(row.src))
@@ -1660,7 +1646,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
isAssetLibraryItem: true,
}))
const templateItems = gameItemRows.map((row) => ({
const templateItems = topicItemRows.map((row) => ({
id: row.id,
ownerId: '',
src: row.src,
@@ -2036,12 +2022,12 @@ async function listAdminTierLists({ queryText = '', topicId = '', page = 1, limi
const normalizedPage = Math.max(Number(page) || 1, 1)
const hasQuery = !!(queryText || '').trim()
const resolvedTopicId = (topicId || '').trim()
const hasGameId = !!resolvedTopicId
const hasTopicId = !!resolvedTopicId
const search = `%${(queryText || '').trim()}%`
const whereParts = []
const params = []
if (hasGameId) {
if (hasTopicId) {
whereParts.push('t.topic_id = ?')
params.push(resolvedTopicId)
}
@@ -2121,12 +2107,12 @@ async function listAdminTierLists({ queryText = '', topicId = '', page = 1, limi
async function summarizeAdminTierLists({ queryText = '', topicId = '' } = {}) {
const hasQuery = !!(queryText || '').trim()
const resolvedTopicId = (topicId || '').trim()
const hasGameId = !!resolvedTopicId
const hasTopicId = !!resolvedTopicId
const search = `%${(queryText || '').trim()}%`
const whereParts = []
const params = []
if (hasGameId) {
if (hasTopicId) {
whereParts.push('t.topic_id = ?')
params.push(resolvedTopicId)
}
@@ -2512,6 +2498,7 @@ module.exports = {
closePool,
countUsers,
findUserByEmail,
findUserByNickname,
findUserById,
createUser,
updateUserProfile,

View 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,
}

View File

@@ -7,6 +7,8 @@ const { z } = require('zod')
const { nanoid } = require('nanoid')
const {
findUserById,
findUserByEmail,
findUserByNickname,
findTopicById,
findTopicItemById,
listTopicItems,
@@ -52,6 +54,7 @@ const {
} = require('../db')
const { requireAdmin } = require('../middleware/auth')
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
const { isReservedNickname } = require('../lib/user-validation')
const router = express.Router()
@@ -128,7 +131,7 @@ router.post('/templates', requireAdmin, async (req, res) => {
if (exists) return res.status(409).json({ error: 'topic_id_taken' })
const template = await createTopic({ id: parsed.data.id, name: parsed.data.name, isPublic: parsed.data.isPublic })
if (parsed.data.thumbnailSrc) {
const copiedThumb = await copyUploadIntoGameAsset(parsed.data.thumbnailSrc)
const copiedThumb = await copyUploadIntoTopicAsset(parsed.data.thumbnailSrc)
await updateTopicThumbnail(template.id, copiedThumb)
}
const savedTemplate = await findTopicById(template.id)
@@ -469,7 +472,7 @@ async function promoteLibraryItemToTemplateItem({ item, templateId }) {
})
}
async function copyUploadIntoGameAsset(src) {
async function copyUploadIntoTopicAsset(src) {
if (typeof src !== 'string') return ''
const raw = src.trim()
if (!raw) return ''
@@ -507,7 +510,7 @@ async function promoteTierListItemsToTemplate({ tierList, templateId, itemIds =
const createdItems = []
for (const item of itemsToCopy) {
const copiedSrc = await copyUploadIntoGameAsset(item.src)
const copiedSrc = await copyUploadIntoTopicAsset(item.src)
createdItems.push(
await createTopicItem({
id: nanoid(),
@@ -531,7 +534,7 @@ async function promoteSnapshotItemsToTemplate({ items, templateId }) {
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 createTopicItem({
@@ -576,13 +579,13 @@ function pickTemplateRequestItems(templateRequest, itemIds = [], itemLabels = {}
async function createTemplateFromTierList({ tierList, templateId, templateName }) {
await createTopic({ id: templateId, name: templateName, isPublic: false })
if (tierList.thumbnailSrc) {
const copiedThumb = await copyUploadIntoGameAsset(tierList.thumbnailSrc)
const copiedThumb = await copyUploadIntoTopicAsset(tierList.thumbnailSrc)
await updateTopicThumbnail(templateId, copiedThumb)
}
const createdItems = []
for (const item of uniqueTierListPoolItems(tierList)) {
const copiedSrc = await copyUploadIntoGameAsset(item.src)
const copiedSrc = await copyUploadIntoTopicAsset(item.src)
createdItems.push(
await createTopicItem({
id: nanoid(),
@@ -600,7 +603,7 @@ async function createTemplateFromRequest({ templateRequest, templateId, template
await createTopic({ id: templateId, name: templateName, isPublic: false })
if (templateRequest.thumbnailSrc) {
const copiedThumb = await copyUploadIntoGameAsset(templateRequest.thumbnailSrc)
const copiedThumb = await copyUploadIntoTopicAsset(templateRequest.thumbnailSrc)
await updateTopicThumbnail(templateId, copiedThumb)
}
@@ -962,6 +965,18 @@ router.patch('/users/:userId', requireAdmin, async (req, res) => {
return res.status(403).json({ error: 'primary_admin_only' })
}
if (isReservedNickname(parsed.data.nickname)) {
return res.status(400).json({ error: 'nickname_reserved' })
}
const duplicateEmail = await findUserByEmail(parsed.data.email)
if (duplicateEmail && duplicateEmail.id !== targetUser.id) {
return res.status(409).json({ error: 'email_taken' })
}
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,

View File

@@ -6,6 +6,7 @@ const multer = require('multer')
const {
countUsers,
findUserByEmail,
findUserByNickname,
findUserById,
createUser,
updateUserProfile,
@@ -13,11 +14,13 @@ const {
} = require('../db')
const { requireAuth } = require('../middleware/auth')
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
const { isReservedNickname } = require('../lib/user-validation')
const router = express.Router()
const signupSchema = z.object({
email: z.string().email(),
nickname: z.string().trim().min(2).max(40),
password: z.string().min(6),
})
@@ -62,13 +65,16 @@ 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 })
const user = await createUser({ id: nanoid(), email, nickname, passwordHash, isAdmin })
try {
await establishSession(req, user)
@@ -79,7 +85,10 @@ router.post('/signup', async (req, res) => {
})
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
@@ -121,6 +130,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({

View File

@@ -24,7 +24,7 @@ const FREEFORM_TOPIC_ID = 'freeform'
const FREEFORM_DEFAULT_TITLE = '직접 티어표 만들기'
function normalizePoolItem(item) {
if (!item || !['game', 'template'].includes(item.origin) || 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 {
@@ -83,7 +83,7 @@ const templateRequestSchema = z.object({
id: z.string().min(1),
src: z.string().min(1),
label: z.string().min(1).max(60),
origin: z.enum(['template', 'game', 'custom']).default('template'),
origin: z.enum(['template', 'custom']).default('template'),
})
),
})
@@ -112,7 +112,7 @@ const tierListUpsertSchema = z.object({
id: z.string().min(1),
src: z.string().min(1),
label: z.string().min(1).max(60),
origin: z.enum(['template', 'game', 'custom']).default('template'),
origin: z.enum(['template', 'custom']).default('template'),
})
),
}).superRefine((value, ctx) => {

View File

@@ -1,5 +1,22 @@
# 의사결정 이력
## 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`로 바꿔두는 편이 이후 관리자 유지보수에서 훨씬 덜 헷갈린다고 정리했다.
@@ -595,6 +612,10 @@
- 게임 이미지 경로는 저장 시 상대 경로(`/uploads/...`)를 유지하는 방향으로 정리했다.
- 현재 단계에서는 구조 변경 비용을 고려해 DB를 유지하되, 운영/확장성 요구가 커지기 전 RDB 이관 판단이 필요하다고 기록했다.
## 2026-04-02 v1.4.34
- 라이트모드는 단순 토글 존재만으로 충분하지 않고, 셸/카드/버튼/오버레이가 같은 색 문법을 공유해야 품질이 안정된다고 판단해 공통 토큰을 다시 정리했다.
- 홈 카드 즐겨찾기 버튼처럼 다크 전용 하드코딩이 남아 있으면 전체 인상이 쉽게 무너지므로, 이후 테마 보정은 공통 변수 우선 원칙으로 계속 가져가기로 했다.
## 2026-03-19
- 초기 저장소는 빠른 구현을 위해 `lowdb(JSON 파일)`를 채택했다.
- 인증은 JWT 대신 서버 세션(`express-session` + `session-file-store`)을 사용했다.

View File

@@ -1,6 +1,15 @@
# 할 일 및 이슈
## 단기 확인
- `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 후에는 이 레거시 층을 언제 제거할지 별도 마감 판단만 하면 된다.
@@ -104,6 +113,7 @@
- 관리자 게임 아이템 순서 저장은 추가됐으므로, 다음 단계에서는 새 아이템 추가 직후 `자동 맨 앞 배치``관리자 수동 고정 순서`의 우선순위를 실제 운영 흐름 기준으로 한 번 더 QA한다.
- 관리자 템플릿 요청 미리보기는 실제 완성본 iframe 방식과의 체감 차이를 마지막으로 한 번 더 QA한다.
- 라이트모드/다크모드 2차 보정까지 반영했으므로, 남은 작업은 전체 화면을 실제 사용 흐름으로 돌려 보며 대비·명도·아이콘 가독성을 미세하게 QA하는 최종 테마 점검 단계로 가져간다.
- 라이트모드 공통 토큰 재정비와 카드/아바타/즐겨찾기 버튼 보정까지 반영했으므로, 다음 QA에서는 로그인/홈/주제 허브/에디터/관리자 순으로 실제 플로우를 돌리며 남은 하드코딩 색과 과한 대비가 없는지 확인한다.
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
- 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다.
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.

View File

@@ -1,5 +1,26 @@
# 업데이트 로그
## 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` 흔적을 제거했다.
@@ -1083,6 +1104,13 @@
- **티어표 데이터 정규화**: 게임 이미지 경로가 절대 로컬 URL로 저장되지 않도록 저장/조회 시 `/uploads/...` 상대 경로로 정규화
- **프로젝트 점검 결과 문서화**: DB 구조, 화면-파일 매핑, 코딩 규칙, 기술 명세, 남은 위험 요소를 `docs/`에 신규 정리
## 2026-04-02 v1.4.34
- **라이트모드 팔레트 재정비**: 공통 라이트 테마 색상을 회색 위주에서 더 정돈된 청회색 계열로 다시 잡고, 셸/레일/메인/카드 표면 대비를 처음부터 재조정
- **공통 토큰 확장**: 강조색 강도, 강조 배경, 오버레이 스크림, 아바타 테두리, 즐겨찾기 버튼 상태색을 공통 변수로 분리해 화면별 하드코딩을 줄임
- **홈 카드 보정**: 주제 카드 즐겨찾기 버튼이 라이트모드에서 검은 플로팅 버튼처럼 뜨던 문제를 테마 변수 기반으로 수정
- **목록 카드 통일**: 주제 허브/나의 티어표/즐겨찾기/검색 결과 카드의 아바타 테두리를 공통 토큰으로 맞춰 라이트모드에서 카드 밀도가 덜 어색하게 보이도록 정리
- **전역 셸 보정**: 백엔드 점검 안내 버튼과 가이드 모달 오버레이도 라이트모드에 맞는 공통 색상 체계로 통일
## 2026-03-19 v0.1.2
- **로그인 UI 개선**: 로그인 카드 중앙 배치, 중복 타이틀 제거, 입력 overflow 수정, 엔터로 로그인/회원가입 제출
- **안내문 조건화**: “첫 회원가입 계정은 admin” 문구는 유저가 0명일 때만 표시(`/api/auth/meta`)

View File

@@ -137,7 +137,7 @@ 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 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))
@@ -275,7 +275,7 @@ 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') {
@@ -748,8 +748,8 @@ function reloadApp() {
min-width: 128px;
padding: 12px 18px;
border-radius: 999px;
border: 1px solid rgba(98, 170, 255, 0.32);
background: rgba(98, 170, 255, 0.18);
border: 1px solid var(--theme-accent-soft-strong);
background: var(--theme-accent-soft);
color: var(--theme-text-strong);
font-weight: 700;
cursor: pointer;
@@ -929,7 +929,7 @@ function reloadApp() {
border-radius: 999px;
object-fit: cover;
flex: 0 0 auto;
border: 1px solid rgba(255, 255, 255, 0.14);
border: 1px solid var(--theme-avatar-border);
background: var(--theme-surface-soft-3);
}
@@ -1453,7 +1453,7 @@ function reloadApp() {
align-items: center;
justify-content: center;
padding: 32px 20px;
background: rgba(0, 0, 0, 0.62);
background: var(--theme-overlay-scrim);
backdrop-filter: blur(10px);
}

View File

@@ -47,7 +47,7 @@ const props = defineProps({
selectedTemplateId: { type: String, default: '' },
})
function setGameItemListElement(el) {
function setTemplateItemListElement(el) {
props.templateItemListRef(el)
}
@@ -109,8 +109,8 @@ function setThumbFileElement(el) {
</div>
</div>
<div v-else-if="props.hasSelectedTemplate" class="panel">
<section class="adminCard gameSettingsCard">
<div class="gameSettingsCard__media">
<section class="adminCard templateSettingsCard">
<div class="templateSettingsCard__media">
<input :ref="setThumbFileElement" type="file" accept="image/*" class="srOnlyInput" @change="props.onThumb" />
<button
class="thumbDropZone"
@@ -125,22 +125,19 @@ function setThumbFileElement(el) {
<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 v-if="!props.displayThumbnailUrl" class="thumbDropZone__iconWrap">
<SvgIcon :src="addPhotoAlternateIcon" alt="" class="thumbDropZone__icon" />
</div>
<div class="thumbDropZone__title">{{ props.displayThumbnailUrl ? '썸네일 변경' : '클릭 & 드래그' }}</div>
</div>
</button>
</div>
<div class="gameSettingsCard__body">
<div class="templateSettingsCard__body">
<div class="panel__title">템플릿 설정</div>
<div class="gameSettingsCard__meta">{{ props.selectedTemplate.template.name }} · {{ props.selectedTemplate.template.id }}</div>
<div class="templateSettingsCard__meta">{{ props.selectedTemplate.template.name }} · {{ 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="gameSettingsCard__actions">
<div class="templateSettingsCard__actions">
<button class="btn" :disabled="!props.canApplyThumbnail" @click="props.uploadThumbnail">썸네일 적용</button>
<button class="btn btn--danger" @click="props.removeTemplate">템플릿 삭제</button>
</div>
@@ -215,7 +212,7 @@ function setThumbFileElement(el) {
<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="setGameItemListElement" class="thumbGrid">
<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 />

View File

@@ -1,7 +1,7 @@
import { nextTick } from 'vue'
import Sortable from 'sortablejs'
export function useAdminFeaturedGames({
export function useAdminFeaturedTemplates({
api,
featuredListEl,
featuredSortable,

View File

@@ -1,7 +1,7 @@
import { nextTick } from 'vue'
import Sortable from 'sortablejs'
export function useAdminGameManager({
export function useAdminTemplateManager({
api,
toApiUrl,
selectedTemplateId,

View File

@@ -55,7 +55,7 @@ async function request(path, { method = 'GET', body, headers } = {}) {
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 } }),
logout: () => request('/api/auth/logout', { method: 'POST' }),

View File

@@ -1,7 +1,7 @@
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'
@@ -16,8 +16,7 @@ export function createRouter() {
history: createWebHistory(),
routes: [
{ path: '/', name: 'home', component: HomeView },
{ path: '/games/:gameId', redirect: (to) => `/topics/${encodeURIComponent(String(to.params.gameId || ''))}` },
{ path: '/topics/:topicId', name: 'topicHub', component: GameHubView },
{ 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 },
@@ -26,7 +25,6 @@ export function createRouter() {
{ path: '/search', name: 'search', component: SearchResultsView },
{ path: '/admin', redirect: '/admin/featured' },
{ path: '/admin/featured', name: 'adminFeatured', component: AdminView },
{ path: '/admin/games', redirect: '/admin/templates' },
{ path: '/admin/templates', name: 'adminTemplates', component: AdminView },
{ path: '/admin/items', name: 'adminItems', component: AdminView },
{ path: '/admin/tierlists', name: 'adminTierlists', component: AdminView },

View File

@@ -29,8 +29,8 @@ export const useAuthStore = defineStore('auth', {
})()
return refreshPromise
},
async signup(email, password) {
const user = await api.signup({ email, password })
async signup(email, nickname, password) {
const user = await api.signup({ email, nickname, password })
this.user = user
this.hydrated = true
return user

View File

@@ -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%);
}

View File

@@ -8,13 +8,13 @@ import lockResetIcon from '../assets/icons/lock_reset.svg'
import deleteIcon from '../assets/icons/delete.svg'
import SvgIcon from '../components/SvgIcon.vue'
import AdminFeaturedSection from '../components/admin/AdminFeaturedSection.vue'
import AdminGamesSection from '../components/admin/AdminGamesSection.vue'
import AdminTemplatesSection from '../components/admin/AdminTemplatesSection.vue'
import AdminItemsSection from '../components/admin/AdminItemsSection.vue'
import AdminTierlistsSection from '../components/admin/AdminTierlistsSection.vue'
import AdminUsersSection from '../components/admin/AdminUsersSection.vue'
import { useAdminCustomItems } from '../composables/useAdminCustomItems'
import { useAdminFeaturedGames } from '../composables/useAdminFeaturedGames'
import { useAdminGameManager } from '../composables/useAdminGameManager'
import { useAdminFeaturedTemplates } from '../composables/useAdminFeaturedTemplates'
import { useAdminTemplateManager } from '../composables/useAdminTemplateManager'
import { useAdminTemplateRequests } from '../composables/useAdminTemplateRequests'
import { useAdminUsers } from '../composables/useAdminUsers'
import { useAuthStore } from '../stores/auth'
@@ -50,7 +50,7 @@ const customItemModalTargetTemplateId = ref('')
const adminTierLists = ref([])
const adminTierListQuery = ref('')
const adminTierListGameId = ref('')
const adminTierListTopicId = ref('')
const adminTierListPage = ref(1)
const adminTierListLimit = ref(50)
const adminTierListTotal = ref(0)
@@ -143,7 +143,7 @@ function setThumbFileInputRef(el) {
thumbFileInput.value = el
}
function scheduleGameItemSortableSync() {
function scheduleTemplateItemSortableSync() {
if (templateItemSortableSyncTimer) {
clearTimeout(templateItemSortableSyncTimer)
templateItemSortableSyncTimer = null
@@ -156,10 +156,10 @@ function scheduleGameItemSortableSync() {
}, 0)
}
function setGameItemListRef(el) {
function setTemplateItemListRef(el) {
templateItemListEl.value = el
if (!el) return
scheduleGameItemSortableSync()
scheduleTemplateItemSortableSync()
}
function normalizeAdminSrc(src) {
@@ -437,7 +437,7 @@ watch(
const nextMode = route.query.mode === 'all' ? 'all' : 'requests'
if (tierlistsMode.value !== nextMode) tierlistsMode.value = nextMode
const nextTierListTopicId = typeof route.query.topicId === 'string' ? route.query.topicId : ''
if (adminTierListGameId.value !== nextTierListTopicId) adminTierListGameId.value = nextTierListTopicId
if (adminTierListTopicId.value !== nextTierListTopicId) adminTierListTopicId.value = nextTierListTopicId
}
},
{ immediate: true }
@@ -465,13 +465,13 @@ watch(
if (route.name !== 'adminTierlists') return
syncAdminRouteQuery({
mode: mode === 'all' ? 'all' : undefined,
topicId: mode === 'all' && adminTierListGameId.value ? adminTierListGameId.value : undefined,
topicId: mode === 'all' && adminTierListTopicId.value ? adminTierListTopicId.value : undefined,
})
}
)
watch(
() => adminTierListGameId.value,
() => adminTierListTopicId.value,
(topicId) => {
if (route.name !== 'adminTierlists' || tierlistsMode.value !== 'all') return
syncAdminRouteQuery({ topicId: topicId || undefined })
@@ -527,7 +527,7 @@ watch(
() => [selectedTemplate.value?.template?.id || '', selectedTemplate.value?.items?.length || 0, !!templateItemListEl.value],
([templateId, itemCount, hasListEl]) => {
if (!templateId || !itemCount || !hasListEl) return
scheduleGameItemSortableSync()
scheduleTemplateItemSortableSync()
}
)
@@ -572,7 +572,6 @@ function formatImageJobSourceCategory(category) {
case 'tierlists':
return '티어표 썸네일'
case 'topics':
case 'games':
return '주제/템플릿 이미지'
case 'avatars':
return '프로필 아바타'
@@ -716,10 +715,10 @@ async function cleanupMissingImageReferences() {
success.value =
`누락 참조를 정리했어요. ` +
`아바타 ${result.clearedAvatars || 0}건, ` +
`템플릿 썸네일 ${result.clearedGameThumbnails || 0}건, ` +
`템플릿 썸네일 ${result.clearedTopicThumbnails || 0}건, ` +
`티어표 썸네일 ${result.clearedTierListThumbnails || 0}건, ` +
`요청 썸네일 ${result.clearedTemplateRequestThumbnails || 0}건, ` +
`템플릿 아이템 ${result.deletedGameItems || 0}건, ` +
`템플릿 아이템 ${result.deletedTopicItems || 0}건, ` +
`커스텀 아이템 ${result.deletedCustomItems || 0}`
} catch (e) {
error.value = '누락 이미지 참조 정리에 실패했어요.'
@@ -823,7 +822,7 @@ async function refreshAdminTierLists() {
try {
const data = await api.listAdminTierLists({
q: adminTierListQuery.value,
topicId: adminTierListGameId.value,
topicId: adminTierListTopicId.value,
page: adminTierListPage.value,
limit: adminTierListLimit.value,
})
@@ -840,7 +839,7 @@ async function refreshAdminTierLists() {
async function refreshAdminTierListStats() {
if (!auth.user?.isAdmin) return
try {
const data = await api.getAdminTierListStats({ q: adminTierListQuery.value, topicId: adminTierListGameId.value })
const data = await api.getAdminTierListStats({ q: adminTierListQuery.value, topicId: adminTierListTopicId.value })
adminTierListStats.value = {
total: data.total || 0,
publicCount: data.publicCount || 0,
@@ -920,7 +919,7 @@ const {
removeFeaturedTemplate,
moveFeaturedTemplate,
saveFeaturedOrder,
} = useAdminFeaturedGames({
} = useAdminFeaturedTemplates({
api,
featuredListEl,
featuredSortable,
@@ -944,7 +943,7 @@ const {
clearItemFiles,
uploadItem,
saveTemplateItemOrder,
} = useAdminGameManager({
} = useAdminTemplateManager({
api,
toApiUrl,
selectedTemplateId,
@@ -1307,8 +1306,8 @@ function submitAdminTierListSearch() {
refreshAdminTierLists()
}
function setAdminTierListGameId(topicId) {
adminTierListGameId.value = topicId || ''
function setAdminTierListTopicId(topicId) {
adminTierListTopicId.value = topicId || ''
adminTierListPage.value = 1
refreshAdminTierLists()
}
@@ -1328,7 +1327,7 @@ function closeTemplatePickerModal() {
async function chooseTemplateFromPicker(templateId) {
if (!templateId) return
if (templatePickerMode.value === 'tierlists-filter') {
setAdminTierListGameId(templateId)
setAdminTierListTopicId(templateId)
closeTemplatePickerModal()
return
}
@@ -1701,7 +1700,7 @@ function userAvatarFallback(user) {
:add-featured-template="addFeaturedTemplate"
/>
<AdminGamesSection
<AdminTemplatesSection
v-else-if="activeTab === 'template-admin'"
:active-template-request="activeTemplateRequest"
:template-request-source-url="templateRequestSourceUrl"
@@ -1740,7 +1739,7 @@ function userAvatarFallback(user) {
:remove-upload-draft="removeUploadDraft"
:has-template-item-order-changes="hasTemplateItemOrderChanges"
:save-template-item-order="saveTemplateItemOrder"
:template-item-list-ref="setGameItemListRef"
:template-item-list-ref="setTemplateItemListRef"
:save-template-item-label="saveTemplateItemLabel"
:remove-template-item="removeTemplateItem"
:selected-template-id="selectedTemplateId"
@@ -1988,6 +1987,9 @@ function userAvatarFallback(user) {
<div class="customItemModal__body">
<button class="customItemModal__close" type="button" @click="closeCustomItemModal">닫기</button>
<div class="customItemModal__content">
<div class="customItemModal__preview">
<img class="customItemModal__previewImage" :src="toApiUrl(modalTargetCustomItem.src)" :alt="modalTargetCustomItem.label" />
</div>
<div class="customItemModal__titleRow">
<div>
<div class="customItemModal__title">{{ modalTargetCustomItem.label }}</div>
@@ -2047,34 +2049,34 @@ function userAvatarFallback(user) {
<option value="oldest">오래된순</option>
</select>
<button
v-if="templatePickerMode === 'tierlists-filter' && adminTierListGameId"
v-if="templatePickerMode === 'tierlists-filter' && adminTierListTopicId"
class="btn btn--ghost"
type="button"
@click="setAdminTierListGameId(''); closeTemplatePickerModal()"
@click="setAdminTierListTopicId(''); closeTemplatePickerModal()"
>
모든 주제 보기
</button>
</div>
<div class="gamePickerModalList">
<div class="templatePickerModalList">
<button
v-for="template in filteredTemplatePickerTemplates"
:key="template.id"
class="adminGamePicker__item"
class="adminTemplatePicker__item"
:class="{
'adminGamePicker__item--active': templatePickerMode === 'tierlists-filter'
? adminTierListGameId === template.id
'adminTemplatePicker__item--active': templatePickerMode === 'tierlists-filter'
? adminTierListTopicId === template.id
: templatePickerMode === 'custom-item-target'
? customItemModalTargetTemplateId === template.id
: selectedTemplateId === template.id,
'adminGamePicker__item--disabled': templatePickerMode === 'custom-item-target' && linkedCustomItemTemplateIds.has(template.id),
'adminTemplatePicker__item--disabled': templatePickerMode === 'custom-item-target' && linkedCustomItemTemplateIds.has(template.id),
}"
type="button"
:disabled="templatePickerMode === 'custom-item-target' && linkedCustomItemTemplateIds.has(template.id)"
@click="chooseTemplateFromPicker(template.id)"
>
<span class="adminGamePicker__name">{{ template.name }}</span>
<span class="adminGamePicker__meta">{{ template.id }}</span>
<span v-if="templatePickerMode === 'custom-item-target' && linkedCustomItemTemplateIds.has(template.id)" class="adminGamePicker__state">이미 추가됨</span>
<span class="adminTemplatePicker__name">{{ template.name }}</span>
<span class="adminTemplatePicker__meta">{{ template.id }}</span>
<span v-if="templatePickerMode === 'custom-item-target' && linkedCustomItemTemplateIds.has(template.id)" class="adminTemplatePicker__state">이미 추가됨</span>
</button>
<div v-if="!filteredTemplatePickerTemplates.length" class="hint hint--tight">검색 결과가 없어요.</div>
</div>
@@ -2306,11 +2308,11 @@ function userAvatarFallback(user) {
<button class="btn btn--ghost" @click="submitAdminTierListSearch">검색</button>
</div>
<button class="btn btn--ghost" @click="openTemplatePickerModal('tierlists-filter')">주제 선택</button>
<div v-if="adminTierListGameId" class="adminSelectionCard">
<div v-if="adminTierListTopicId" class="adminSelectionCard">
<div class="adminSelectionCard__label">필터된 주제</div>
<div class="adminSelectionCard__title">{{ templates.find((template) => template.id === adminTierListGameId)?.name || adminTierListGameId }}</div>
<div class="adminSelectionCard__meta">{{ adminTierListGameId }}</div>
<button class="btn btn--ghost btn--small" @click="setAdminTierListGameId('')">필터 해제</button>
<div class="adminSelectionCard__title">{{ templates.find((template) => template.id === adminTierListTopicId)?.name || adminTierListTopicId }}</div>
<div class="adminSelectionCard__meta">{{ adminTierListTopicId }}</div>
<button class="btn btn--ghost btn--small" @click="setAdminTierListTopicId('')">필터 해제</button>
</div>
<select :value="adminTierListLimit" class="select" @change="changeAdminTierListLimit(Number($event.target.value))">
<option :value="50">50개씩 보기</option>
@@ -2584,14 +2586,14 @@ function userAvatarFallback(user) {
font-weight: 800;
color: var(--theme-text);
}
.adminUiScope .adminGamePicker {
.adminUiScope .adminTemplatePicker {
display: grid;
gap: 8px;
max-height: 640px;
overflow: auto;
padding-right: 4px;
}
.adminUiScope .adminGamePicker__item {
.adminUiScope .adminTemplatePicker__item {
display: grid;
/* gap: 2px; */
padding: 11px 12px;
@@ -2602,32 +2604,32 @@ function userAvatarFallback(user) {
color: var(--theme-text);
cursor: pointer;
}
.adminUiScope .adminGamePicker__item--active {
.adminUiScope .adminTemplatePicker__item--active {
border-color: rgba(77, 127, 233, 0.58);
background: rgba(77, 127, 233, 0.12);
}
.adminUiScope .adminGamePicker__item--disabled {
.adminUiScope .adminTemplatePicker__item--disabled {
cursor: not-allowed;
opacity: 0.58;
border-style: dashed;
}
.adminUiScope .adminGamePicker__name {
.adminUiScope .adminTemplatePicker__name {
font-size: 13px;
font-weight: 800;
}
.adminUiScope .adminGamePicker__meta {
.adminUiScope .adminTemplatePicker__meta {
font-size: 11px;
color: var(--theme-text-soft);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.adminUiScope .adminGamePicker__state {
.adminUiScope .adminTemplatePicker__state {
margin-top: 4px;
font-size: 11px;
color: var(--theme-text-faint);
}
.adminUiScope .gamePickerModalList {
.adminUiScope .templatePickerModalList {
margin-top: 14px;
display: grid;
gap: 8px;
@@ -2874,16 +2876,16 @@ function userAvatarFallback(user) {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.adminUiScope .gameManagerGrid {
.adminUiScope .templateManagerGrid {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.adminUiScope .gameManagerGrid--single {
.adminUiScope .templateManagerGrid--single {
grid-template-columns: minmax(0, 1fr);
}
.adminUiScope .gameManagerCard__body {
.adminUiScope .templateManagerCard__body {
margin-top: 10px;
display: grid;
gap: 10px;
@@ -3049,37 +3051,37 @@ function userAvatarFallback(user) {
display: flex;
gap: 8px;
}
.adminUiScope .selectedGame__name {
.adminUiScope .selectedTemplate__name {
margin-top: 8px;
font-size: 22px;
font-weight: 900;
}
.adminUiScope .selectedGame__id {
.adminUiScope .selectedTemplate__id {
margin-top: 6px;
opacity: 0.72;
word-break: break-all;
}
.adminUiScope .gameSettingsCard {
.adminUiScope .templateSettingsCard {
display: grid;
grid-template-columns: minmax(220px, 300px) minmax(0, 1fr);
gap: 18px;
align-items: center;
}
.adminUiScope .gameSettingsCard__media {
.adminUiScope .templateSettingsCard__media {
min-width: 0;
}
.adminUiScope .gameSettingsCard__body {
.adminUiScope .templateSettingsCard__body {
display: grid;
gap: 14px;
align-content: center;
}
.adminUiScope .gameSettingsCard__meta {
.adminUiScope .templateSettingsCard__meta {
color: var(--theme-text-soft);
font-size: 13px;
line-height: 1.5;
word-break: break-all;
}
.adminUiScope .gameSettingsCard__actions {
.adminUiScope .templateSettingsCard__actions {
display: flex;
justify-content: space-between;
gap: 10px;
@@ -3102,11 +3104,11 @@ function userAvatarFallback(user) {
.adminUiScope .selectedThumb--sidebar {
width: 100%;
}
.adminUiScope .selectedGameSidebar__name {
.adminUiScope .selectedTemplateSidebar__name {
font-size: 18px;
font-weight: 900;
}
.adminUiScope .selectedGameSidebar__id {
.adminUiScope .selectedTemplateSidebar__id {
font-size: 12px;
opacity: 0.68;
word-break: break-all;
@@ -3518,6 +3520,19 @@ function userAvatarFallback(user) {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
}
.adminUiScope .customItemModal__preview {
display: flex;
justify-content: flex-start;
}
.adminUiScope .customItemModal__previewImage {
width: 88px;
height: 88px;
object-fit: cover;
border-radius: 18px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
flex: 0 0 auto;
}
.adminUiScope .customItemModal__pickerPanel::-webkit-scrollbar,
.adminUiScope .customItemModal__content::-webkit-scrollbar {
width: 8px;
@@ -4480,8 +4495,8 @@ function userAvatarFallback(user) {
}
.adminUiScope .featuredOrderPanel,
.adminUiScope .section--topGrid,
.adminUiScope .gameManagerGrid,
.adminUiScope .gameSettingsCard,
.adminUiScope .templateManagerGrid,
.adminUiScope .templateSettingsCard,
.adminUiScope .toolbar,
.adminUiScope .itemComposer,
.adminUiScope .tierAdminCard,

View File

@@ -223,6 +223,7 @@ onMounted(loadFavorites)
height: 22px;
border-radius: 9999px;
object-fit: cover;
border: 1px solid var(--theme-avatar-border);
background: var(--theme-border);
flex: 0 0 auto;
}

View File

@@ -165,9 +165,9 @@ function templateThumbUrl(template) {
width: 34px;
height: 34px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(15, 15, 15, 0.72);
color: rgba(255, 255, 255, 0.82);
border: 1px solid var(--theme-favorite-border);
background: var(--theme-favorite-bg);
color: var(--theme-favorite-icon);
font-size: 17px;
line-height: 1;
cursor: pointer;
@@ -177,16 +177,16 @@ function templateThumbUrl(template) {
justify-content: center;
}
.libraryCard__favorite--active {
background: rgba(54, 45, 10, 0.92);
border-color: rgba(255, 216, 107, 0.28);
background: var(--theme-favorite-active-bg);
border-color: var(--theme-favorite-active-border);
}
.libraryCard__favoriteIcon {
opacity: 0.76;
color: rgba(255, 255, 255, 0.94);
color: var(--theme-favorite-icon);
}
.libraryCard__favorite--active .libraryCard__favoriteIcon {
opacity: 1;
color: #ffd86b;
color: var(--theme-favorite-active-icon);
}
.libraryCard__thumbWrap {
width: 100%;

View File

@@ -4,25 +4,20 @@ import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { api } from '../lib/api'
import { homePath, mePath } from '../lib/paths'
import { useToast } from '../composables/useToast'
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 hasUsers = ref(true)
watch(error, (message) => {
if (!message) return
toast.error(message)
error.value = ''
})
const emailError = ref('')
const nicknameError = ref('')
const title = computed(() => (mode.value === 'signup' ? '회원가입' : '로그인'))
const description = computed(() =>
@@ -57,18 +52,59 @@ watch(
{ immediate: true }
)
watch(mode, () => {
error.value = ''
emailError.value = ''
nicknameError.value = ''
})
watch(email, () => {
emailError.value = ''
if (error.value === '이메일이 이미 사용 중이에요.') error.value = ''
})
watch(nickname, () => {
nicknameError.value = ''
if (error.value === '닉네임이 이미 사용 중이에요.' || error.value === '사용할 수 없는 닉네임이에요.') error.value = ''
})
async function submit() {
error.value = ''
emailError.value = ''
nicknameError.value = ''
if (mode.value === 'signup' && nickname.value.trim().length < 2) {
nicknameError.value = '닉네임은 2자 이상 입력해주세요.'
error.value = '닉네임을 확인해주세요.'
return
}
if (mode.value === 'signup' && password.value !== passwordConfirm.value) {
error.value = '비밀번호 확인이 일치하지 않아요.'
return
}
try {
if (mode.value === 'signup') await auth.signup(email.value, password.value)
if (mode.value === 'signup') await auth.signup(email.value, nickname.value, password.value)
else await auth.login(email.value, password.value)
router.push(typeof route.query.redirect === 'string' ? route.query.redirect : mePath())
} 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
}
}
error.value = mode.value === 'signup' ? '회원가입에 실패했어요.' : '로그인에 실패했어요.'
}
}
</script>
@@ -102,9 +138,17 @@ async function submit() {
<label class="field">
<span class="field__label">이메일</span>
<input v-model="email" class="field__input" 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 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 class="field">
<span class="field__label">비밀번호</span>
<input
@@ -132,6 +176,7 @@ async function submit() {
</label>
<div v-if="!hasUsers" class="roleBadge"> 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>
<div v-if="error" class="authError">{{ error }}</div>
<div class="authActions">
<button class="secondaryAction" type="button" @click="router.push(homePath())">취소</button>
@@ -244,6 +289,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;
@@ -255,6 +306,16 @@ 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;
}
.authActions {
display: flex;
gap: 12px;

View File

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

View File

@@ -215,6 +215,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;
}

View File

@@ -319,7 +319,7 @@ watch(
height: 22px;
border-radius: 9999px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.12);
border: 1px solid var(--theme-avatar-border);
background: var(--theme-border);
flex: 0 0 auto;
}