Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73a269d61d | |||
| 713b07a1de | |||
| 764e18c16b | |||
| 2107223634 | |||
| 5eb08e1757 | |||
| 15c03835ef | |||
| 672d17849b | |||
| 85863b1b36 | |||
| 60cc5a72c5 | |||
| 8898ac24f9 | |||
| 9f6cb33bbd | |||
| b3575d59a6 | |||
| d0ebc97bc3 | |||
| 139f78bb89 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,6 +8,7 @@ backend/.sessions/
|
||||
backend/uploads/avatars/
|
||||
backend/uploads/games/
|
||||
backend/uploads/custom/
|
||||
backend/uploads/assets/
|
||||
|
||||
.DS_Store
|
||||
.env.production
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -80,7 +80,6 @@ app.use(async (req, res, next) => {
|
||||
})
|
||||
|
||||
app.use('/api/auth', authRoutes)
|
||||
app.use('/api/games', topicsRoutes)
|
||||
app.use('/api/topics', topicsRoutes)
|
||||
app.use('/api/tierlists', tierListsRoutes)
|
||||
app.use('/api/admin', adminRoutes)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,19 +762,19 @@ 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) {
|
||||
const topic = await findTopicById(topicId)
|
||||
if (!topic) return null
|
||||
const items = await listTopicItems(topicId)
|
||||
return { topic, game: topic, items }
|
||||
return { topic, template: topic, items }
|
||||
}
|
||||
|
||||
async function createTopic({ id, name, isPublic = true }) {
|
||||
@@ -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, 'game-thumbnail')
|
||||
for (const row of gameItemRows) addUsage(row.src, 'game-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 deleteGameItem(row.id)
|
||||
stats.deletedGameItems += 1
|
||||
await deleteTopicItem(row.id)
|
||||
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) {
|
||||
@@ -1468,22 +1454,6 @@ async function createCustomItem({ id, ownerId, src, label }) {
|
||||
return { id, ownerId, src, label, origin: 'custom', createdAt }
|
||||
}
|
||||
|
||||
const listGames = listTopics
|
||||
const findGameById = findTopicById
|
||||
const listGameItems = listTopicItems
|
||||
const findGameItemById = findTopicItemById
|
||||
const getGameDetail = getTopicDetail
|
||||
const createGame = createTopic
|
||||
const updateGameThumbnail = updateTopicThumbnail
|
||||
const updateGameVisibility = updateTopicVisibility
|
||||
const createGameItem = createTopicItem
|
||||
const updateGameItemLabel = updateTopicItemLabel
|
||||
const updateGameItemDisplayOrder = updateTopicItemDisplayOrder
|
||||
const countTierListsUsingGameItem = countTierListsUsingTopicItem
|
||||
const deleteGameItem = deleteTopicItem
|
||||
const deleteGame = deleteTopic
|
||||
const updateGameDisplayOrder = updateTopicDisplayOrder
|
||||
|
||||
async function syncOwnedCustomItemLabels({ ownerId, items }) {
|
||||
const customItems = Array.from(
|
||||
new Map(
|
||||
@@ -1533,7 +1503,7 @@ async function getCustomItemUsageMeta() {
|
||||
`
|
||||
)
|
||||
const usageMap = new Map()
|
||||
const linkedGamesMap = new Map()
|
||||
const linkedTemplatesMap = new Map()
|
||||
|
||||
rows.forEach((row) => {
|
||||
const groups = parseJson(row.groups_json, [])
|
||||
@@ -1557,8 +1527,8 @@ async function getCustomItemUsageMeta() {
|
||||
if (!row.topic_id) return
|
||||
|
||||
seenItemIds.forEach((itemId) => {
|
||||
if (!linkedGamesMap.has(itemId)) linkedGamesMap.set(itemId, new Map())
|
||||
linkedGamesMap.get(itemId).set(row.topic_id, {
|
||||
if (!linkedTemplatesMap.has(itemId)) linkedTemplatesMap.set(itemId, new Map())
|
||||
linkedTemplatesMap.get(itemId).set(row.topic_id, {
|
||||
id: row.topic_id,
|
||||
name: row.topic_name || row.topic_id,
|
||||
})
|
||||
@@ -1567,7 +1537,7 @@ async function getCustomItemUsageMeta() {
|
||||
|
||||
return {
|
||||
usageMap,
|
||||
linkedGamesMap: new Map(Array.from(linkedGamesMap.entries()).map(([itemId, gameMap]) => [itemId, Array.from(gameMap.values())])),
|
||||
linkedTemplatesMap: new Map(Array.from(linkedTemplatesMap.entries()).map(([itemId, templateMap]) => [itemId, Array.from(templateMap.values())])),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1578,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
|
||||
@@ -1626,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, {
|
||||
@@ -1636,7 +1606,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
})
|
||||
|
||||
const customItems = customRows.map((row) => {
|
||||
const linkedGames = Array.from((templateLinkedBySrc.get(row.src) || new Map()).values())
|
||||
const linkedTemplates = Array.from((templateLinkedBySrc.get(row.src) || new Map()).values())
|
||||
return {
|
||||
id: row.id,
|
||||
ownerId: row.owner_id,
|
||||
@@ -1646,14 +1616,14 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
ownerName: row.nickname || row.email,
|
||||
ownerEmail: row.email,
|
||||
usageCount: usageMeta.usageMap.get(row.id) || 0,
|
||||
linkedGames,
|
||||
linkedTemplates,
|
||||
sourceType: 'user',
|
||||
sourceLabel: '사용자 업로드',
|
||||
canDelete: true,
|
||||
}
|
||||
})
|
||||
|
||||
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))
|
||||
@@ -1667,7 +1637,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
ownerName: '관리자 보관 자산',
|
||||
ownerEmail: '',
|
||||
usageCount: 0,
|
||||
linkedGames: [],
|
||||
linkedTemplates: [],
|
||||
sourceType: 'template',
|
||||
sourceLabel: '관리자 템플릿',
|
||||
canDelete: true,
|
||||
@@ -1676,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,
|
||||
@@ -1685,7 +1655,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
ownerName: row.topic_name || row.topic_id,
|
||||
ownerEmail: '',
|
||||
usageCount: (templateLinkedBySrc.get(row.src) || new Map()).size,
|
||||
linkedGames: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()),
|
||||
linkedTemplates: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()),
|
||||
sourceType: 'template',
|
||||
sourceLabel: '관리자 템플릿',
|
||||
canDelete: true,
|
||||
@@ -1704,7 +1674,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
const allItems = baseItems
|
||||
.map((item) => {
|
||||
const siblings = groupedBySrc.get(item.src) || [item]
|
||||
const linkedGames = new Map()
|
||||
const linkedTemplates = new Map()
|
||||
let userReferenceCount = 0
|
||||
let templateReferenceCount = 0
|
||||
let assetReferenceCount = 0
|
||||
@@ -1713,8 +1683,8 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
if (entry.sourceType === 'user') userReferenceCount += 1
|
||||
else if (entry.isAssetLibraryItem) assetReferenceCount += 1
|
||||
else templateReferenceCount += 1
|
||||
;(entry.linkedGames || []).forEach((game) => {
|
||||
if (game?.id) linkedGames.set(game.id, game)
|
||||
;(entry.linkedTemplates || []).forEach((template) => {
|
||||
if (template?.id) linkedTemplates.set(template.id, template)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1724,7 +1694,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
sharedUserReferenceCount: userReferenceCount,
|
||||
sharedTemplateReferenceCount: templateReferenceCount,
|
||||
sharedAssetReferenceCount: assetReferenceCount,
|
||||
sharedLinkedGameCount: linkedGames.size,
|
||||
sharedLinkedTemplateCount: linkedTemplates.size,
|
||||
sharedEntries: siblings
|
||||
.slice()
|
||||
.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))
|
||||
@@ -1738,7 +1708,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
sourceTopicId: entry.sourceTopicId || '',
|
||||
sourceTopicName: entry.sourceTopicName || '',
|
||||
usageCount: entry.usageCount || 0,
|
||||
linkedGames: entry.linkedGames || [],
|
||||
linkedTemplates: entry.linkedTemplates || [],
|
||||
isAssetLibraryItem: !!entry.isAssetLibraryItem,
|
||||
})),
|
||||
}
|
||||
@@ -1752,7 +1722,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
case 'asset':
|
||||
return !!item.isAssetLibraryItem
|
||||
case 'unused-user':
|
||||
return item.sourceType === 'user' && item.usageCount === 0 && item.linkedGames.length === 0
|
||||
return item.sourceType === 'user' && item.usageCount === 0 && item.linkedTemplates.length === 0
|
||||
case 'unused-admin':
|
||||
return !!item.isAssetLibraryItem
|
||||
default:
|
||||
@@ -2027,7 +1997,7 @@ function uniqueTierListItems(poolItems) {
|
||||
id: item.id,
|
||||
src: item.src || '',
|
||||
label: item.label || 'item',
|
||||
origin: item.origin || 'game',
|
||||
origin: item.origin || 'template',
|
||||
})
|
||||
})
|
||||
return Array.from(map.values())
|
||||
@@ -2052,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)
|
||||
}
|
||||
@@ -2137,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)
|
||||
}
|
||||
@@ -2522,15 +2492,13 @@ async function unfavoriteTopic({ userId, topicId }) {
|
||||
await query('DELETE FROM favorite_topics WHERE user_id = ? AND topic_id = ?', [userId, topicId])
|
||||
}
|
||||
|
||||
const favoriteGame = favoriteTopic
|
||||
const unfavoriteGame = unfavoriteTopic
|
||||
|
||||
module.exports = {
|
||||
DB_NAME,
|
||||
ensureData,
|
||||
closePool,
|
||||
countUsers,
|
||||
findUserByEmail,
|
||||
findUserByNickname,
|
||||
findUserById,
|
||||
createUser,
|
||||
updateUserProfile,
|
||||
@@ -2547,14 +2515,6 @@ module.exports = {
|
||||
createTopic,
|
||||
updateTopicThumbnail,
|
||||
updateTopicVisibility,
|
||||
listGames,
|
||||
findGameById,
|
||||
listGameItems,
|
||||
findGameItemById,
|
||||
getGameDetail,
|
||||
createGame,
|
||||
updateGameThumbnail,
|
||||
updateGameVisibility,
|
||||
findImageAssetByHash,
|
||||
findImageAssetBySrc,
|
||||
findImageAssetById,
|
||||
@@ -2578,15 +2538,8 @@ module.exports = {
|
||||
deleteTopicItem,
|
||||
deleteTopic,
|
||||
updateTopicDisplayOrder,
|
||||
createGameItem,
|
||||
updateGameItemLabel,
|
||||
updateGameItemDisplayOrder,
|
||||
countTierListsUsingGameItem,
|
||||
updateCustomItemLabel,
|
||||
updateImageAssetLabel,
|
||||
deleteGameItem,
|
||||
deleteGame,
|
||||
updateGameDisplayOrder,
|
||||
createCustomItem,
|
||||
findCustomItemById,
|
||||
listCustomItems,
|
||||
@@ -2602,8 +2555,6 @@ module.exports = {
|
||||
unfavoriteTopic,
|
||||
favoriteTierList,
|
||||
unfavoriteTierList,
|
||||
favoriteGame,
|
||||
unfavoriteGame,
|
||||
deleteTierList,
|
||||
findCustomItemsByIds,
|
||||
deleteCustomItems,
|
||||
@@ -2614,5 +2565,4 @@ module.exports = {
|
||||
listAdminTemplateRequests,
|
||||
updateTemplateRequestStatus,
|
||||
updateTemplateRequestTargetTopic,
|
||||
updateTemplateRequestTargetGame: updateTemplateRequestTargetTopic,
|
||||
}
|
||||
|
||||
48
backend/src/lib/user-validation.js
Normal file
48
backend/src/lib/user-validation.js
Normal file
@@ -0,0 +1,48 @@
|
||||
const RESERVED_NICKNAME_KEYWORDS = [
|
||||
'admin',
|
||||
'administrator',
|
||||
'operator',
|
||||
'owner',
|
||||
'master',
|
||||
'staff',
|
||||
'system',
|
||||
'root',
|
||||
'support',
|
||||
'manager',
|
||||
'mod',
|
||||
'moderator',
|
||||
'official',
|
||||
'service',
|
||||
'team',
|
||||
'zenn',
|
||||
'운영자',
|
||||
'관리자',
|
||||
'오너',
|
||||
'마스터',
|
||||
'스태프',
|
||||
'시스템',
|
||||
'루트',
|
||||
'서포트',
|
||||
'매니저',
|
||||
'모더레이터',
|
||||
'공식',
|
||||
]
|
||||
|
||||
function normalizeNickname(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
function isReservedNickname(value) {
|
||||
const normalized = normalizeNickname(value)
|
||||
if (!normalized) return false
|
||||
return RESERVED_NICKNAME_KEYWORDS.some((keyword) => normalized.includes(normalizeNickname(keyword)))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RESERVED_NICKNAME_KEYWORDS,
|
||||
normalizeNickname,
|
||||
isReservedNickname,
|
||||
}
|
||||
@@ -7,6 +7,8 @@ const { z } = require('zod')
|
||||
const { nanoid } = require('nanoid')
|
||||
const {
|
||||
findUserById,
|
||||
findUserByEmail,
|
||||
findUserByNickname,
|
||||
findTopicById,
|
||||
findTopicItemById,
|
||||
listTopicItems,
|
||||
@@ -52,11 +54,10 @@ const {
|
||||
} = require('../db')
|
||||
const { requireAdmin } = require('../middleware/auth')
|
||||
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
function getTemplateIdParam(req) {
|
||||
return req.params.templateId || req.params.gameId || ''
|
||||
function getTemplateIdFromParams(req) {
|
||||
return req.params.templateId || ''
|
||||
}
|
||||
|
||||
function buildUploadFilename(file) {
|
||||
@@ -115,7 +116,7 @@ function canManageAdminRole(actingUser, primaryAdmin) {
|
||||
return !!actingUser?.isAdmin && primaryAdmin?.id === actingUser.id
|
||||
}
|
||||
|
||||
router.post(['/games', '/templates'], requireAdmin, async (req, res) => {
|
||||
router.post('/templates', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).max(60),
|
||||
@@ -125,24 +126,24 @@ router.post(['/games', '/templates'], requireAdmin, async (req, res) => {
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
const exists = await findTopicById(parsed.data.id)
|
||||
if (exists) return res.status(409).json({ error: 'game_id_taken' })
|
||||
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)
|
||||
res.json({ template: savedTemplate })
|
||||
})
|
||||
|
||||
router.patch(['/games/:gameId', '/templates/:templateId'], requireAdmin, async (req, res) => {
|
||||
router.patch('/templates/:templateId', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
isPublic: z.boolean(),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const templateId = getTemplateIdParam(req)
|
||||
const templateId = getTemplateIdFromParams(req)
|
||||
const template = await findTopicById(templateId)
|
||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
@@ -150,7 +151,7 @@ router.patch(['/games/:gameId', '/templates/:templateId'], requireAdmin, async (
|
||||
res.json({ template: updated })
|
||||
})
|
||||
|
||||
router.patch(['/games/display-order', '/templates/display-order'], requireAdmin, async (req, res) => {
|
||||
router.patch('/templates/display-order', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
topicIds: z.array(z.string().min(1)).max(50),
|
||||
})
|
||||
@@ -164,14 +165,14 @@ router.patch(['/games/display-order', '/templates/display-order'], requireAdmin,
|
||||
res.json({ templates: updatedTemplates })
|
||||
})
|
||||
|
||||
router.patch(['/games/:gameId/items/display-order', '/templates/:templateId/items/display-order'], requireAdmin, async (req, res) => {
|
||||
router.patch('/templates/:templateId/items/display-order', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
itemIds: z.array(z.string().min(1)).min(1),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const templateId = getTemplateIdParam(req)
|
||||
const templateId = getTemplateIdFromParams(req)
|
||||
const template = await findTopicById(templateId)
|
||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
@@ -179,15 +180,15 @@ router.patch(['/games/:gameId/items/display-order', '/templates/:templateId/item
|
||||
res.json({ items })
|
||||
})
|
||||
|
||||
router.post(['/games/:gameId/thumbnail', '/templates/:templateId/thumbnail'], requireAdmin, upload.single('thumbnail'), async (req, res) => {
|
||||
router.post('/templates/:templateId/thumbnail', requireAdmin, upload.single('thumbnail'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'file_required' })
|
||||
const templateId = getTemplateIdParam(req)
|
||||
const templateId = getTemplateIdFromParams(req)
|
||||
const template = await findTopicById(templateId)
|
||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const optimized = await writeOptimizedImage({
|
||||
file: req.file,
|
||||
directory: 'games',
|
||||
directory: 'topics',
|
||||
width: 1280,
|
||||
height: 1280,
|
||||
fit: 'inside',
|
||||
@@ -198,10 +199,10 @@ router.post(['/games/:gameId/thumbnail', '/templates/:templateId/thumbnail'], re
|
||||
res.json({ template: updated })
|
||||
})
|
||||
|
||||
router.post(['/games/:gameId/images', '/templates/:templateId/images'], requireAdmin, upload.array('images', 50), async (req, res) => {
|
||||
router.post('/templates/:templateId/images', requireAdmin, upload.array('images', 50), async (req, res) => {
|
||||
const files = Array.isArray(req.files) ? req.files : []
|
||||
if (!files.length) return res.status(400).json({ error: 'file_required' })
|
||||
const templateId = getTemplateIdParam(req)
|
||||
const templateId = getTemplateIdFromParams(req)
|
||||
const template = await findTopicById(templateId)
|
||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
@@ -209,39 +210,68 @@ router.post(['/games/:gameId/images', '/templates/:templateId/images'], requireA
|
||||
const labels = Array.isArray(labelsRaw) ? labelsRaw : labelsRaw ? [labelsRaw] : []
|
||||
const normalizedLabels = labels.map((label) => (typeof label === 'string' ? label.trim().slice(0, 60) : ''))
|
||||
if (normalizedLabels.some((label) => label.length > 60)) return res.status(400).json({ error: 'bad_request' })
|
||||
const totalBytes = files.reduce((sum, file) => sum + Number(file?.size || 0), 0)
|
||||
|
||||
const items = await Promise.all(
|
||||
files.map(async (file, index) => {
|
||||
const optimized = await writeOptimizedImage({
|
||||
file,
|
||||
directory: 'games',
|
||||
width: 512,
|
||||
height: 512,
|
||||
fit: 'inside',
|
||||
quality: 84,
|
||||
})
|
||||
console.info('[admin] template image upload start', {
|
||||
templateId: template.id,
|
||||
fileCount: files.length,
|
||||
totalBytes,
|
||||
fileNames: files.map((file) => file.originalname),
|
||||
})
|
||||
|
||||
return createTopicItem({
|
||||
id: nanoid(),
|
||||
topicId: template.id,
|
||||
src: optimized.src,
|
||||
label: normalizedLabels[index] || buildItemLabelFromFilename(file),
|
||||
try {
|
||||
const items = await Promise.all(
|
||||
files.map(async (file, index) => {
|
||||
const optimized = await writeOptimizedImage({
|
||||
file,
|
||||
directory: 'topics',
|
||||
width: 512,
|
||||
height: 512,
|
||||
fit: 'inside',
|
||||
quality: 84,
|
||||
})
|
||||
|
||||
return createTopicItem({
|
||||
id: nanoid(),
|
||||
topicId: template.id,
|
||||
src: optimized.src,
|
||||
label: normalizedLabels[index] || buildItemLabelFromFilename(file),
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
console.info('[admin] template image upload success', {
|
||||
templateId: template.id,
|
||||
fileCount: items.length,
|
||||
totalBytes,
|
||||
})
|
||||
)
|
||||
|
||||
res.json({ item: items[0], items })
|
||||
res.json({ item: items[0], items })
|
||||
} catch (error) {
|
||||
console.error('[admin] template image upload failed', {
|
||||
templateId: template.id,
|
||||
fileCount: files.length,
|
||||
totalBytes,
|
||||
message: error?.message || 'upload_failed',
|
||||
code: error?.code || '',
|
||||
stack: error?.stack || '',
|
||||
})
|
||||
res.status(500).json({
|
||||
error: 'template_image_upload_failed',
|
||||
detail: error?.message || 'upload_failed',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
router.delete(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:itemId'], requireAdmin, async (req, res) => {
|
||||
const template = await findTopicById(getTemplateIdParam(req))
|
||||
router.delete('/templates/:templateId/items/:itemId', requireAdmin, async (req, res) => {
|
||||
const template = await findTopicById(getTemplateIdFromParams(req))
|
||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||
await deleteTopicItem(req.params.itemId)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
router.get(['/games/:gameId/items/:itemId/usage', '/templates/:templateId/items/:itemId/usage'], requireAdmin, async (req, res) => {
|
||||
const template = await findTopicById(getTemplateIdParam(req))
|
||||
router.get('/templates/:templateId/items/:itemId/usage', requireAdmin, async (req, res) => {
|
||||
const template = await findTopicById(getTemplateIdFromParams(req))
|
||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||
const item = await findTopicItemById(req.params.itemId)
|
||||
if (!item || item.topicId !== template.id) return res.status(404).json({ error: 'not_found' })
|
||||
@@ -249,12 +279,12 @@ router.get(['/games/:gameId/items/:itemId/usage', '/templates/:templateId/items/
|
||||
res.json({ usage })
|
||||
})
|
||||
|
||||
router.patch(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:itemId'], requireAdmin, async (req, res) => {
|
||||
router.patch('/templates/:templateId/items/:itemId', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({ label: z.string().trim().min(1).max(60) })
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const template = await findTopicById(getTemplateIdParam(req))
|
||||
const template = await findTopicById(getTemplateIdFromParams(req))
|
||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const updated = await updateTopicItemLabel(req.params.itemId, parsed.data.label)
|
||||
@@ -262,8 +292,8 @@ router.patch(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:ite
|
||||
res.json({ item: updated })
|
||||
})
|
||||
|
||||
router.delete(['/games/:gameId', '/templates/:templateId'], requireAdmin, async (req, res) => {
|
||||
const templateId = getTemplateIdParam(req)
|
||||
router.delete('/templates/:templateId', requireAdmin, async (req, res) => {
|
||||
const templateId = getTemplateIdFromParams(req)
|
||||
const template = await findTopicById(templateId)
|
||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||
await deleteTopic(templateId)
|
||||
@@ -469,7 +499,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 +537,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 +561,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 +606,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(),
|
||||
@@ -593,14 +623,14 @@ async function createTemplateFromTierList({ tierList, templateId, templateName }
|
||||
)
|
||||
}
|
||||
|
||||
return { game: await findTopicById(templateId), items: createdItems }
|
||||
return { template: await findTopicById(templateId), items: createdItems }
|
||||
}
|
||||
|
||||
async function createTemplateFromRequest({ templateRequest, templateId, templateName }) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -609,7 +639,7 @@ async function createTemplateFromRequest({ templateRequest, templateId, template
|
||||
templateId,
|
||||
})
|
||||
|
||||
return { game: await findTopicById(templateId), items }
|
||||
return { template: await findTopicById(templateId), items }
|
||||
}
|
||||
|
||||
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
||||
@@ -631,7 +661,7 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
||||
}
|
||||
|
||||
if (!target.canDelete) return res.status(409).json({ error: 'item_locked' })
|
||||
if (target.linkedGames.length > 0) return res.status(409).json({ error: 'item_linked' })
|
||||
if (target.linkedTemplates.length > 0) return res.status(409).json({ error: 'item_linked' })
|
||||
if (target.usageCount > 0) return res.status(409).json({ error: 'item_in_use' })
|
||||
|
||||
const items = await findCustomItemsByIds([target.id])
|
||||
@@ -691,7 +721,7 @@ router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, re
|
||||
res.json({ items })
|
||||
})
|
||||
|
||||
router.post('/tierlists/:tierListId/create-game-template', requireAdmin, async (req, res) => {
|
||||
router.post('/tierlists/:tierListId/create-template', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
topicId: z.string().trim().min(1).max(120),
|
||||
name: z.string().trim().min(1).max(120),
|
||||
@@ -804,7 +834,7 @@ router.post('/template-requests/:requestId/review', requireAdmin, async (req, re
|
||||
res.json({ request })
|
||||
})
|
||||
|
||||
router.post('/template-requests/:requestId/link-game', requireAdmin, async (req, res) => {
|
||||
router.post('/template-requests/:requestId/link-template', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
topicId: z.string().trim().min(1).max(120),
|
||||
})
|
||||
@@ -962,6 +992,15 @@ router.patch('/users/:userId', requireAdmin, async (req, res) => {
|
||||
return res.status(403).json({ error: 'primary_admin_only' })
|
||||
}
|
||||
|
||||
const duplicateEmail = await findUserByEmail(parsed.data.email)
|
||||
if (duplicateEmail && duplicateEmail.id !== targetUser.id) {
|
||||
return res.status(409).json({ error: 'email_taken' })
|
||||
}
|
||||
const duplicateNickname = await findUserByNickname(parsed.data.nickname, targetUser.id)
|
||||
if (duplicateNickname) {
|
||||
return res.status(409).json({ error: 'nickname_taken' })
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await adminUpdateUser({
|
||||
id: targetUser.id,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -24,7 +24,7 @@ const FREEFORM_TOPIC_ID = 'freeform'
|
||||
const FREEFORM_DEFAULT_TITLE = '직접 티어표 만들기'
|
||||
|
||||
function normalizePoolItem(item) {
|
||||
if (!item || item.origin !== 'game' || typeof item.src !== 'string') return item
|
||||
if (!item || item.origin !== 'template' || typeof item.src !== 'string') return item
|
||||
if (item.src.startsWith('/uploads/')) return item
|
||||
|
||||
try {
|
||||
@@ -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(['game', 'custom']).default('game'),
|
||||
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(['game', 'custom']).default('game'),
|
||||
origin: z.enum(['template', 'custom']).default('template'),
|
||||
})
|
||||
),
|
||||
}).superRefine((value, ctx) => {
|
||||
|
||||
@@ -1,5 +1,53 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-04-03 v1.4.40
|
||||
- 공유 링크 진입 화면은 사용자가 조작 가능한 편집 화면보다 `뷰어 모드`로 명확히 분리하는 편이 안전하므로, 비로그인 사용자와 타인 티어표는 기본적으로 드래그 없는 완성본 열람 상태를 보여주기로 정리했다.
|
||||
- 공유 링크를 받은 비로그인 사용자도 다시 전달할 수 있어야 하므로 `공유하기`는 로그인 여부와 무관하게 뷰어 모드에서 열어두고, `내 티어표로 복사`는 로그인한 타인 열람자에게만 노출하는 쪽으로 권한을 나눴다.
|
||||
- 작성자 본인도 공유 화면이 어떻게 보이는지 확인할 필요가 있으므로, 본인 티어표는 `수정 모드`와 `뷰어 모드`를 양방향 전환할 수 있게 두는 편이 자연스럽다고 판단했다.
|
||||
- 뷰어 모드 우측 레일은 빈 공간을 크게 남기기보다 다른 화면처럼 광고를 상단에 두고, 실제 행동 버튼은 하단 카드로 모아 시각 균형과 전환 흐름을 함께 맞추는 방향으로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.39
|
||||
- 기존 저장 티어표를 다시 열 때마다 최신 템플릿 아이템으로 전체를 덮어쓰면 과거 결과물이 깨질 수 있으므로, 저장본의 기존 그룹/풀은 보존하고 새로 추가된 템플릿 아이템만 미사용 풀에 합류시키는 병합 방식이 더 안전하다고 판단했다.
|
||||
- 관리자 템플릿에서 삭제된 기본 아이템을 과거 저장본에서도 자동 삭제하면 사용자 결과물 보존성이 떨어지므로, 삭제는 이후 새 티어표 생성에만 반영하고 기존 저장본은 유지하는 정책을 계속 유지하기로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.38
|
||||
- 최고 관리자 보호는 서버에서만 막고 프런트 버튼은 열어두면 운영자가 계속 실패 액션을 누르게 되므로, 최고 관리자 대상 행은 일반 운영자 화면에서 버튼 자체를 비활성화하는 편이 맞다고 판단했다.
|
||||
- 예약어 닉네임은 일반 가입/프로필 수정에서는 계속 막아야 하지만, 운영자가 직접 회원 계정을 정리할 때는 `공식`, `운영자` 같은 표기를 의도적으로 부여해야 할 수 있으므로 관리자 수정 API만 예외를 두는 쪽으로 정리했다.
|
||||
- 공유 프리뷰는 별도 단독 페이지처럼 보이는 것보다 홈과 같은 좌측/우측 레일, 같은 중앙 헤더, 같은 메인 배경 위에 자연스럽게 얹히는 편이 서비스 유입과 탐색 전환에 더 유리하다고 판단해, 프리뷰 전용 셸을 공통 앱 셸로 흡수했다.
|
||||
|
||||
## 2026-04-02 v1.4.33
|
||||
- 서비스 공개 전 단계에서는 가입 자체를 열어두는 것보다, 이메일/닉네임 중복과 운영자 사칭성 닉네임을 먼저 막아두는 편이 훨씬 중요하다고 판단했다.
|
||||
- 닉네임 제한은 회원가입 한 곳에만 두면 이후 프로필 수정이나 관리자 수정으로 쉽게 우회되므로, auth/profile/admin 수정 흐름 전부가 같은 예약어 정책을 공유하도록 정리했다.
|
||||
- 라이트 모드는 취향상 필요한 사용자가 있을 수 있으므로 완전히 제거하기보다, 기본값만 다크로 고정하고 설정 화면에서만 직접 토글하도록 두는 편이 더 균형 잡힌 선택이라고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.32
|
||||
- 서비스 공개 전 마감 단계에서는 사용자 노출 텍스트만이 아니라 파일명·composable 이름·관리자 CSS 클래스·백엔드 헬퍼 함수명까지 같이 정리해 두는 편이 이후 유지보수 비용을 확실히 낮춘다고 판단했다.
|
||||
- 이 시점부터는 `game`이 데이터 호환층도 아닌 단순 내부 이름으로 남아 있는 것조차 혼란을 만들 수 있으므로, 실제 기능을 바꾸지 않는 선에서 이름층까지 끝까지 정리해 코드 검색 결과 자체를 깨끗하게 만드는 방향으로 마감했다.
|
||||
|
||||
## 2026-04-02 v1.4.31
|
||||
- 서비스가 아직 외부 공개 전이고 예전 북마크/예전 데이터베이스를 이어갈 필요가 없다는 전제가 확인되었으므로, 남겨둔 호환층을 유지하는 것보다 지금 마감 시점에 완전히 제거해 구조를 단순화하는 편이 맞다고 판단했다.
|
||||
- 이 단계에서는 “기존 것도 읽어준다”보다 “현재 구조만 남긴다”가 더 중요한 목표가 되었으므로, redirect·legacy migration·`origin: 'game'` 허용까지 함께 정리해 실제 코드 검색에서 `game` 흔적을 0건으로 맞추는 방향으로 마감했다.
|
||||
|
||||
## 2026-04-02 v1.4.30
|
||||
- 로컬 MariaDB는 테스트용으로 새로 밀어도 된다는 전제가 확인되었으므로, 개발 환경에서는 기존 데이터를 끌고 가는 것보다 현재 스키마가 “빈 DB에서 바로 정상 부팅되는지”를 먼저 검증하는 편이 더 가치 있다고 판단했다.
|
||||
- `origin: 'game'` 호환층은 즉시 제거하기보다, `ensureData()`에서 저장 데이터와 요청 스냅샷을 자동 정규화하게 만들어 두고 새 DB에서도 legacy 값이 생기지 않는 상태를 먼저 확인한 뒤 다음 제거 판단으로 넘기는 편이 가장 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.29
|
||||
- `origin: 'game'`는 이미 저장된 티어표 데이터와 직접 맞물리므로, 이 단계에서는 새 데이터 기본값만 `template`로 옮기고 예전 값도 계속 받아주는 점진 호환이 가장 안전하다고 판단했다.
|
||||
- 아이템 라이브러리의 `linkedGames`는 실제 의미가 템플릿 연결 정보이므로, 이 응답 키까지 `linkedTemplates`로 바꿔두는 편이 이후 관리자 유지보수에서 훨씬 덜 헷갈린다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.28
|
||||
- 이 시점 이후 코드 검색에 남는 `game`는 대부분 레거시 데이터 마이그레이션, 옛 주소 redirect, 저장 데이터의 `origin` 호환처럼 의도된 층이므로, 무리하게 전부 0으로 만들기보다 기능을 깨뜨리지 않는 선에서 의미 있는 이름층만 더 줄이는 편이 맞다고 판단했다.
|
||||
- 관리자 화면 내부 상태명(`selectedTemplate.game`, `isGameLoading`, `gameVisibilitySaving`)은 실제 기능 의미와 어긋나므로, QA 전에 한 번 더 `template` 기준으로 옮겨두는 편이 이후 유지보수에 더 유리하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.27
|
||||
- 공개/관리자 API 표면까지 `topic/template`로 정리된 뒤에는, 관리자 내부 상태 이름과 DB export alias에 남은 `game` 흔적도 계속 유지할 이유가 작아졌으므로 이 단계에서 함께 걷어내는 편이 맞다고 판단했다.
|
||||
- 다만 외부에서 직접 참조할 수 있는 공개 북마크와 달리, `adminGames`, `game-admin`, `favoriteGame` 같은 이름은 내부 구현 용어라서 이번 단계에서 정리해도 위험이 낮다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.26
|
||||
- `topic/template` 표면을 거의 마감한 시점에서는 관리자 API와 관리자 화면 경로까지 계속 `/games` alias를 유지하는 편보다, 실제 사용하는 `templates` 경로만 남기고 예전 관리자 주소는 redirect로만 정리하는 편이 더 일관되고 안전하다고 판단했다.
|
||||
- 공개 사용자 북마크는 여전히 `/games -> /topics` redirect가 필요하지만, 백엔드 API의 `/api/games`까지 계속 유지할 이유는 작아졌으므로 이 단계에서 `/api/topics`만 남기는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.25
|
||||
- 이제 프런트와 백엔드 소비층이 `topic/template`를 기본으로 읽을 준비가 되었으므로, 응답과 payload에 `gameId / gameName` 호환 키를 오래 남기는 것보다 실제 표면을 먼저 정리하는 편이 더 낫다고 판단했다.
|
||||
- 다만 오래된 외부 링크까지 한 번에 끊는 건 위험하므로, 이번 단계에서는 데이터/응답/프런트 소비는 `topic`으로 마감하되 `/games/:gameId`와 관리자 route alias 같은 레거시 주소만 마지막 호환 레이어로 남기는 점진 종료가 가장 안전하다고 정리했다.
|
||||
@@ -579,6 +627,22 @@
|
||||
- 게임 이미지 경로는 저장 시 상대 경로(`/uploads/...`)를 유지하는 방향으로 정리했다.
|
||||
- 현재 단계에서는 구조 변경 비용을 고려해 DB를 유지하되, 운영/확장성 요구가 커지기 전 RDB 이관 판단이 필요하다고 기록했다.
|
||||
|
||||
## 2026-04-03 v1.4.37
|
||||
- 드롭 또는 클릭 안내를 보여주는 업로드 박스는 실제 클릭 동작까지 연결돼 있어야 UX가 자연스러우므로, 이후 같은 패턴에서는 박스 자체가 트리거 역할을 하도록 맞추기로 했다.
|
||||
|
||||
## 2026-04-03 v1.4.36
|
||||
- 복사 기능은 “타인 티어표 가져오기”에만 묶기보다, 본인 작업도 파생본을 빠르게 만드는 용도로 열어두는 편이 실제 제작 흐름에 더 맞는다고 정리했다.
|
||||
- 공유 프리뷰도 서비스와 완전히 단절된 단일 화면보다, 광고 레일과 카피라이트를 포함한 가벼운 사이트 문맥을 유지하는 편이 자연스럽다고 판단했다.
|
||||
|
||||
## 2026-04-03 v1.4.35
|
||||
- 실제 사용 테스트에서 아이템 수가 80개 안팎으로 늘어나면 “기능은 있는데 찾을 수 없는 상태”가 되기 쉬워, 편집기 풀에는 가벼운 이름 검색이 필수라고 정리했다.
|
||||
- 공유 링크는 완성본만 보여주는 데서 끝내지 말고 서비스 메인으로 돌아오는 손잡이를 함께 두는 편이 자연스럽다고 판단했다.
|
||||
- 공개 프리뷰의 작성 시각은 분 단위까지 노출할 필요가 낮아, 신뢰에 필요한 최소 정보만 남기는 쪽으로 줄이기로 했다.
|
||||
|
||||
## 2026-04-02 v1.4.34
|
||||
- 라이트모드는 단순 토글 존재만으로 충분하지 않고, 셸/카드/버튼/오버레이가 같은 색 문법을 공유해야 품질이 안정된다고 판단해 공통 토큰을 다시 정리했다.
|
||||
- 홈 카드 즐겨찾기 버튼처럼 다크 전용 하드코딩이 남아 있으면 전체 인상이 쉽게 무너지므로, 이후 테마 보정은 공통 변수 우선 원칙으로 계속 가져가기로 했다.
|
||||
|
||||
## 2026-03-19
|
||||
- 초기 저장소는 빠른 구현을 위해 `lowdb(JSON 파일)`를 채택했다.
|
||||
- 인증은 JWT 대신 서버 세션(`express-session` + `session-file-store`)을 사용했다.
|
||||
|
||||
26
docs/map.md
26
docs/map.md
@@ -2,18 +2,18 @@
|
||||
|
||||
## `/`
|
||||
- 화면 파일: `frontend/src/views/HomeView.vue`
|
||||
- 역할: 데스크톱 기본 4열 게임 카드 라이브러리 대시보드, 상단 메인 썸네일과 `게임명 + 작은 ID` 메타를 가진 템플릿 선택 카드, 게임 카드 클릭 이동, `직접 티어표 만들기` 진입
|
||||
- 연동 API: `GET /api/games`
|
||||
- 역할: 데스크톱 기본 4열 주제 카드 라이브러리 대시보드, 상단 메인 썸네일과 `주제명 + 작은 ID` 메타를 가진 템플릿 선택 카드, 주제 카드 클릭 이동, `직접 티어표 만들기` 진입
|
||||
- 연동 API: `GET /api/topics`
|
||||
|
||||
## `/games/:gameId`
|
||||
- 화면 파일: `frontend/src/views/GameHubView.vue`
|
||||
- 역할: 선택한 게임 정보 표시, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
|
||||
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`
|
||||
## `/topics/:topicId`
|
||||
- 화면 파일: `frontend/src/views/TopicHubView.vue`
|
||||
- 역할: 선택한 주제 정보 표시, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
|
||||
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`
|
||||
|
||||
## `/editor/:gameId/new`, `/editor/:gameId/:tierListId`
|
||||
## `/editor/:topicId/new`, `/editor/:topicId/:tierListId`
|
||||
- 화면 파일: `frontend/src/views/TierEditorView.vue`
|
||||
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청
|
||||
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/:id`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`, `POST /api/tierlists/template-request`
|
||||
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰 렌더링
|
||||
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/:id`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`, `POST /api/tierlists/template-request`
|
||||
|
||||
## `/login`
|
||||
- 화면 파일: `frontend/src/views/LoginView.vue`
|
||||
@@ -37,8 +37,8 @@
|
||||
|
||||
## `/admin`
|
||||
- 화면 파일: `frontend/src/views/AdminView.vue`
|
||||
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 게임 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 게임 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
|
||||
- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `PATCH /api/admin/games/:gameId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `GET /api/admin/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-game-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId`
|
||||
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `템플릿 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 템플릿 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 템플릿 삭제
|
||||
- 연동 API: `POST /api/admin/templates`, `POST /api/admin/templates/:templateId/thumbnail`, `POST /api/admin/templates/:templateId/images`, `PATCH /api/admin/templates/:templateId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `GET /api/admin/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/template-requests/:requestId/link-template`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/templates/:templateId/items/:itemId`, `DELETE /api/admin/templates/:templateId`
|
||||
|
||||
## `/profile`
|
||||
- 화면 파일: `frontend/src/views/ProfileView.vue`
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
## 공통 레이아웃
|
||||
- 앱 셸 파일: `frontend/src/App.vue`
|
||||
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
|
||||
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, `preview=1` 공유 프리뷰에서도 같은 좌우 레일과 중앙 헤더 유지, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
|
||||
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker`와 서비스 설명이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
|
||||
|
||||
## 백엔드 진입점
|
||||
@@ -56,6 +56,6 @@
|
||||
- 로컬 DB 실행 설정: `docker-compose.yml`
|
||||
- 로컬 MariaDB 가이드: `docs/local-mariadb.md`
|
||||
- 인증 라우트: `backend/src/routes/auth.js`
|
||||
- 게임 라우트: `backend/src/routes/games.js`
|
||||
- 주제 라우트: `backend/src/routes/topics.js`
|
||||
- 티어표 라우트: `backend/src/routes/tierlists.js`
|
||||
- 관리자 라우트: `backend/src/routes/admin.js`
|
||||
|
||||
39
docs/spec.md
39
docs/spec.md
@@ -9,7 +9,7 @@
|
||||
- 운영 배포: `frontend(Nginx 정적 서빙 + /api,/uploads 프록시) + backend + mariadb` Docker Compose 구조
|
||||
- NAS HTTPS 리버스 프록시 운영 시 프런트 Nginx는 백엔드로 `X-Forwarded-Proto: https`를 전달하고, Express 세션은 프록시 환경에서 `secure` 쿠키를 허용하도록 설정한다.
|
||||
- 프런트 파비콘은 운영 정적 파일 차단 영향을 줄이기 위해 `index.html`의 인라인 데이터 URL로 제공한다.
|
||||
- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, preview 모드에서는 이 셸을 숨기고 콘텐츠만 렌더링한다.
|
||||
- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, `preview=1` 모드에서도 같은 셸을 유지한 채 중앙 본문만 완성본 프리뷰로 렌더링한다.
|
||||
- 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 우측 패널은 상단 토글 버튼으로 접고 펼칠 수 있다.
|
||||
- 좌측 패널은 필요 시 축소형 레일로 접을 수 있으며, 접힌 상태에서는 아이콘 중심 내비게이션과 축약된 바로가기만 유지한다.
|
||||
- 이 3단 셸 구조는 홈, 게임 허브, 에디터, 관리자 등 일반 페이지 전반의 공통 뼈대로 유지하고, 페이지별 차이는 중앙/우측에 어떤 콘텐츠를 넣는지만 달라지도록 관리한다.
|
||||
@@ -45,6 +45,7 @@
|
||||
- 좌우 레일의 주요 CTA는 스크롤되는 본문과 분리된 하단 `56px` 액션 영역에 배치한다.
|
||||
- 하단 액션은 화면 바닥에 바로 붙지 않도록 푸터 내부에 추가 하단 여백을 둔다.
|
||||
- 홈 화면 기준 우측 패널은 임시 정보 카드 여러 개보다 핵심 CTA 하나만 남겨, 시안처럼 단순한 보조 레일 역할을 우선 유지한다.
|
||||
- 광고 영역은 상단 헤더와 시각적으로 너무 붙지 않도록 `78px` 상단 여백을 두고, 하단 카피라이트는 중앙 정렬된 공통 footer로 표시한다.
|
||||
- 티어표 편집 화면
|
||||
- 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다.
|
||||
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 보드 영역은 메인 컬럼에, 우측 `320px` 편집 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
|
||||
@@ -54,7 +55,7 @@
|
||||
- 관리자 화면
|
||||
- 공통 우측 패널 대신 전용 로컬 운영 패널을 사용한다.
|
||||
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 관리 목록은 메인 컬럼에, 우측 운영 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
|
||||
- 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다. 템플릿 요청 카드는 전체 티어표 카드와 같은 썸네일 좌측/정보 우측 구조를 따르며, 요청 미리보기는 일반 티어표 완성본과 같은 행·열 보드 문법으로 검수한다.
|
||||
- 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다. 템플릿 요청 카드는 전체 티어표 카드와 같은 썸네일 좌측/정보 우측 구조를 따르며, 요청 미리보기와 `preview=1` 공유 프리뷰는 공통 앱 셸 안에서 일반 티어표 완성본과 같은 행·열 보드 문법으로 검수한다.
|
||||
- 상단 헤더에는 현재 탭 기준 요약 통계 카드를 배치해 운영 상태를 먼저 읽고, 각 관리 카드는 공통 대시보드 카드 문법(두꺼운 반경, 얕은 레이어 배경, 강조된 액션 버튼)을 공유한다.
|
||||
|
||||
## DB 스키마
|
||||
@@ -114,12 +115,12 @@
|
||||
- `GET /api/auth/me`
|
||||
- `GET /api/auth/meta`
|
||||
- `POST /api/auth/profile`
|
||||
- 게임
|
||||
- `GET /api/games`
|
||||
- `GET /api/games/:gameId`
|
||||
- 주제
|
||||
- `GET /api/topics`
|
||||
- `GET /api/topics/:topicId`
|
||||
- 티어표
|
||||
- `GET /api/tierlists/public`
|
||||
- `gameId` 없이 `q`만 전달하면 전체 공개 티어표 검색에 사용한다.
|
||||
- `topicId` 없이 `q`만 전달하면 전체 공개 티어표 검색에 사용한다.
|
||||
- `GET /api/tierlists/me`
|
||||
- `GET /api/tierlists/favorites/me`
|
||||
- `GET /api/tierlists/:id`
|
||||
@@ -131,17 +132,18 @@
|
||||
- `POST /api/tierlists/custom-items`
|
||||
- `POST /api/tierlists`
|
||||
- 관리자
|
||||
- `POST /api/admin/games`
|
||||
- `POST /api/admin/games/:gameId/thumbnail`
|
||||
- `POST /api/admin/games/:gameId/images`
|
||||
- `POST /api/admin/templates`
|
||||
- `POST /api/admin/templates/:templateId/thumbnail`
|
||||
- `POST /api/admin/templates/:templateId/images`
|
||||
- 여러 이미지를 한 번에 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다.
|
||||
- `PATCH /api/admin/games/:gameId/items/:itemId`
|
||||
- `PATCH /api/admin/templates/:templateId/items/:itemId`
|
||||
- `GET /api/admin/tierlists`
|
||||
- `GET /api/admin/template-requests`
|
||||
- `POST /api/admin/template-requests/:requestId/approve`
|
||||
- `POST /api/admin/template-requests/:requestId/reject`
|
||||
- `POST /api/admin/template-requests/:requestId/link-template`
|
||||
- `POST /api/admin/tierlists/:tierListId/promote-items`
|
||||
- `POST /api/admin/tierlists/:tierListId/create-game-template`
|
||||
- `POST /api/admin/tierlists/:tierListId/create-template`
|
||||
- `GET /api/admin/custom-items`
|
||||
- `POST /api/admin/custom-items/:itemId/promote`
|
||||
- `DELETE /api/admin/custom-items/:itemId`
|
||||
@@ -150,8 +152,8 @@
|
||||
- `PATCH /api/admin/users/:userId`
|
||||
- `PATCH /api/admin/users/:userId/password`
|
||||
- `DELETE /api/admin/users/:userId`
|
||||
- `DELETE /api/admin/games/:gameId/items/:itemId`
|
||||
- `DELETE /api/admin/games/:gameId`
|
||||
- `DELETE /api/admin/templates/:templateId/items/:itemId`
|
||||
- `DELETE /api/admin/templates/:templateId`
|
||||
|
||||
## 관리자 화면 메모
|
||||
- 썸네일은 16:9 비율 미리보기 후 `썸네일 적용` 버튼으로 실제 반영한다.
|
||||
@@ -171,11 +173,17 @@
|
||||
- 관리자 티어표 관리 상단에는 사용자가 보낸 템플릿 등록/업데이트 요청 목록이 별도로 표시되며, 여기서 승인/반려를 바로 처리할 수 있다.
|
||||
- 관리자 템플릿 요청 목록에서 `반려 후 숨김`을 누르면 해당 요청은 pending 목록에서 즉시 제외된다.
|
||||
- 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다.
|
||||
- 단, 일반 운영자는 최고 관리자 계정의 프로필 이미지/회원 정보/비밀번호/삭제 버튼을 사용할 수 없고, 최고 관리자만 다른 관리자 권한을 변경할 수 있다.
|
||||
- 관리자 회원 정보 수정은 운영상 필요한 경우 예약어 닉네임도 저장할 수 있지만, 일반 회원가입과 개인 프로필 수정에서는 운영자 사칭성 예약어 닉네임을 계속 차단한다.
|
||||
- 회원 관리 카드에는 아바타, 작성 티어표 수, 최근 활동 시각을 함께 표시한다.
|
||||
|
||||
## 티어표 접근 메모
|
||||
- `new` 작성 경로는 로그인한 사용자만 진입할 수 있다.
|
||||
- 비로그인 사용자는 공개된 티어표를 열람만 할 수 있고, 편집 UI와 저장 동작은 비활성화된다.
|
||||
- 공유 링크로 여는 `preview=1` 화면은 `뷰어 모드`로 취급하며, 드래그/행열 편집/저장 같은 편집 UI 없이 완성본만 렌더링한다.
|
||||
- 비로그인 사용자나 작성자 본인이 아닌 로그인 사용자는 저장된 티어표를 기본적으로 뷰어 모드로 열람하며, 일반 편집 URL로 직접 진입해도 소유자가 아니면 `preview=1` 주소로 자동 전환된다.
|
||||
- 비로그인 사용자도 뷰어 모드 우측 레일의 `공유하기` 버튼으로 현재 공유 링크를 복사할 수 있다.
|
||||
- 로그인한 타인 티어표 열람자는 뷰어 모드 우측 레일에서 `내 티어표로 복사`를 사용할 수 있고, 작성자 본인은 `수정 모드로 전환`을 사용할 수 있다.
|
||||
- 작성자 본인이 일반 편집 화면에서 저장된 본인 티어표를 보고 있을 때는 우측 패널의 `뷰어 모드로 보기`로 공유 화면 형태를 바로 확인할 수 있다.
|
||||
- 비공개 티어표라도 관리자는 편집 화면에서 완성본을 열람할 수 있다.
|
||||
- 공개 티어표는 목록과 상세 화면에서 즐겨찾기 토글 및 개수를 함께 표시한다.
|
||||
- 카드형 목록에서는 즐겨찾기 수/상태만 표시하고, 실제 토글은 상세 화면에서 처리한다.
|
||||
@@ -185,6 +193,8 @@
|
||||
- 사용자가 직접 추가한 커스텀 아이템 이름은 편집 화면 우측 목록에서 정리할 수 있고, 저장 시 원본 커스텀 아이템 라벨과 함께 동기화된다.
|
||||
- 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다.
|
||||
- 티어 행에 넣은 아이템은 작은 제거 버튼으로 다시 우측 아이템 풀로 되돌릴 수 있다.
|
||||
- 기존 저장 티어표/복사본을 수정 가능한 상태로 다시 열 때는 저장본의 그룹/풀을 먼저 복원하고, 이후 현재 템플릿에 새로 추가된 기본 아이템만 미사용 풀 끝에 병합한다.
|
||||
- 관리자 템플릿에서 삭제된 기본 아이템이라도 이미 저장된 티어표의 그룹/풀에 포함되어 있던 항목은 자동 제거하지 않고 그대로 보존한다.
|
||||
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
|
||||
- 제목이 비어 있는 상태로 저장하면 내부 제목은 현재 게임명을 기본값으로 사용한다.
|
||||
- 제목 입력이 비어 있는 동안에는 무분별한 도배 방지를 위한 관리자 임의 삭제 가능성 안내 문구를 표시한다.
|
||||
@@ -196,6 +206,7 @@
|
||||
- 티어표에 썸네일을 직접 지정하지 않으면 저장 시 대표 아이템 이미지 하나를 기본 썸네일로 자동 선택한다.
|
||||
- 편집 화면 상단 헤더는 좌측 제목/설명, 우측 썸네일 카드 구조를 사용하며 모바일에서는 한 열로 접힌다.
|
||||
- 티어표 편집 화면의 우측 패널은 공통 `rightRail`의 `localRightRailRoot`에 직접 section들을 쌓는 구조이며, 별도 외곽 래퍼 카드 없이 공통 오른쪽 컬럼 문법을 그대로 따른다.
|
||||
- 뷰어 모드 우측 패널도 같은 `localRightRailRoot`를 사용하며, 상단에는 광고 블록을, 하단에는 공유/복사/수정 전환 액션 카드를 배치한다.
|
||||
- 공통 앱 셸은 좌측/중앙/우측 컬럼마다 높이 `56px`의 상단 헤더 블록을 유지하며, 중앙 헤더에는 사이트 타이틀 `Tier Maker`와 현재 서비스 설명을 표시한다.
|
||||
- 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다.
|
||||
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
|
||||
|
||||
35
docs/todo.md
35
docs/todo.md
@@ -1,8 +1,37 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 단기 확인
|
||||
- `v1.4.40`에서 `preview=1` 공유 화면을 뷰어 모드로 정리했으므로, 비로그인/로그인한 타인/작성자 본인 세 경우에 드래그 편집이 막히고 오른쪽 레일 버튼이 각각 `공유하기`, `내 티어표로 복사`, `수정 모드로 전환` 조건대로 노출되는지 확인한다. 특히 비로그인/타인이 일반 편집 URL로 직접 들어왔을 때도 자동으로 `preview=1`로 바뀌는지 본다.
|
||||
- 작성자 본인 편집 화면에는 `뷰어 모드로 보기`가 추가됐으므로, 저장된 본인 티어표에서 뷰어 모드로 진입한 뒤 다시 `수정 모드로 전환`으로 돌아오는 왕복 라우팅이 자연스러운지 QA한다.
|
||||
- 뷰어 모드 오른쪽 레일이 공통 로컬 레일 마운트를 다시 사용하게 바뀌었으므로, 데스크톱/태블릿 폭에서 광고가 상단에 나오고 액션 카드가 하단으로 내려가며, 우측 레일 접기/펼치기 시 콘텐츠가 깨지지 않는지 확인한다.
|
||||
- `v1.4.39`에서 기존 저장 티어표/복사본을 다시 열 때 최신 템플릿 기본 아이템이 미사용 풀로 합류하도록 바꿨으므로, `12345`로 저장한 티어표를 만든 뒤 관리자 템플릿에 `6789`를 추가하고 다시 열거나 복사했을 때 `6789`만 미사용 상태로 나타나는지 확인한다.
|
||||
- 같은 정책에서 관리자 템플릿에서 삭제한 기존 아이템은 과거 저장 티어표의 그룹/풀 안에 남아 있으면 계속 보존되어야 하므로, `5`번을 삭제해도 이미 사용한 옛 티어표에서 사라지지 않는지 다시 확인한다.
|
||||
- `v1.4.39`에서 주제별 공개 티어표 화면을 `pageWrap`으로 감쌌으므로, 좁은 브라우저 폭에서 검색창이 아래 줄로 내려온 상태에서도 검색창과 카드 목록 사이 간격이 홈/즐겨찾기 화면과 비슷하게 유지되는지 확인한다.
|
||||
- `v1.4.38`에서 운영자 계정의 최고 관리자 관리 버튼을 프런트에서 비활성화했으므로, 최고 관리자/일반 운영자/일반 회원 세 계정 조합으로 회원 정보 수정, 썸네일 변경, 비밀번호 초기화, 삭제 버튼 활성 상태와 서버 차단 응답이 기대대로 맞는지 확인한다.
|
||||
- 관리자 회원 정보 수정에서는 예약어 닉네임을 허용하도록 예외를 열었으므로, 일반 회원가입/개인 프로필 수정은 여전히 예약어가 막히고 관리자 수정만 저장되는지 한 번 더 QA한다.
|
||||
- `preview=1` 화면이 공통 앱 셸을 그대로 쓰도록 바뀌었으므로, 좌측 레일 접기/펼치기, 우측 레일 닫기/열기, 중앙 헤더 타이틀, 메인 배경색, 오른쪽 광고와 카피라이트 정렬이 홈 화면과 같은 문법으로 보이는지 비교 QA한다.
|
||||
- 프리뷰 우측 광고에 `padding-top: 78px`를 넣었으므로, 데스크톱/태블릿 폭에서 광고 시작 위치가 너무 내려가거나 모바일 오버레이 레이아웃에서 어색해지지 않는지 확인한다.
|
||||
- `v1.4.33`에서 회원가입에 닉네임 입력과 중복/예약어 검사를 붙였으므로, 실제 QA에서는 이메일 중복, 닉네임 중복, 예약 닉네임, 프로필 닉네임 변경, 관리자 회원 수정 흐름이 같은 규칙으로 막히는지 확인한다.
|
||||
- 테마는 저장값이 없을 때 무조건 다크로 시작하게 바꿨고 설정 화면 토글도 다시 열었으므로, 첫 접속/새 브라우저/다른 운영체제에서 기본 다크 시작과 수동 토글 저장이 그대로 정상인지 확인한다.
|
||||
- 관리자 템플릿 썸네일 드롭존 빈 상태 아이콘 제거와 아이템 상세 모달 썸네일 프리뷰가 들어갔으므로, 관리자 화면에서 썸네일 교체와 아이템 선택 모달 가독성을 한 번 더 QA한다.
|
||||
- `v1.4.32`에서 파일명·composable·관리자 클래스명·백엔드 헬퍼 함수명까지 `topic/template` 기준으로 끝까지 정리했으므로, 다음 실제 QA는 기능 동작 확인에 집중하고 이름층 회귀는 별도 체크만 하면 된다.
|
||||
- 현재 `backend/src`, `frontend/src`, `backend/scripts`, `backend/index.js` 기준 `game/Game` 검색은 0건이므로, 이후 남는 확인 작업은 서비스 동작과 배포 환경 쪽에만 집중한다.
|
||||
- `v1.4.31`에서 `/games` redirect와 legacy DB 마이그레이션까지 제거했으므로, 실제 QA에서는 오직 현재 주소(`/topics`, `/admin/templates`)와 새 DB 기준 흐름만 집중적으로 확인하면 된다.
|
||||
- 현재 `backend/src`, `frontend/src` 기준 `game` 검색은 0건이므로, 이후 남는 확인 작업은 기능 QA와 운영 환경 배포 점검 쪽에만 집중한다.
|
||||
- `v1.4.30`에서 빈 로컬 MariaDB 재초기화 검증까지 통과했으므로, 다음 실제 QA에서는 “기존 데이터가 있는 환경”에서 `ensureData()`가 저장 티어표와 템플릿 요청 스냅샷의 legacy origin을 정상 정규화하는지만 추가 확인하면 된다.
|
||||
- 개발 환경 기준으로는 새 DB에서 `legacyTierItems=0`, `legacyRequestItems=0`가 확인됐으므로, 이후에는 `origin: 'game'` 호환 코드를 언제 완전히 제거할지 운영 데이터 기준으로만 판단하면 된다.
|
||||
- `v1.4.29`에서 새 티어표 데이터 기본 origin을 `template`로 바꿨으므로, 저장 후 다시 열기/복사/요청 생성/관리자 가져오기 흐름에서 예전 데이터와 새 데이터가 함께 섞여도 정상 동작하는지 한 번 더 확인한다.
|
||||
- 관리자 아이템 라이브러리 응답 키가 `linkedTemplates`로 정리됐으므로, 사용자 업로드 이미지 삭제 차단과 템플릿 이동 모달이 그대로 정상 동작하는지 확인한다.
|
||||
- 현재 남아 있는 `game`는 레거시 redirect, DB 마이그레이션, 호환용 origin만 남겨둔 상태이므로, `v1.4` QA 후에는 이 레거시 층을 언제 제거할지 별도 마감 판단만 하면 된다.
|
||||
- `v1.4.28`에서 관리자 템플릿 상세 상태와 기본 아이템 정렬 상태 이름을 `template` 기준으로 더 정리했으므로, 관리자 템플릿 선택/공개 전환/기본 아이템 정렬 저장이 그대로 정상인지 한 번 더 확인한다.
|
||||
- 새 템플릿 썸네일/기본 아이템 업로드는 이제 `topics` 디렉터리로 저장되므로, 실제 업로드 후 최적화 작업 분류와 관리자 최근 작업 표시가 자연스럽게 보이는지 확인한다.
|
||||
- 현재 코드 검색에 남는 `game`는 레거시 redirect, DB 마이그레이션, `origin: 'game'` 호환이 중심이므로, 이 층까지 실제로 없앨지 여부는 `v1.4` QA 후 안정성 기준으로 다시 판단한다.
|
||||
- `v1.4.27`에서 관리자 내부 탭/라우트 이름과 DB alias export까지 더 정리했으므로, 관리자 템플릿 탭 이동, 커스텀 아이템에서 템플릿 관리로 점프, 템플릿 요청 확인하기 이동이 모두 정상인지 한 번 더 확인한다.
|
||||
- `v1.4.26`에서 관리자 기본 경로를 `/admin/templates`로 바꾸고 `/api/admin/templates`만 남겼으므로, 관리자 진입/새로고침/뒤로가기와 템플릿 생성·썸네일 업로드·아이템 추가가 모두 정상인지 확인한다.
|
||||
- `v1.4.26`에서 공개 API `/api/games`를 제거했으므로, 실제 서버 재시작 후 홈/주제 상세/티어표 편집기에서 `/api/topics`만으로 모두 정상 동작하는지 확인한다.
|
||||
- 오래된 관리자 주소 `/admin/games`는 redirect만 남겼으므로, 북마크로 직접 진입해도 `/admin/templates`로 자연스럽게 바뀌는지 본다.
|
||||
- `v1.4.25`에서 티어표/요청 응답의 `gameId / gameName` 호환 키를 실제로 제거했으므로, 브라우저에서 홈 목록, 주제 상세, 저장된 티어표 열기, 즐겨찾기, 검색 결과, 관리자 템플릿 요청/전체 티어표 관리가 모두 정상 동작하는지 한 번 더 QA한다.
|
||||
- `v1.4.25`에서 관리자 route query와 편집기 저장/request payload를 `topicId` 기준으로 옮겼으므로, `/admin/games?topicId=...`, `/admin/tierlists?mode=all&topicId=...`, 티어표 저장, 템플릿 요청, 추가 아이템 가져오기 흐름이 모두 정상인지 확인한다.
|
||||
- `v1.4.25`에서 관리자 route query와 편집기 저장/request payload를 `topicId` 기준으로 옮겼으므로, `/admin/templates?topicId=...`, `/admin/tierlists?mode=all&topicId=...`, 티어표 저장, 템플릿 요청, 추가 아이템 가져오기 흐름이 모두 정상인지 확인한다.
|
||||
- 남은 `gameId`는 의도적으로 유지한 레거시 주소 alias(`/games/:gameId`)와 관리자 alias route path뿐이므로, 오래된 외부 링크 진입 후 주소가 새 `topic` 체계로 자연스럽게 정규화되는지만 마지막으로 본다.
|
||||
- `v1.4.24`에서 공개 주제 API와 관리자 템플릿 API의 기본 응답 키를 더 줄였으므로, 실제 브라우저에서 홈 목록, 즐겨찾기 토글, 주제 상세, 티어표 편집기, 관리자 템플릿 공개 전환/생성이 모두 그대로 정상인지 한 번 더 QA한다.
|
||||
- 다음 단계에서는 `mapTierListRow`, `mapTemplateRequestRow`, 관리자 route query, 저장 payload 입력 호환에 남아 있는 `gameId/gameName/sourceGameId/targetGameId`를 끝까지 걷어낼지 최종 결정한다.
|
||||
@@ -30,7 +59,6 @@
|
||||
- 프런트 API 호출부는 `topic/template` 의미 이름으로 옮겼으므로, 다음 단계에서는 `api.js` 안의 레거시 alias를 얼마나 더 유지할지와 백엔드 API 경로를 실제로 바꿀지 범위를 정한다.
|
||||
- 관리자 템플릿/주제 화면과 홈·에디터·즐겨찾기에서 새 API 이름층으로 바뀐 뒤에도 저장, 즐겨찾기, 템플릿 생성, 아이템 정렬 흐름이 자연스러운지 한 번 더 QA한다.
|
||||
- `topicHub / topicId`를 기본 라우트 기준으로 세웠으므로, 기존 `/games/...` 북마크와 새 `/topics/...` 주소 양쪽에서 주제 상세와 에디터 진입이 모두 자연스럽게 이어지는지 한 번 더 QA한다.
|
||||
- 다음 단계에서는 `api.getGame`, `listGames`, `favoriteGame`처럼 남아 있는 프런트 API 이름을 어느 수준까지 `topic/template` 의미로 감쌀지 정리한다.
|
||||
- 경로 헬퍼를 사용자 주요 화면에 연결했으므로, 로그인 리다이렉트·공유 프리뷰·복사본 이동·주제 복귀 흐름이 실제 브라우저에서 모두 같은 주소 체계로 자연스럽게 이어지는지 한 번 더 QA한다.
|
||||
- 다음 단계에서는 `router/index.js`의 `gameHub`, `route.params.gameId`, `api.getGame`처럼 남아 있는 프런트 내부 이름도 어디까지 `topic/template` 의미로 감쌀지 범위를 더 좁힌다.
|
||||
- 주제 상세 화면 제목은 ID fallback 대신 로딩 문구를 쓰도록 바꿨으므로, 직접 진입·뒤로가기·다른 주제로 연속 이동할 때 헤더가 깜빡이거나 이전 제목을 잠깐 유지하지 않는지 한 번 더 QA한다.
|
||||
@@ -95,6 +123,7 @@
|
||||
- 관리자 게임 아이템 순서 저장은 추가됐으므로, 다음 단계에서는 새 아이템 추가 직후 `자동 맨 앞 배치`와 `관리자 수동 고정 순서`의 우선순위를 실제 운영 흐름 기준으로 한 번 더 QA한다.
|
||||
- 관리자 템플릿 요청 미리보기는 실제 완성본 iframe 방식과의 체감 차이를 마지막으로 한 번 더 QA한다.
|
||||
- 라이트모드/다크모드 2차 보정까지 반영했으므로, 남은 작업은 전체 화면을 실제 사용 흐름으로 돌려 보며 대비·명도·아이콘 가독성을 미세하게 QA하는 최종 테마 점검 단계로 가져간다.
|
||||
- 라이트모드 공통 토큰 재정비와 카드/아바타/즐겨찾기 버튼 보정까지 반영했으므로, 다음 QA에서는 로그인/홈/주제 허브/에디터/관리자 순으로 실제 플로우를 돌리며 남은 하드코딩 색과 과한 대비가 없는지 확인한다.
|
||||
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
|
||||
- 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다.
|
||||
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
|
||||
@@ -105,6 +134,8 @@
|
||||
- 책 아이콘 기반 사용법 모달은 제작 흐름뿐 아니라 복사, 템플릿 업데이트 요청, 새 템플릿 요청까지 확장했으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다.
|
||||
- 관리자 아이템 라이브러리에서 동일 이미지(src)를 여러 템플릿이 공유하는 경우, 필요하면 묶어서 보거나 대표 카드로 합쳐 보는 후속 정리 옵션을 검토한다.
|
||||
- 라이트모드 최종 QA 시 홈/설정/관리자/에디터를 실제 사용 흐름으로 돌리며, 남아 있는 하드코딩 텍스트 색과 플레이스홀더 배경을 한 번 더 점검한다.
|
||||
- 템플릿 기본 아이템 다중 업로드는 8개까지 성공, 9개 이상 한 번에 전송 시 실패하는 사례가 있었으므로 NAS/리버스 프록시의 업로드 body 제한(`client_max_body_size` 등)과 실제 응답 코드를 운영 환경에서 확인한다.
|
||||
- 프리뷰 우측 광고 레일을 붙였으므로, 실제 운영 환경에서 광고가 로드될 때 프리뷰 본문 폭이 과하게 줄지 않는지 데스크톱 기준으로 한 번 더 확인한다.
|
||||
- 관리자 아이템 라이브러리는 보관 자산까지 노출되므로, 이후에는 `활성 템플릿 / 보관 자산` 분리 필터나 그룹 보기까지 검토한다.
|
||||
- 가이드 모달과 관리자 아이템 모달은 현재 같은 톤의 큰 셸을 쓰므로, 이후 공통 모달 레이아웃 컴포넌트로 분리할지 검토한다.
|
||||
- 관리자 아이템 라이브러리 이름 변경은 템플릿·사용자 업로드·보관 자산까지 모두 가능하므로, 이후에는 일괄 이름 정리나 중복 이름 감지 보조 기능까지 검토한다.
|
||||
|
||||
@@ -1,5 +1,65 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-03 v1.4.40
|
||||
- 공유 링크로 여는 `preview=1` 화면을 `뷰어 모드`로 정의하고, 드래그/편집 없이 완성본만 보이는 상태에서 오른쪽 레일 상단에는 광고, 하단에는 공유·복사·수정 전환 액션을 노출하도록 정리했다.
|
||||
- 비로그인 사용자나 작성자 본인이 아닌 사용자가 일반 편집 URL로 저장된 티어표를 직접 열어도 자동으로 `preview=1` 뷰어 모드 주소로 전환되도록 로딩 후 라우팅을 보정했다.
|
||||
- 비로그인 사용자도 뷰어 모드에서 `공유하기` 버튼을 사용할 수 있고, 로그인한 타인 티어표는 `내 티어표로 복사`, 작성자 본인 티어표는 `수정 모드로 전환`을 사용할 수 있게 권한별 액션을 분기했다.
|
||||
- 작성자 본인이 수정 화면에 있을 때는 우측 패널에 `뷰어 모드로 보기`를 추가해, 본인도 공유 화면과 같은 뷰어 모드를 바로 확인할 수 있게 했다.
|
||||
- 뷰어 모드에서도 에디터 로컬 오른쪽 레일을 마운트하도록 공통 앱 셸 조건을 보정해, 광고와 액션 카드가 실제 우측 패널에 안정적으로 렌더링되게 맞췄다.
|
||||
|
||||
## 2026-04-03 v1.4.39
|
||||
- 기존 저장 티어표/복사본을 수정 가능한 상태로 다시 열 때, 저장본에 없던 최신 템플릿 기본 아이템만 미사용 풀 맨 뒤에 자동 합류하도록 병합 로딩을 추가했다.
|
||||
- 관리자 템플릿에서 삭제된 기본 아이템이라도 이미 저장된 티어표의 그룹/풀에 남아 있는 항목은 그대로 보존해, 과거 결과물이 템플릿 정리 때문에 깨지지 않도록 유지했다.
|
||||
- 주제별 공개 티어표 목록 화면은 좁은 브라우저 폭에서 상단 검색 툴바가 아래 줄로 내려오면 카드 목록과 간격이 붙어 보일 수 있었으므로, 홈/즐겨찾기 화면과 같은 `pageWrap` 구조로 감싸 상단 영역과 목록 사이 여백을 유지하도록 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.38
|
||||
- 관리자 회원 관리에서 운영자 계정으로는 최고 관리자 계정의 썸네일 변경, 비밀번호 초기화, 회원 삭제, 회원 정보 수정 버튼이 비활성화되도록 프런트 보호를 추가했고, 자기 자신 삭제 버튼도 함께 막았다.
|
||||
- 관리자 회원 정보 수정에서는 운영자/관리자 예약어가 들어간 닉네임도 저장할 수 있도록 서버 검증 예외를 분리했고, 일반 회원가입과 개인 프로필 수정의 예약어 차단은 그대로 유지했다.
|
||||
- `preview=1` 티어표 화면은 별도 프리뷰 셸을 걷어내고 공통 좌측 레일·중앙 헤더·우측 레일을 그대로 쓰도록 바꿨으며, 프리뷰 본문 제목도 홈 화면과 같은 `pageHead` 문법으로 맞췄다.
|
||||
- 프리뷰/일반 화면의 오른쪽 광고 레일은 같은 공통 레일 footer를 사용하게 되어 카피라이트 중앙 정렬이 맞춰졌고, 애드센스 영역 상단에는 `padding-top: 78px`를 적용해 시각적 시작점을 조금 아래로 내렸다.
|
||||
- 로컬에서 생성되는 `backend/uploads/assets/` 최적화 이미지가 Git 변경분에 섞이지 않도록 `.gitignore` 제외 경로에 추가했다.
|
||||
|
||||
## 2026-04-02 v1.4.33
|
||||
- 회원가입 시 닉네임 입력을 함께 받도록 바꾸고, 이메일 중복과 닉네임 중복을 서버에서 명확히 차단하도록 정리했다.
|
||||
- `admin`, `운영자`, `관리자`, `official`, `zenn`처럼 운영자·공식 계정으로 오해될 수 있는 닉네임은 예약어로 막고, 프로필 수정/관리자 회원 수정에서도 같은 규칙을 공유하도록 맞췄다.
|
||||
- 로그인·회원가입 화면은 중복된 이메일/닉네임일 때 빨간색 오류 메시지를 바로 보여주도록 보강했고, 테마는 저장값이 없을 때 무조건 다크로 시작하면서 설정 화면에서만 라이트/다크 토글을 다시 노출하도록 정리했다.
|
||||
- 관리자 템플릿 썸네일 드롭존의 빈 상태 아이콘은 제거했고, 아이템 상세 모달에는 선택한 썸네일 프리뷰를 추가해 현재 선택한 이미지가 더 잘 보이게 했다.
|
||||
|
||||
## 2026-04-02 v1.4.32
|
||||
- 파일명과 내부 심볼 이름까지 `topic/template` 기준으로 마감했다. `GameHubView`는 `TopicHubView`, `AdminGamesSection`은 `AdminTemplatesSection`, `useAdminGameManager`와 `useAdminFeaturedGames`는 각각 `useAdminTemplateManager`, `useAdminFeaturedTemplates`로 정리했다.
|
||||
- 관리자 화면 내부 상태와 스타일 클래스도 `adminTemplatePicker`, `templateManagerGrid`, `templateSettingsCard` 기준으로 바꿔, 사용자에게는 안 보이지만 코드 검색에서 남던 `Game` 흔적을 더 걷어냈다.
|
||||
- 백엔드도 `copyUploadIntoTopicAsset`, `mapTopicRow`, `mapTopicItemRow`처럼 내부 함수명을 맞추고, 업로드 디렉터리/정리 스크립트도 `topics` 기준으로 통일해 `backend/src`, `frontend/src`, `backend/scripts`, `backend/index.js` 범위의 `game/Game` 검색 결과를 0건으로 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.31
|
||||
- 서비스가 아직 공개 전이고 예전 링크/예전 DB를 이어갈 필요가 없다는 전제에 맞춰, `/games` redirect와 관리자 `/admin/games` redirect, DB 레거시 마이그레이션 코드, legacy origin 정규화 코드를 실제로 제거했다.
|
||||
- 티어표 저장/request schema도 이제 `origin: 'template' | 'custom'`만 받도록 정리했고, 관리자 최근 최적화 작업 분류 fallback에 남아 있던 `games` 처리도 걷어냈다.
|
||||
- seed 데이터 ID까지 `example-topic`, `another-topic` 기준으로 바꿔, 현재 `backend/src`와 `frontend/src` 코드 검색에서 `game` 흔적이 0건인 상태까지 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.30
|
||||
- `ensureData()` 단계에서 저장된 티어표 `pool_json`과 템플릿 요청 스냅샷(`items_json`, `board_items_json`) 안에 남아 있을 수 있는 `origin: 'game'` 값을 자동으로 `template`로 정리하도록 보강했다.
|
||||
- 로컬 MariaDB를 비운 뒤 현재 스키마로 다시 올리는 검증도 함께 진행했고, 새 DB 기준으로 `topics=3`, `tierlists=0`, `legacyTierItems=0`, `legacyRequestItems=0` 상태까지 확인했다.
|
||||
- 즉 현재 개발 환경에서는 새로 생성되거나 다시 초기화한 데이터에 `game` 기반 origin이 남지 않으며, 남은 `game` 코드는 레거시 redirect·DB 마이그레이션 감지·과거 데이터 호환층만 담당하게 됐다.
|
||||
|
||||
## 2026-04-02 v1.4.29
|
||||
- 티어표 저장/request schema는 이제 새 데이터에서 `origin: 'template'`를 기본으로 쓰고, 예전 `origin: 'game'`도 계속 읽을 수 있게 호환 레이어를 남겼다.
|
||||
- 관리자 아이템 라이브러리의 템플릿 연결 정보도 `linkedTemplates` 기준으로 정리해, 내부 응답/프런트 상태에 남아 있던 `linkedGames` 흔적을 제거했다.
|
||||
- 현재 `game` 검색에 남는 것은 레거시 주소 redirect, DB 마이그레이션용 legacy 테이블/컬럼명, 과거 저장 데이터 호환용 `origin: 'game'`처럼 의도적으로 남겨둔 층만 남도록 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.28
|
||||
- 관리자 템플릿 상세 상태(`selectedTemplate.game`)와 관련 응답 키를 `template` 기준으로 정리해, 내부 코드 검색에서 남던 `game` 흔적을 더 줄였다.
|
||||
- 관리자 기본 아이템 정렬/로딩 상태 이름도 `templateItem*`, `isTemplateLoading`, `templateVisibilitySaving` 기준으로 바꾸고, 새 템플릿 자산 업로드는 `topics` 디렉터리로 저장되게 맞췄다.
|
||||
- 현재 코드 검색에서 남는 `game`는 주로 레거시 주소 redirect(`/games/:gameId`), DB 마이그레이션용 legacy 테이블/컬럼명, 기존 저장 데이터와 맞춘 `origin: 'game'` 값처럼 의도적으로 남겨둔 호환층만 남도록 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.27
|
||||
- 관리자 내부 탭/라우트 이름도 `template-admin`, `adminTemplates`, `/admin/templates` 기준으로 더 정리해, 화면 상태값과 라우트 이름에 남아 있던 `game-admin`, `adminGames` 흔적을 줄였다.
|
||||
- 더 이상 참조되지 않는 DB alias export(`listGames`, `createGame`, `favoriteGame` 등)와 `updateTemplateRequestTargetGame` 별칭도 제거해, 백엔드 모듈 표면에서 남아 있던 레거시 `game` 이름층을 더 걷어냈다.
|
||||
- 커스텀 아이템 모달 내부 클래스명도 `createTemplateButton` 기준으로 정리해, 관리자 코드 검색에서 남는 `createGame` 흔적을 줄였다.
|
||||
|
||||
## 2026-04-02 v1.4.26
|
||||
- 관리자 API 레거시 `/games` alias를 걷어내고 `POST /api/admin/templates`, `.../templates/:templateId/...`만 남기도록 정리했다. 관리자 템플릿 연결/가져오기 액션도 `link-template`, `create-template` path로 바꿨다.
|
||||
- 백엔드 공개 주제 라우트도 이제 `/api/topics`만 마운트하고, 오래된 `/api/games` 경로는 제거했다. 관리자 화면 URL 역시 `/admin/games` 대신 `/admin/templates`를 기본 경로로 쓰고, 예전 주소는 redirect만 남겼다.
|
||||
- 문서의 API/화면 매핑도 현재 구조 기준으로 갱신해, `games` 중심 설명 대신 `topics / templates` 기준으로 읽히게 맞췄다.
|
||||
|
||||
## 2026-04-02 v1.4.25
|
||||
- 티어표와 템플릿 요청 응답에서 `gameId / gameName / sourceGameId / targetGameId` 호환 키를 실제로 제거하고, 프런트 화면도 `topicId / topicName / sourceTopicId / targetTopicId`만 읽도록 정리했다.
|
||||
- 관리자 전체 티어표 관리와 템플릿 요청 관리, 나의 티어표/즐겨찾기/검색 결과 이동, 티어표 편집기 저장·요청 payload도 `topicId` 기준으로 맞춰, 화면과 요청 바디에서 보이는 `game` 흔적을 더 줄였다.
|
||||
@@ -1063,6 +1123,28 @@
|
||||
- **티어표 데이터 정규화**: 게임 이미지 경로가 절대 로컬 URL로 저장되지 않도록 저장/조회 시 `/uploads/...` 상대 경로로 정규화
|
||||
- **프로젝트 점검 결과 문서화**: DB 구조, 화면-파일 매핑, 코딩 규칙, 기술 명세, 남은 위험 요소를 `docs/`에 신규 정리
|
||||
|
||||
## 2026-04-03 v1.4.37
|
||||
- **썸네일 업로드 UX 보정**: 티어표 편집기 우측 `대표 썸네일` 프레임을 클릭/엔터/스페이스로 바로 파일 선택할 수 있게 바꾸고, 중복이던 `파일 업로드` 버튼은 제거
|
||||
|
||||
## 2026-04-03 v1.4.36
|
||||
- **자기 티어표 복사 허용**: 기존에는 타인의 저장본만 복사할 수 있었지만, 이제는 본인 티어표도 저장본이면 복사해서 일부만 수정한 새 버전으로 다시 작업할 수 있게 변경
|
||||
- **프리뷰 우측 레일 추가**: 공유 프리뷰 화면도 본 사이트 문법을 더 닮도록 우측에 300×600 광고 레일과 카피라이트를 붙이고, 모바일 폭에서는 자동으로 숨기도록 정리
|
||||
|
||||
## 2026-04-03 v1.4.35
|
||||
- **에디터 아이템 검색 추가**: 미배치 아이템이 많아졌을 때 바로 찾을 수 있도록 사이드바에 `아이템 이름 검색` 입력과 `표시 개수 / 전체 개수`를 추가
|
||||
- **검색 중 드래그 유지**: 아이템 풀 검색은 목록 순서를 바꾸지 않고 일치하지 않는 항목만 숨기는 방식으로 넣어, 검색 중에도 바로 드래그 배치할 수 있게 유지
|
||||
- **공유 프리뷰 유입선 보강**: 공유 링크 프리뷰 좌상단에 `Tier Maker` 로고 링크를 추가해, 미리보기에서 메인 화면으로 자연스럽게 돌아올 수 있게 함
|
||||
- **작성 시각 노출 축소**: 프리뷰와 이미지 저장 하단 메타 정보의 시간 표시를 제거하고 날짜까지만 남겨 개인 생활 패턴 노출을 줄임
|
||||
- **업로드 추적 로그 보강**: 관리자 템플릿 기본 아이템 업로드는 프런트/백엔드 양쪽에서 파일 수·총 용량·응답 상태를 콘솔에 남기도록 해, 다중 업로드 실패 원인을 다음 재현 때 바로 좁힐 수 있게 보강
|
||||
- **카피라이트 링크 변경**: 우측 레일 하단 카피라이트의 `zenn` 링크를 `https://x.com/zennbox`로 변경
|
||||
|
||||
## 2026-04-02 v1.4.34
|
||||
- **라이트모드 팔레트 재정비**: 공통 라이트 테마 색상을 회색 위주에서 더 정돈된 청회색 계열로 다시 잡고, 셸/레일/메인/카드 표면 대비를 처음부터 재조정
|
||||
- **공통 토큰 확장**: 강조색 강도, 강조 배경, 오버레이 스크림, 아바타 테두리, 즐겨찾기 버튼 상태색을 공통 변수로 분리해 화면별 하드코딩을 줄임
|
||||
- **홈 카드 보정**: 주제 카드 즐겨찾기 버튼이 라이트모드에서 검은 플로팅 버튼처럼 뜨던 문제를 테마 변수 기반으로 수정
|
||||
- **목록 카드 통일**: 주제 허브/나의 티어표/즐겨찾기/검색 결과 카드의 아바타 테두리를 공통 토큰으로 맞춰 라이트모드에서 카드 밀도가 덜 어색하게 보이도록 정리
|
||||
- **전역 셸 보정**: 백엔드 점검 안내 버튼과 가이드 모달 오버레이도 라이트모드에 맞는 공통 색상 체계로 통일
|
||||
|
||||
## 2026-03-19 v0.1.2
|
||||
- **로그인 UI 개선**: 로그인 카드 중앙 배치, 중복 타이틀 제거, 입력 overflow 수정, 엔터로 로그인/회원가입 제출
|
||||
- **안내문 조건화**: “첫 회원가입 계정은 admin” 문구는 유저가 0명일 때만 표시(`/api/auth/meta`)
|
||||
|
||||
@@ -22,7 +22,7 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const { toasts, dismissToast } = useToast()
|
||||
const RIGHT_RAIL_COPYRIGHT_URL = 'https://zenn.town/@murabito'
|
||||
const RIGHT_RAIL_COPYRIGHT_URL = 'https://x.com/zennbox'
|
||||
const currentTopicId = computed(() => route.params.topicId || '')
|
||||
|
||||
const leftRailCollapsed = ref(false)
|
||||
@@ -43,7 +43,9 @@ const authReady = computed(() => auth.hydrated)
|
||||
const isAdmin = computed(() => authReady.value && !!auth.user?.isAdmin)
|
||||
const isPreviewMode = computed(() => route.query.preview === '1')
|
||||
const isAdminRoute = computed(() => String(route.name || '').startsWith('admin'))
|
||||
const usesLocalRightRail = computed(() => ['editEditor', 'newEditor'].includes(String(route.name || '')) || isAdminRoute.value)
|
||||
const usesLocalRightRail = computed(
|
||||
() => ['editEditor', 'newEditor'].includes(String(route.name || '')) || isAdminRoute.value
|
||||
)
|
||||
const isRightRailOverlay = computed(() => viewportWidth.value <= 1200)
|
||||
const isMobileLayout = computed(() => viewportWidth.value <= 860)
|
||||
const avatarUrl = computed(() => (auth.user?.avatarSrc ? toApiUrl(auth.user.avatarSrc) : ''))
|
||||
@@ -76,7 +78,7 @@ const showRightRailAction = computed(() => false)
|
||||
const showSettingsGuideButton = computed(() => route.name === 'profile')
|
||||
const guideSteps = [
|
||||
{
|
||||
id: 'select-game',
|
||||
id: 'select-topic',
|
||||
title: '주제 또는 양식 선택',
|
||||
summary: '주제 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작합니다.',
|
||||
description:
|
||||
@@ -137,7 +139,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 +277,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') {
|
||||
@@ -424,7 +426,6 @@ function reloadApp() {
|
||||
<div
|
||||
class="appShell"
|
||||
:class="{
|
||||
'appShell--preview': isPreviewMode,
|
||||
'appShell--leftCollapsed': leftRailCollapsed,
|
||||
'appShell--rightClosed': !rightRailOpen,
|
||||
'appShell--rightOverlay': isRightRailOverlay,
|
||||
@@ -450,11 +451,6 @@ function reloadApp() {
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
<template v-else-if="isPreviewMode">
|
||||
<main class="appMain appMain--preview">
|
||||
<RouterView />
|
||||
</main>
|
||||
</template>
|
||||
<template v-else>
|
||||
<aside class="leftRail">
|
||||
<div class="leftRail__top railHeader">
|
||||
@@ -697,6 +693,7 @@ function reloadApp() {
|
||||
}
|
||||
|
||||
.backendFallback {
|
||||
min-width: 100dvw;
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -748,17 +745,13 @@ function reloadApp() {
|
||||
min-width: 128px;
|
||||
padding: 12px 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(98, 170, 255, 0.32);
|
||||
background: rgba(98, 170, 255, 0.18);
|
||||
border: 1px solid var(--theme-accent-soft-strong);
|
||||
background: var(--theme-accent-soft);
|
||||
color: var(--theme-text-strong);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.appShell--preview {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.leftRail,
|
||||
.rightRail {
|
||||
min-height: 100dvh;
|
||||
@@ -929,7 +922,7 @@ function reloadApp() {
|
||||
border-radius: 999px;
|
||||
object-fit: cover;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border: 1px solid var(--theme-avatar-border);
|
||||
background: var(--theme-surface-soft-3);
|
||||
}
|
||||
|
||||
@@ -1220,10 +1213,6 @@ function reloadApp() {
|
||||
border-right: 1px solid var(--theme-border);
|
||||
}
|
||||
|
||||
.appMain--preview {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-rows: 56px minmax(0, 1fr);
|
||||
@@ -1453,7 +1442,7 @@ function reloadApp() {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 20px;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
background: var(--theme-overlay-scrim);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ onMounted(async () => {
|
||||
.rightRailAd {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding-top: 78px;
|
||||
}
|
||||
|
||||
.rightRailAd__eyebrow {
|
||||
|
||||
@@ -9,12 +9,12 @@ const props = defineProps({
|
||||
stagedRequestDraftCount: { type: Number, required: true },
|
||||
appliedRequestItemCount: { type: Number, required: true },
|
||||
openTemplateCreateModal: { type: Function, required: true },
|
||||
isGameLoading: { type: Boolean, required: true },
|
||||
isTemplateLoading: { type: Boolean, required: true },
|
||||
hasSelectedTemplate: { type: Boolean, required: true },
|
||||
selectedTemplate: { type: Object, default: null },
|
||||
displayThumbnailUrl: { type: String, default: '' },
|
||||
canApplyThumbnail: { type: Boolean, required: true },
|
||||
gameVisibilitySaving: { type: Boolean, required: true },
|
||||
templateVisibilitySaving: { type: Boolean, required: true },
|
||||
thumbFileInputRef: { type: Function, required: true },
|
||||
openThumbFilePicker: { type: Function, required: true },
|
||||
onThumb: { type: Function, required: true },
|
||||
@@ -41,14 +41,14 @@ const props = defineProps({
|
||||
removeUploadDraft: { type: Function, required: true },
|
||||
hasTemplateItemOrderChanges: { type: Boolean, required: true },
|
||||
saveTemplateItemOrder: { type: Function, required: true },
|
||||
gameItemListRef: { type: Function, required: true },
|
||||
templateItemListRef: { type: Function, required: true },
|
||||
saveTemplateItemLabel: { type: Function, required: true },
|
||||
removeTemplateItem: { type: Function, required: true },
|
||||
selectedTemplateId: { type: String, default: '' },
|
||||
})
|
||||
|
||||
function setGameItemListElement(el) {
|
||||
props.gameItemListRef(el)
|
||||
function setTemplateItemListElement(el) {
|
||||
props.templateItemListRef(el)
|
||||
}
|
||||
|
||||
function setThumbFileElement(el) {
|
||||
@@ -102,15 +102,15 @@ function setThumbFileElement(el) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="props.isGameLoading" class="panel panel--empty">
|
||||
<div v-if="props.isTemplateLoading" class="panel panel--empty">
|
||||
<div class="emptyState">
|
||||
<div class="emptyState__title">템플릿 정보를 불러오는 중이에요.</div>
|
||||
<div class="emptyState__desc">선택한 템플릿의 썸네일과 기본 아이템을 곧 표시합니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="props.hasSelectedTemplate" class="panel">
|
||||
<section class="adminCard 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"
|
||||
@@ -122,25 +122,22 @@ function setThumbFileElement(el) {
|
||||
@dragleave="props.onThumbDragLeave"
|
||||
@drop="props.onThumbDrop"
|
||||
>
|
||||
<img v-if="props.displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="props.displayThumbnailUrl" :alt="props.selectedTemplate.game.name" />
|
||||
<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.game.name }} · {{ props.selectedTemplate.game.id }}</div>
|
||||
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': props.gameVisibilitySaving }">
|
||||
<input :checked="!!props.selectedTemplate.game.isPublic" type="checkbox" @change="props.toggleSelectedTemplateVisibility($event.target.checked)" />
|
||||
<span class="toggleSwitch__label">{{ props.selectedTemplate.game.isPublic ? '템플릿 공개중' : '비공개 상태' }}</span>
|
||||
<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,9 +212,9 @@ 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-for="item in props.selectedTemplate.items" :key="item.id" class="thumbCard" :data-game-item-id="item.id">
|
||||
<img class="thumb thumb--game" :src="toApiUrl(item.src)" :alt="item.label" draggable="false" />
|
||||
<div v-else :ref="setTemplateItemListElement" class="thumbGrid">
|
||||
<div v-for="item in props.selectedTemplate.items" :key="item.id" class="thumbCard" :data-template-item-id="item.id">
|
||||
<img class="thumb thumb--template" :src="toApiUrl(item.src)" :alt="item.label" draggable="false" />
|
||||
<input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" data-no-drag />
|
||||
<div class="thumbCard__actions">
|
||||
<button
|
||||
@@ -15,6 +15,10 @@ const props = defineProps({
|
||||
userDisplayName: { type: Function, required: true },
|
||||
userAvatarFallback: { type: Function, required: true },
|
||||
removeUserAvatar: { type: Function, required: true },
|
||||
canEditUserAvatar: { type: Function, required: true },
|
||||
canEditUserInfo: { type: Function, required: true },
|
||||
canResetUserPassword: { type: Function, required: true },
|
||||
canDeleteUser: { type: Function, required: true },
|
||||
roleLabelOf: { type: Function, required: true },
|
||||
fmt: { type: Function, required: true },
|
||||
openUserPasswordModal: { type: Function, required: true },
|
||||
@@ -76,7 +80,12 @@ const userSortDirectionModel = computed({
|
||||
@change="props.onUserAvatarChange(user, $event)"
|
||||
/>
|
||||
<div class="userAvatarWrap">
|
||||
<button class="userAvatar userAvatarButton" type="button" :disabled="user.isAvatarBusy" @click="props.openUserAvatarPicker(user)">
|
||||
<button
|
||||
class="userAvatar userAvatarButton"
|
||||
type="button"
|
||||
:disabled="user.isAvatarBusy || !props.canEditUserAvatar(user)"
|
||||
@click="props.openUserAvatarPicker(user)"
|
||||
>
|
||||
<img v-if="props.userAvatarUrl(user)" class="userAvatar__image" :src="props.userAvatarUrl(user)" :alt="props.userDisplayName(user)" />
|
||||
<span v-else class="userAvatar__fallback">{{ props.userAvatarFallback(user) }}</span>
|
||||
<span class="userAvatarButton__overlay">{{ user.isAvatarBusy ? '업데이트중...' : '수정' }}</span>
|
||||
@@ -86,7 +95,7 @@ const userSortDirectionModel = computed({
|
||||
class="userAvatarRemoveButton"
|
||||
type="button"
|
||||
title="회원 썸네일 삭제"
|
||||
:disabled="user.isAvatarBusy"
|
||||
:disabled="user.isAvatarBusy || !props.canEditUserAvatar(user)"
|
||||
@click.stop="props.removeUserAvatar(user)"
|
||||
>
|
||||
<SvgIcon class="userAvatarRemoveIcon" :src="props.deleteIcon" :size="12" />
|
||||
@@ -111,13 +120,32 @@ const userSortDirectionModel = computed({
|
||||
</div>
|
||||
|
||||
<div class="userCard__actions userCard__actions--compact">
|
||||
<button class="iconActionButton" type="button" title="비밀번호 초기화" @click="props.openUserPasswordModal(user)">
|
||||
<button
|
||||
class="iconActionButton"
|
||||
type="button"
|
||||
title="비밀번호 초기화"
|
||||
:disabled="!props.canResetUserPassword(user)"
|
||||
@click="props.openUserPasswordModal(user)"
|
||||
>
|
||||
<SvgIcon class="iconActionButton__icon" :src="props.lockResetIcon" :size="18" />
|
||||
</button>
|
||||
<button class="iconActionButton iconActionButton--danger" type="button" title="회원 삭제" @click="props.openUserDeleteModal(user)">
|
||||
<button
|
||||
class="iconActionButton iconActionButton--danger"
|
||||
type="button"
|
||||
title="회원 삭제"
|
||||
:disabled="!props.canDeleteUser(user)"
|
||||
@click="props.openUserDeleteModal(user)"
|
||||
>
|
||||
<SvgIcon class="iconActionButton__icon" :src="props.deleteIcon" :size="18" />
|
||||
</button>
|
||||
<button class="btn btn--ghost userSaveButton" type="button" @click="props.openUserEditModal(user)">회원 정보 수정</button>
|
||||
<button
|
||||
class="btn btn--ghost userSaveButton"
|
||||
type="button"
|
||||
:disabled="!props.canEditUserInfo(user)"
|
||||
@click="props.openUserEditModal(user)"
|
||||
>
|
||||
회원 정보 수정
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -85,7 +85,7 @@ export function useAdminCustomItems({
|
||||
|
||||
function openCustomItemDeleteModal(item) {
|
||||
if (!item) return
|
||||
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedGames.length > 0)) {
|
||||
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedTemplates.length > 0)) {
|
||||
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
|
||||
return
|
||||
}
|
||||
@@ -100,7 +100,7 @@ export function useAdminCustomItems({
|
||||
function jumpToTemplateAdmin(templateId) {
|
||||
if (!templateId) return
|
||||
closeCustomItemModal()
|
||||
setTab('game-admin')
|
||||
setTab('template-admin')
|
||||
nextTick(() => {
|
||||
selectAdminTemplate(templateId)
|
||||
})
|
||||
@@ -109,7 +109,7 @@ export function useAdminCustomItems({
|
||||
async function removeCustomItem(item = modalTargetCustomItem.value) {
|
||||
resetMessages()
|
||||
if (!item) return
|
||||
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedGames.length > 0)) {
|
||||
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedTemplates.length > 0)) {
|
||||
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { nextTick } from 'vue'
|
||||
import Sortable from 'sortablejs'
|
||||
|
||||
export function useAdminFeaturedGames({
|
||||
export function useAdminFeaturedTemplates({
|
||||
api,
|
||||
featuredListEl,
|
||||
featuredSortable,
|
||||
@@ -1,7 +1,7 @@
|
||||
import { nextTick } from 'vue'
|
||||
import Sortable from 'sortablejs'
|
||||
|
||||
export function useAdminGameManager({
|
||||
export function useAdminTemplateManager({
|
||||
api,
|
||||
toApiUrl,
|
||||
selectedTemplateId,
|
||||
@@ -11,10 +11,10 @@ export function useAdminGameManager({
|
||||
thumbFile,
|
||||
itemPreviewUrls,
|
||||
itemFileInput,
|
||||
gameItemListEl,
|
||||
gameItemSortable,
|
||||
savedGameItemOrderIds,
|
||||
isGameLoading,
|
||||
templateItemListEl,
|
||||
templateItemSortable,
|
||||
savedTemplateItemOrderIds,
|
||||
isTemplateLoading,
|
||||
activeTemplateRequest,
|
||||
templateRequests,
|
||||
customItemModalOpen,
|
||||
@@ -49,21 +49,21 @@ export function useAdminGameManager({
|
||||
return src.split('/').pop() || item.file?.name || 'item'
|
||||
}
|
||||
|
||||
function destroyGameItemSortable() {
|
||||
if (gameItemSortable.value) {
|
||||
gameItemSortable.value.destroy()
|
||||
gameItemSortable.value = null
|
||||
function destroyTemplateItemSortable() {
|
||||
if (templateItemSortable.value) {
|
||||
templateItemSortable.value.destroy()
|
||||
templateItemSortable.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function syncGameItemSortable() {
|
||||
async function syncTemplateItemSortable() {
|
||||
await nextTick()
|
||||
destroyGameItemSortable()
|
||||
if (!gameItemListEl.value || !selectedTemplate.value?.items?.length) return
|
||||
destroyTemplateItemSortable()
|
||||
if (!templateItemListEl.value || !selectedTemplate.value?.items?.length) return
|
||||
|
||||
gameItemSortable.value = Sortable.create(gameItemListEl.value, {
|
||||
templateItemSortable.value = Sortable.create(templateItemListEl.value, {
|
||||
animation: 160,
|
||||
draggable: '[data-game-item-id]',
|
||||
draggable: '[data-template-item-id]',
|
||||
forceFallback: true,
|
||||
fallbackOnBody: false,
|
||||
filter: '[data-no-drag]',
|
||||
@@ -124,31 +124,30 @@ export function useAdminGameManager({
|
||||
|
||||
if (!selectedTemplateId.value) {
|
||||
selectedTemplate.value = null
|
||||
savedGameItemOrderIds.value = []
|
||||
destroyGameItemSortable()
|
||||
savedTemplateItemOrderIds.value = []
|
||||
destroyTemplateItemSortable()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isGameLoading.value = true
|
||||
isTemplateLoading.value = true
|
||||
const data = await api.getTopic(selectedTemplateId.value)
|
||||
const loadedTemplate = data.template || data.topic || null
|
||||
selectedTemplate.value = {
|
||||
...data,
|
||||
game: loadedTemplate,
|
||||
template: loadedTemplate,
|
||||
items: (data.items || []).map((item) => ({
|
||||
...item,
|
||||
draftLabel: item.label,
|
||||
})),
|
||||
}
|
||||
savedGameItemOrderIds.value = (data.items || []).map((item) => item.id)
|
||||
await syncGameItemSortable()
|
||||
savedTemplateItemOrderIds.value = (data.items || []).map((item) => item.id)
|
||||
await syncTemplateItemSortable()
|
||||
} catch (e) {
|
||||
selectedTemplate.value = null
|
||||
error.value = '템플릿 정보를 불러오지 못했어요.'
|
||||
} finally {
|
||||
isGameLoading.value = false
|
||||
isTemplateLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +173,7 @@ export function useAdminGameManager({
|
||||
const data = await res.json()
|
||||
const createdTemplate = data.template || {}
|
||||
if (activeTemplateRequest.value?.type === 'create' && activeTemplateRequest.value?.id) {
|
||||
const linkData = await api.linkAdminTemplateRequestGame(activeTemplateRequest.value.id, {
|
||||
const linkData = await api.linkAdminTemplateRequestTemplate(activeTemplateRequest.value.id, {
|
||||
topicId: createdTemplate.id,
|
||||
})
|
||||
activeTemplateRequest.value = {
|
||||
@@ -279,9 +278,16 @@ export function useAdminGameManager({
|
||||
|
||||
const fileDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'file')
|
||||
const requestDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'request')
|
||||
const totalUploadBytes = fileDrafts.reduce((sum, entry) => sum + Number(entry.file?.size || 0), 0)
|
||||
let uploadCount = 0
|
||||
|
||||
if (fileDrafts.length) {
|
||||
console.info('[admin] template item upload start', {
|
||||
topicId: selectedTemplateId.value,
|
||||
fileCount: fileDrafts.length,
|
||||
totalBytes: totalUploadBytes,
|
||||
labels: fileDrafts.map((entry) => entry.label.trim()),
|
||||
})
|
||||
const fd = new FormData()
|
||||
fileDrafts.forEach((entry) => {
|
||||
fd.append('images', entry.file)
|
||||
@@ -292,7 +298,25 @@ export function useAdminGameManager({
|
||||
credentials: 'include',
|
||||
body: fd,
|
||||
})
|
||||
if (!res.ok) throw new Error('failed')
|
||||
if (!res.ok) {
|
||||
const responseText = await res.text().catch(() => '')
|
||||
console.error('[admin] template item upload failed', {
|
||||
topicId: selectedTemplateId.value,
|
||||
fileCount: fileDrafts.length,
|
||||
totalBytes: totalUploadBytes,
|
||||
status: res.status,
|
||||
body: responseText,
|
||||
})
|
||||
const uploadError = new Error('failed')
|
||||
uploadError.status = res.status
|
||||
uploadError.body = responseText
|
||||
throw uploadError
|
||||
}
|
||||
console.info('[admin] template item upload success', {
|
||||
topicId: selectedTemplateId.value,
|
||||
fileCount: fileDrafts.length,
|
||||
totalBytes: totalUploadBytes,
|
||||
})
|
||||
uploadCount += fileDrafts.length
|
||||
}
|
||||
|
||||
@@ -317,6 +341,12 @@ export function useAdminGameManager({
|
||||
await loadTemplate()
|
||||
success.value = `템플릿 기본 아이템 ${uploadCount}개 추가를 완료했어요.`
|
||||
} catch (e) {
|
||||
console.error('[admin] uploadItem error', {
|
||||
message: e?.message || '',
|
||||
status: e?.status || 0,
|
||||
body: e?.body || '',
|
||||
data: e?.data || null,
|
||||
})
|
||||
const apiError = e?.data?.error || ''
|
||||
if (apiError === 'no_items_selected') {
|
||||
error.value = '추가할 요청 아이템이 없어요.'
|
||||
@@ -331,6 +361,10 @@ export function useAdminGameManager({
|
||||
error.value = '선택한 템플릿을 찾지 못했어요.'
|
||||
return
|
||||
}
|
||||
if (e?.status === 413) {
|
||||
error.value = '한 번에 업로드한 파일 용량이 너무 커서 실패했어요.'
|
||||
return
|
||||
}
|
||||
error.value = '아이템 추가에 실패했어요.'
|
||||
}
|
||||
}
|
||||
@@ -350,8 +384,8 @@ export function useAdminGameManager({
|
||||
draftLabel: item.label,
|
||||
})),
|
||||
}
|
||||
savedGameItemOrderIds.value = (data.items || []).map((item) => item.id)
|
||||
await syncGameItemSortable()
|
||||
savedTemplateItemOrderIds.value = (data.items || []).map((item) => item.id)
|
||||
await syncTemplateItemSortable()
|
||||
success.value = '기본 아이템 순서를 저장했어요.'
|
||||
} catch (e) {
|
||||
error.value = '기본 아이템 순서 저장에 실패했어요.'
|
||||
@@ -360,8 +394,8 @@ export function useAdminGameManager({
|
||||
|
||||
return {
|
||||
requestItemFilename,
|
||||
destroyGameItemSortable,
|
||||
syncGameItemSortable,
|
||||
destroyTemplateItemSortable,
|
||||
syncTemplateItemSortable,
|
||||
mergeRequestItemsIntoDrafts,
|
||||
removeUploadDraft,
|
||||
loadTemplate,
|
||||
@@ -62,7 +62,7 @@ export function useAdminTemplateRequests({
|
||||
Object.assign(request, syncedRequest)
|
||||
request.status = syncedRequest.status || 'reviewing'
|
||||
updateActiveTemplateRequest(syncedRequest)
|
||||
setTab('game-admin')
|
||||
setTab('template-admin')
|
||||
|
||||
if (request.type === 'create') {
|
||||
const linkedTopicId = syncedRequest.targetTopicId || ''
|
||||
|
||||
@@ -38,6 +38,29 @@ export function useAdminUsers({
|
||||
return !modalTargetUser.value.isPrimaryAdmin
|
||||
})
|
||||
|
||||
function canManageUser(user) {
|
||||
if (!user?.isPrimaryAdmin) return true
|
||||
return !!auth.user?.isPrimaryAdmin
|
||||
}
|
||||
|
||||
function canEditUserAvatar(user) {
|
||||
return canManageUser(user)
|
||||
}
|
||||
|
||||
function canEditUserInfo(user) {
|
||||
return canManageUser(user)
|
||||
}
|
||||
|
||||
function canResetUserPassword(user) {
|
||||
return canManageUser(user)
|
||||
}
|
||||
|
||||
function canDeleteUser(user) {
|
||||
if (!canManageUser(user)) return false
|
||||
if (user?.isAdmin && !auth.user?.isPrimaryAdmin) return false
|
||||
return user?.id !== auth.user?.id
|
||||
}
|
||||
|
||||
const isUserEditDirty = computed(() => {
|
||||
if (!modalTargetUser.value) return false
|
||||
return (
|
||||
@@ -54,6 +77,7 @@ export function useAdminUsers({
|
||||
}
|
||||
|
||||
function openUserAvatarPicker(user) {
|
||||
if (!canEditUserAvatar(user)) return
|
||||
userAvatarInputs.value[user?.id]?.click()
|
||||
}
|
||||
|
||||
@@ -96,11 +120,13 @@ export function useAdminUsers({
|
||||
}
|
||||
|
||||
async function removeUserAvatar(user) {
|
||||
if (!canEditUserAvatar(user)) return
|
||||
if (!user?.avatarSrc) return
|
||||
await uploadUserAvatar(user, null, { remove: true })
|
||||
}
|
||||
|
||||
function openUserEditModal(user) {
|
||||
if (!canEditUserInfo(user)) return
|
||||
resetMessages()
|
||||
modalTargetUser.value = user ? { ...user } : null
|
||||
modalUserDraftEmail.value = user?.email || ''
|
||||
@@ -147,6 +173,7 @@ export function useAdminUsers({
|
||||
}
|
||||
|
||||
function openUserPasswordModal(user) {
|
||||
if (!canResetUserPassword(user)) return
|
||||
resetMessages()
|
||||
modalTargetUser.value = user ? { ...user } : null
|
||||
modalPasswordDraft.value = ''
|
||||
@@ -183,6 +210,7 @@ export function useAdminUsers({
|
||||
}
|
||||
|
||||
function openUserDeleteModal(user) {
|
||||
if (!canDeleteUser(user)) return
|
||||
resetMessages()
|
||||
modalTargetUser.value = user ? { ...user } : null
|
||||
userDeleteModalOpen.value = true
|
||||
@@ -242,6 +270,10 @@ export function useAdminUsers({
|
||||
return {
|
||||
setUserAvatarInput,
|
||||
canManageModalRole,
|
||||
canEditUserAvatar,
|
||||
canEditUserInfo,
|
||||
canResetUserPassword,
|
||||
canDeleteUser,
|
||||
isUserEditDirty,
|
||||
roleLabelOf,
|
||||
openUserAvatarPicker,
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -100,11 +100,11 @@ export const api = {
|
||||
promoteAdminTierListItems: (tierListId, payload) =>
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/promote-items`, { method: 'POST', body: payload }),
|
||||
createAdminTemplateFromTierList: (tierListId, payload) =>
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-game-template`, { method: 'POST', body: payload }),
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-template`, { method: 'POST', body: payload }),
|
||||
startAdminTemplateRequestReview: (requestId) =>
|
||||
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/review`, { method: 'POST', body: {} }),
|
||||
linkAdminTemplateRequestGame: (requestId, payload) =>
|
||||
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/link-game`, { method: 'POST', body: payload }),
|
||||
linkAdminTemplateRequestTemplate: (requestId, payload) =>
|
||||
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/link-template`, { method: 'POST', body: payload }),
|
||||
promoteAdminTemplateRequestItems: (requestId, payload) =>
|
||||
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/promote-items`, { method: 'POST', body: payload }),
|
||||
completeAdminTemplateRequest: (requestId) =>
|
||||
|
||||
@@ -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,7 @@ export function createRouter() {
|
||||
{ path: '/search', name: 'search', component: SearchResultsView },
|
||||
{ path: '/admin', redirect: '/admin/featured' },
|
||||
{ path: '/admin/featured', name: 'adminFeatured', component: AdminView },
|
||||
{ path: '/admin/games', name: 'adminGames', component: AdminView },
|
||||
{ path: '/admin/templates', name: 'adminTemplates', component: AdminView },
|
||||
{ path: '/admin/items', name: 'adminItems', component: AdminView },
|
||||
{ path: '/admin/tierlists', name: 'adminTierlists', component: AdminView },
|
||||
{ path: '/admin/users', name: 'adminUsers', component: AdminView },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -36,7 +36,7 @@ const selectedTemplateId = ref('')
|
||||
const selectedTemplate = ref(null)
|
||||
const featuredTemplateIds = ref([])
|
||||
const templatePickerModalOpen = ref(false)
|
||||
const templatePickerMode = ref('game-admin')
|
||||
const templatePickerMode = ref('template-admin')
|
||||
const templatePickerQuery = ref('')
|
||||
const templatePickerSort = ref('recent')
|
||||
|
||||
@@ -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)
|
||||
@@ -109,7 +109,7 @@ const success = ref('')
|
||||
const newTemplateId = ref('')
|
||||
const newTemplateName = ref('')
|
||||
const newTemplateIsPublic = ref(false)
|
||||
const gameVisibilitySaving = ref(false)
|
||||
const templateVisibilitySaving = ref(false)
|
||||
|
||||
const uploadFiles = ref([])
|
||||
const uploadItemDrafts = ref([])
|
||||
@@ -122,12 +122,12 @@ const itemFileInput = ref(null)
|
||||
const thumbFileInput = ref(null)
|
||||
const featuredListEl = ref(null)
|
||||
const featuredSortable = ref(null)
|
||||
const gameItemListEl = ref(null)
|
||||
const gameItemSortable = ref(null)
|
||||
let gameItemSortableSyncTimer = null
|
||||
const savedGameItemOrderIds = ref([])
|
||||
const templateItemListEl = ref(null)
|
||||
const templateItemSortable = ref(null)
|
||||
let templateItemSortableSyncTimer = null
|
||||
const savedTemplateItemOrderIds = ref([])
|
||||
const userAvatarInputs = ref({})
|
||||
const isGameLoading = ref(false)
|
||||
const isTemplateLoading = ref(false)
|
||||
const templateCreateModalOpen = ref(false)
|
||||
const previousBodyOverflow = ref('')
|
||||
|
||||
@@ -143,23 +143,23 @@ function setThumbFileInputRef(el) {
|
||||
thumbFileInput.value = el
|
||||
}
|
||||
|
||||
function scheduleGameItemSortableSync() {
|
||||
if (gameItemSortableSyncTimer) {
|
||||
clearTimeout(gameItemSortableSyncTimer)
|
||||
gameItemSortableSyncTimer = null
|
||||
function scheduleTemplateItemSortableSync() {
|
||||
if (templateItemSortableSyncTimer) {
|
||||
clearTimeout(templateItemSortableSyncTimer)
|
||||
templateItemSortableSyncTimer = null
|
||||
}
|
||||
if (!gameItemListEl.value || !selectedTemplate.value?.items?.length) return
|
||||
if (!templateItemListEl.value || !selectedTemplate.value?.items?.length) return
|
||||
|
||||
gameItemSortableSyncTimer = setTimeout(() => {
|
||||
gameItemSortableSyncTimer = null
|
||||
syncGameItemSortable()
|
||||
templateItemSortableSyncTimer = setTimeout(() => {
|
||||
templateItemSortableSyncTimer = null
|
||||
syncTemplateItemSortable()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function setGameItemListRef(el) {
|
||||
gameItemListEl.value = el
|
||||
function setTemplateItemListRef(el) {
|
||||
templateItemListEl.value = el
|
||||
if (!el) return
|
||||
scheduleGameItemSortableSync()
|
||||
scheduleTemplateItemSortableSync()
|
||||
}
|
||||
|
||||
function normalizeAdminSrc(src) {
|
||||
@@ -175,7 +175,7 @@ function normalizeAdminSrc(src) {
|
||||
}
|
||||
}
|
||||
|
||||
const hasSelectedTemplate = computed(() => !!selectedTemplate.value?.game?.id)
|
||||
const hasSelectedTemplate = computed(() => !!selectedTemplate.value?.template?.id)
|
||||
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedTemplateId.value)
|
||||
const canAddItem = computed(() => uploadItemDrafts.value.length > 0 && uploadItemDrafts.value.every((item) => !!item.label.trim()) && !!selectedTemplateId.value)
|
||||
const stagedRequestDraftCount = computed(() => uploadItemDrafts.value.filter((item) => item.kind === 'request').length)
|
||||
@@ -188,7 +188,7 @@ const appliedRequestItemCount = computed(() => {
|
||||
})
|
||||
const hasTemplateItemOrderChanges = computed(() => {
|
||||
const currentIds = (selectedTemplate.value?.items || []).map((item) => item.id)
|
||||
return currentIds.join('|') !== savedGameItemOrderIds.value.join('|')
|
||||
return currentIds.join('|') !== savedTemplateItemOrderIds.value.join('|')
|
||||
})
|
||||
const customItemPageCount = computed(() => Math.max(1, Math.ceil(customItemTotal.value / customItemLimit.value)))
|
||||
const adminTierListPageCount = computed(() => Math.max(1, Math.ceil(adminTierListTotal.value / adminTierListLimit.value)))
|
||||
@@ -215,7 +215,7 @@ const customItemTargetTemplate = computed(() => templates.value.find((template)
|
||||
const importModalItemCount = computed(() => importModalItems.value.length)
|
||||
const activeTabTitle = computed(() => {
|
||||
if (activeTab.value === 'featured') return '목록 관리'
|
||||
if (activeTab.value === 'game-admin') return '템플릿 관리'
|
||||
if (activeTab.value === 'template-admin') return '템플릿 관리'
|
||||
if (activeTab.value === 'items') return '아이템 관리'
|
||||
if (activeTab.value === 'tierlists') {
|
||||
return tierlistsMode.value === 'requests' ? '템플릿 요청 관리' : '전체 티어표 관리'
|
||||
@@ -226,7 +226,7 @@ const activeTabDescription = computed(() => {
|
||||
if (activeTab.value === 'featured') {
|
||||
return '홈 화면 상단에 고정 노출되는 템플릿 순서를 따로 관리합니다.'
|
||||
}
|
||||
if (activeTab.value === 'game-admin') {
|
||||
if (activeTab.value === 'template-admin') {
|
||||
return '템플릿 생성, 선택, 썸네일, 기본 아이템 관리를 전용 작업 화면으로 분리했습니다.'
|
||||
}
|
||||
if (activeTab.value === 'items') {
|
||||
@@ -251,7 +251,7 @@ const adminOverviewStats = computed(() => {
|
||||
{ label: '추가 가능', value: `${Math.max(0, 50 - featuredTemplateIds.value.length)}` },
|
||||
]
|
||||
}
|
||||
if (activeTab.value === 'game-admin') {
|
||||
if (activeTab.value === 'template-admin') {
|
||||
return [
|
||||
{ label: '전체 템플릿', value: `${templates.value.length}` },
|
||||
{ label: '티어표 전체', value: `${selectedTemplateTierListStats.value.total || 0}` },
|
||||
@@ -305,14 +305,14 @@ const isAnyModalOpen = computed(
|
||||
)
|
||||
const adminRouteNameByTab = {
|
||||
featured: 'adminFeatured',
|
||||
'game-admin': 'adminGames',
|
||||
'template-admin': 'adminTemplates',
|
||||
items: 'adminItems',
|
||||
tierlists: 'adminTierlists',
|
||||
users: 'adminUsers',
|
||||
}
|
||||
|
||||
function tabFromAdminRoute(name) {
|
||||
if (name === 'adminGames') return 'game-admin'
|
||||
if (name === 'adminTemplates') return 'template-admin'
|
||||
if (name === 'adminItems') return 'items'
|
||||
if (name === 'adminTierlists') return 'tierlists'
|
||||
if (name === 'adminUsers') return 'users'
|
||||
@@ -375,12 +375,12 @@ onUnmounted(() => {
|
||||
if (typeof document !== 'undefined') document.body.style.overflow = previousBodyOverflow.value || ''
|
||||
clearPreviewUrl('item')
|
||||
clearPreviewUrl('thumb')
|
||||
if (gameItemSortableSyncTimer) {
|
||||
clearTimeout(gameItemSortableSyncTimer)
|
||||
gameItemSortableSyncTimer = null
|
||||
if (templateItemSortableSyncTimer) {
|
||||
clearTimeout(templateItemSortableSyncTimer)
|
||||
templateItemSortableSyncTimer = null
|
||||
}
|
||||
destroyFeaturedSortable()
|
||||
destroyGameItemSortable()
|
||||
destroyTemplateItemSortable()
|
||||
})
|
||||
|
||||
function clearPreviewUrl(kind) {
|
||||
@@ -423,7 +423,7 @@ watch(
|
||||
() => route.name,
|
||||
(name) => {
|
||||
activeTab.value = tabFromAdminRoute(name)
|
||||
if (name === 'adminGames') {
|
||||
if (name === 'adminTemplates') {
|
||||
const nextTopicId = typeof route.query.topicId === 'string' ? route.query.topicId : ''
|
||||
if (nextTopicId && nextTopicId !== selectedTemplateId.value) {
|
||||
selectedTemplateId.value = nextTopicId
|
||||
@@ -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 }
|
||||
@@ -446,13 +446,13 @@ watch(
|
||||
watch(
|
||||
() => selectedTemplateId.value,
|
||||
(templateId) => {
|
||||
if (route.name !== 'adminGames') return
|
||||
if (route.name !== 'adminTemplates') return
|
||||
syncAdminRouteQuery({ topicId: templateId || undefined })
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => selectedTemplate.value?.game?.id || '',
|
||||
() => selectedTemplate.value?.template?.id || '',
|
||||
async (templateId) => {
|
||||
await refreshSelectedTemplateTierListStats(templateId)
|
||||
},
|
||||
@@ -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 })
|
||||
@@ -481,7 +481,7 @@ watch(
|
||||
watch(
|
||||
() => activeTab.value,
|
||||
async (tab) => {
|
||||
if (tab === 'game-admin' && selectedTemplateId.value && !selectedTemplate.value?.game?.id) {
|
||||
if (tab === 'template-admin' && selectedTemplateId.value && !selectedTemplate.value?.template?.id) {
|
||||
await loadTemplate()
|
||||
return
|
||||
}
|
||||
@@ -524,10 +524,10 @@ watch(
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [selectedTemplate.value?.game?.id || '', selectedTemplate.value?.items?.length || 0, !!gameItemListEl.value],
|
||||
() => [selectedTemplate.value?.template?.id || '', selectedTemplate.value?.items?.length || 0, !!templateItemListEl.value],
|
||||
([templateId, itemCount, hasListEl]) => {
|
||||
if (!templateId || !itemCount || !hasListEl) return
|
||||
scheduleGameItemSortableSync()
|
||||
scheduleTemplateItemSortableSync()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -571,7 +571,7 @@ function formatImageJobSourceCategory(category) {
|
||||
return '커스텀 아이템'
|
||||
case 'tierlists':
|
||||
return '티어표 썸네일'
|
||||
case 'games':
|
||||
case 'topics':
|
||||
return '주제/템플릿 이미지'
|
||||
case 'avatars':
|
||||
return '프로필 아바타'
|
||||
@@ -619,7 +619,7 @@ const imageDiagnosticsCards = computed(() => {
|
||||
]
|
||||
})
|
||||
const visibleLinkedTemplates = computed(() =>
|
||||
(modalTargetCustomItem.value?.linkedGames || []).filter((template) => template?.id && template.id !== 'freeform')
|
||||
(modalTargetCustomItem.value?.linkedTemplates || []).filter((template) => template?.id && template.id !== 'freeform')
|
||||
)
|
||||
const linkedCustomItemTemplateIds = computed(() => new Set(visibleLinkedTemplates.value.map((template) => template.id).filter(Boolean)))
|
||||
|
||||
@@ -715,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 = '누락 이미지 참조 정리에 실패했어요.'
|
||||
@@ -732,7 +732,7 @@ function setTab(tab) {
|
||||
const nextRouteName = adminRouteNameByTab[tab]
|
||||
if (nextRouteName && route.name !== nextRouteName) {
|
||||
const nextQuery =
|
||||
tab === 'game-admin'
|
||||
tab === 'template-admin'
|
||||
? { topicId: selectedTemplateId.value || undefined }
|
||||
: tab === 'tierlists' && tierlistsMode.value === 'all'
|
||||
? { mode: 'all' }
|
||||
@@ -822,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,
|
||||
})
|
||||
@@ -839,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,
|
||||
@@ -919,7 +919,7 @@ const {
|
||||
removeFeaturedTemplate,
|
||||
moveFeaturedTemplate,
|
||||
saveFeaturedOrder,
|
||||
} = useAdminFeaturedGames({
|
||||
} = useAdminFeaturedTemplates({
|
||||
api,
|
||||
featuredListEl,
|
||||
featuredSortable,
|
||||
@@ -931,8 +931,8 @@ const {
|
||||
})
|
||||
|
||||
const {
|
||||
destroyGameItemSortable,
|
||||
syncGameItemSortable,
|
||||
destroyTemplateItemSortable,
|
||||
syncTemplateItemSortable,
|
||||
mergeRequestItemsIntoDrafts,
|
||||
removeUploadDraft,
|
||||
loadTemplate,
|
||||
@@ -943,7 +943,7 @@ const {
|
||||
clearItemFiles,
|
||||
uploadItem,
|
||||
saveTemplateItemOrder,
|
||||
} = useAdminGameManager({
|
||||
} = useAdminTemplateManager({
|
||||
api,
|
||||
toApiUrl,
|
||||
selectedTemplateId,
|
||||
@@ -953,10 +953,10 @@ const {
|
||||
thumbFile,
|
||||
itemPreviewUrls,
|
||||
itemFileInput,
|
||||
gameItemListEl,
|
||||
gameItemSortable,
|
||||
savedGameItemOrderIds,
|
||||
isGameLoading,
|
||||
templateItemListEl,
|
||||
templateItemSortable,
|
||||
savedTemplateItemOrderIds,
|
||||
isTemplateLoading,
|
||||
activeTemplateRequest,
|
||||
templateRequests,
|
||||
customItemModalOpen,
|
||||
@@ -1039,6 +1039,10 @@ const {
|
||||
const {
|
||||
setUserAvatarInput,
|
||||
canManageModalRole,
|
||||
canEditUserAvatar,
|
||||
canEditUserInfo,
|
||||
canResetUserPassword,
|
||||
canDeleteUser,
|
||||
isUserEditDirty,
|
||||
roleLabelOf,
|
||||
openUserAvatarPicker,
|
||||
@@ -1167,17 +1171,17 @@ async function uploadThumbnail() {
|
||||
}
|
||||
|
||||
async function saveTemplateVisibility() {
|
||||
if (!selectedTemplate.value?.game?.id) return
|
||||
if (!selectedTemplate.value?.template?.id) return
|
||||
try {
|
||||
gameVisibilitySaving.value = true
|
||||
const data = await api.updateAdminTemplate(selectedTemplate.value.game.id, {
|
||||
isPublic: !!selectedTemplate.value.game.isPublic,
|
||||
templateVisibilitySaving.value = true
|
||||
const data = await api.updateAdminTemplate(selectedTemplate.value.template.id, {
|
||||
isPublic: !!selectedTemplate.value.template.isPublic,
|
||||
})
|
||||
const nextTemplate = data.template || {}
|
||||
selectedTemplate.value = {
|
||||
...selectedTemplate.value,
|
||||
game: {
|
||||
...selectedTemplate.value.game,
|
||||
template: {
|
||||
...selectedTemplate.value.template,
|
||||
...nextTemplate,
|
||||
},
|
||||
}
|
||||
@@ -1188,17 +1192,17 @@ async function saveTemplateVisibility() {
|
||||
error.value = '템플릿 공개 상태를 저장하지 못했어요.'
|
||||
return false
|
||||
} finally {
|
||||
gameVisibilitySaving.value = false
|
||||
templateVisibilitySaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSelectedTemplateVisibility(nextValue) {
|
||||
if (!selectedTemplate.value?.game?.id || gameVisibilitySaving.value) return
|
||||
const previous = !!selectedTemplate.value.game.isPublic
|
||||
if (!selectedTemplate.value?.template?.id || templateVisibilitySaving.value) return
|
||||
const previous = !!selectedTemplate.value.template.isPublic
|
||||
selectedTemplate.value = {
|
||||
...selectedTemplate.value,
|
||||
game: {
|
||||
...selectedTemplate.value.game,
|
||||
template: {
|
||||
...selectedTemplate.value.template,
|
||||
isPublic: !!nextValue,
|
||||
},
|
||||
}
|
||||
@@ -1206,8 +1210,8 @@ async function toggleSelectedTemplateVisibility(nextValue) {
|
||||
if (!saved) {
|
||||
selectedTemplate.value = {
|
||||
...selectedTemplate.value,
|
||||
game: {
|
||||
...selectedTemplate.value.game,
|
||||
template: {
|
||||
...selectedTemplate.value.template,
|
||||
isPublic: previous,
|
||||
},
|
||||
}
|
||||
@@ -1278,9 +1282,9 @@ async function saveTemplateItemLabel(item) {
|
||||
|
||||
async function removeTemplate() {
|
||||
resetMessages()
|
||||
if (!selectedTemplateId.value || !selectedTemplate.value?.game) return
|
||||
if (!selectedTemplateId.value || !selectedTemplate.value?.template) return
|
||||
|
||||
const ok = window.confirm(`"${selectedTemplate.value.game.name}" 템플릿을 삭제할까요? 관련 기본 아이템과 티어표도 함께 삭제됩니다.`)
|
||||
const ok = window.confirm(`"${selectedTemplate.value.template.name}" 템플릿을 삭제할까요? 관련 기본 아이템과 티어표도 함께 삭제됩니다.`)
|
||||
if (!ok) return
|
||||
|
||||
try {
|
||||
@@ -1290,7 +1294,7 @@ async function removeTemplate() {
|
||||
})
|
||||
if (!res.ok) throw new Error('failed')
|
||||
|
||||
const deletedName = selectedTemplate.value.game.name
|
||||
const deletedName = selectedTemplate.value.template.name
|
||||
selectedTemplateId.value = ''
|
||||
selectedTemplate.value = null
|
||||
resetUploadState()
|
||||
@@ -1306,13 +1310,13 @@ function submitAdminTierListSearch() {
|
||||
refreshAdminTierLists()
|
||||
}
|
||||
|
||||
function setAdminTierListGameId(topicId) {
|
||||
adminTierListGameId.value = topicId || ''
|
||||
function setAdminTierListTopicId(topicId) {
|
||||
adminTierListTopicId.value = topicId || ''
|
||||
adminTierListPage.value = 1
|
||||
refreshAdminTierLists()
|
||||
}
|
||||
|
||||
function openTemplatePickerModal(mode = 'game-admin') {
|
||||
function openTemplatePickerModal(mode = 'template-admin') {
|
||||
templatePickerMode.value = mode
|
||||
templatePickerQuery.value = ''
|
||||
templatePickerSort.value = 'recent'
|
||||
@@ -1327,7 +1331,7 @@ function closeTemplatePickerModal() {
|
||||
async function chooseTemplateFromPicker(templateId) {
|
||||
if (!templateId) return
|
||||
if (templatePickerMode.value === 'tierlists-filter') {
|
||||
setAdminTierListGameId(templateId)
|
||||
setAdminTierListTopicId(templateId)
|
||||
closeTemplatePickerModal()
|
||||
return
|
||||
}
|
||||
@@ -1368,7 +1372,7 @@ function buildModalItemFromTierListItem(item, tierList) {
|
||||
sourceType: matchedItem?.sourceType || (String(id).startsWith('asset:') ? 'template' : 'user'),
|
||||
sourceLabel: matchedItem?.sourceLabel || '티어표 추가 아이템',
|
||||
ownerName: matchedItem?.ownerName || tierListAuthorDisplayName(tierList),
|
||||
linkedGames: Array.isArray(matchedItem?.linkedGames) ? matchedItem.linkedGames : [],
|
||||
linkedTemplates: Array.isArray(matchedItem?.linkedTemplates) ? matchedItem.linkedTemplates : [],
|
||||
usageCount: matchedItem?.usageCount || 0,
|
||||
canDelete: typeof matchedItem?.canDelete === 'boolean' ? matchedItem.canDelete : false,
|
||||
isPromoting: false,
|
||||
@@ -1432,7 +1436,7 @@ async function saveAdminTierListMeta() {
|
||||
adminTierLists.value = adminTierLists.value.map((tierList) => (tierList.id === updated.id ? { ...tierList, ...updated } : tierList))
|
||||
if (previewTierList.value?.id === updated.id) previewTierList.value = { ...previewTierList.value, ...updated }
|
||||
modalTargetAdminTierList.value = updated
|
||||
await Promise.all([refreshAdminTierListStats(), refreshSelectedTemplateTierListStats(selectedTemplate.value?.game?.id || '')])
|
||||
await Promise.all([refreshAdminTierListStats(), refreshSelectedTemplateTierListStats(selectedTemplate.value?.template?.id || '')])
|
||||
success.value = '티어표 정보를 수정했어요.'
|
||||
closeAdminTierListManageModal()
|
||||
} catch (e) {
|
||||
@@ -1454,7 +1458,7 @@ async function deleteAdminTierListEntry() {
|
||||
adminTierLists.value = adminTierLists.value.filter((tierList) => tierList.id !== modalTargetAdminTierList.value.id)
|
||||
adminTierListTotal.value = Math.max(0, adminTierListTotal.value - 1)
|
||||
if (previewTierList.value?.id === modalTargetAdminTierList.value.id) previewTierList.value = null
|
||||
await Promise.all([refreshAdminTierListStats(), refreshSelectedTemplateTierListStats(selectedTemplate.value?.game?.id || '')])
|
||||
await Promise.all([refreshAdminTierListStats(), refreshSelectedTemplateTierListStats(selectedTemplate.value?.template?.id || '')])
|
||||
success.value = '티어표를 삭제했어요.'
|
||||
closeAdminTierListManageModal()
|
||||
if (!adminTierLists.value.length && adminTierListPage.value > 1) {
|
||||
@@ -1641,7 +1645,7 @@ function templateRequestTargetLabel(request) {
|
||||
|
||||
const displayThumbnailUrl = computed(() => {
|
||||
if (thumbPreviewUrl.value) return thumbPreviewUrl.value
|
||||
if (selectedTemplate.value?.game?.thumbnailSrc) return toApiUrl(selectedTemplate.value.game.thumbnailSrc)
|
||||
if (selectedTemplate.value?.template?.thumbnailSrc) return toApiUrl(selectedTemplate.value.template.thumbnailSrc)
|
||||
return ''
|
||||
})
|
||||
|
||||
@@ -1700,19 +1704,19 @@ function userAvatarFallback(user) {
|
||||
:add-featured-template="addFeaturedTemplate"
|
||||
/>
|
||||
|
||||
<AdminGamesSection
|
||||
v-else-if="activeTab === 'game-admin'"
|
||||
<AdminTemplatesSection
|
||||
v-else-if="activeTab === 'template-admin'"
|
||||
:active-template-request="activeTemplateRequest"
|
||||
:template-request-source-url="templateRequestSourceUrl"
|
||||
:staged-request-draft-count="stagedRequestDraftCount"
|
||||
:applied-request-item-count="appliedRequestItemCount"
|
||||
:open-template-create-modal="openTemplateCreateModal"
|
||||
:is-game-loading="isGameLoading"
|
||||
:is-template-loading="isTemplateLoading"
|
||||
:has-selected-template="hasSelectedTemplate"
|
||||
:selected-template="selectedTemplate"
|
||||
:display-thumbnail-url="displayThumbnailUrl"
|
||||
:can-apply-thumbnail="canApplyThumbnail"
|
||||
:game-visibility-saving="gameVisibilitySaving"
|
||||
:template-visibility-saving="templateVisibilitySaving"
|
||||
:thumb-file-input-ref="setThumbFileInputRef"
|
||||
:open-thumb-file-picker="openThumbFilePicker"
|
||||
:on-thumb="onThumb"
|
||||
@@ -1739,7 +1743,7 @@ function userAvatarFallback(user) {
|
||||
:remove-upload-draft="removeUploadDraft"
|
||||
:has-template-item-order-changes="hasTemplateItemOrderChanges"
|
||||
:save-template-item-order="saveTemplateItemOrder"
|
||||
:game-item-list-ref="setGameItemListRef"
|
||||
:template-item-list-ref="setTemplateItemListRef"
|
||||
:save-template-item-label="saveTemplateItemLabel"
|
||||
:remove-template-item="removeTemplateItem"
|
||||
:selected-template-id="selectedTemplateId"
|
||||
@@ -1795,6 +1799,10 @@ function userAvatarFallback(user) {
|
||||
:user-display-name="userDisplayName"
|
||||
:user-avatar-fallback="userAvatarFallback"
|
||||
:remove-user-avatar="removeUserAvatar"
|
||||
:can-edit-user-avatar="canEditUserAvatar"
|
||||
:can-edit-user-info="canEditUserInfo"
|
||||
:can-reset-user-password="canResetUserPassword"
|
||||
:can-delete-user="canDeleteUser"
|
||||
:role-label-of="roleLabelOf"
|
||||
:fmt="fmt"
|
||||
:open-user-password-modal="openUserPasswordModal"
|
||||
@@ -1823,7 +1831,7 @@ function userAvatarFallback(user) {
|
||||
v-model="newTemplateId"
|
||||
class="field__input"
|
||||
maxlength="120"
|
||||
placeholder="game id (영문/숫자)"
|
||||
placeholder="topic id (영문/숫자)"
|
||||
@keydown.enter.prevent="createTemplate"
|
||||
/>
|
||||
<span class="field__hint">영문, 숫자, 하이픈 조합 권장 · {{ newTemplateId.length }}/120자</span>
|
||||
@@ -1981,12 +1989,15 @@ function userAvatarFallback(user) {
|
||||
</div>
|
||||
<div class="customItemModal__pickerActions">
|
||||
<button class="btn btn--ghost" type="button" @click="openTemplatePickerModal('custom-item-target')">템플릿 선택</button>
|
||||
<button class="btn btn--ghost btn--small customItemModal__createGameButton" type="button" @click="openTemplateCreateModal">새 템플릿 만들기</button>
|
||||
<button class="btn btn--ghost btn--small customItemModal__createTemplateButton" type="button" @click="openTemplateCreateModal">새 템플릿 만들기</button>
|
||||
</div>
|
||||
</aside>
|
||||
<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>
|
||||
@@ -2046,34 +2057,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>
|
||||
@@ -2223,24 +2234,24 @@ function userAvatarFallback(user) {
|
||||
<div class="adminSidebar__label">Mode</div>
|
||||
<div class="adminSidebar__tabs">
|
||||
<button class="tab" :class="{ 'tab--active': activeTab === 'featured' }" @click="setTab('featured')">목록 관리</button>
|
||||
<button class="tab" :class="{ 'tab--active': activeTab === 'game-admin' }" @click="setTab('game-admin')">템플릿 관리</button>
|
||||
<button class="tab" :class="{ 'tab--active': activeTab === 'template-admin' }" @click="setTab('template-admin')">템플릿 관리</button>
|
||||
<button class="tab" :class="{ 'tab--active': activeTab === 'items' }" @click="setTab('items')">아이템 관리</button>
|
||||
<button class="tab" :class="{ 'tab--active': activeTab === 'tierlists' }" @click="setTab('tierlists')">티어표 관리</button>
|
||||
<button class="tab" :class="{ 'tab--active': activeTab === 'users' }" @click="setTab('users')">회원 관리</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="activeTab === 'game-admin'" class="adminSidebar__panel">
|
||||
<section v-if="activeTab === 'template-admin'" class="adminSidebar__panel">
|
||||
<div class="adminSidebar__label">Template</div>
|
||||
<div class="adminSidebar__group">
|
||||
<button class="btn btn--primary" @click="openTemplateCreateModal">새 템플릿 생성</button>
|
||||
<button class="btn btn--ghost" @click="openTemplatePickerModal('game-admin')">템플릿 선택</button>
|
||||
<div v-if="selectedTemplate?.game" class="adminSelectionCard">
|
||||
<button class="btn btn--ghost" @click="openTemplatePickerModal('template-admin')">템플릿 선택</button>
|
||||
<div v-if="selectedTemplate?.template" class="adminSelectionCard">
|
||||
<div class="adminSelectionCard__label">선택한 템플릿</div>
|
||||
<div class="adminSelectionCard__title">{{ selectedTemplate.game.name }}</div>
|
||||
<div class="adminSelectionCard__meta">{{ selectedTemplate.game.id }}</div>
|
||||
<div class="adminSelectionCard__title">{{ selectedTemplate.template.name }}</div>
|
||||
<div class="adminSelectionCard__meta">{{ selectedTemplate.template.id }}</div>
|
||||
</div>
|
||||
<div v-if="selectedTemplateId && !hasSelectedTemplate && !isGameLoading" class="hint hint--tight">선택된 템플릿 ID: {{ selectedTemplateId }}</div>
|
||||
<div v-if="selectedTemplateId && !hasSelectedTemplate && !isTemplateLoading" class="hint hint--tight">선택된 템플릿 ID: {{ selectedTemplateId }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -2305,11 +2316,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>
|
||||
@@ -2583,14 +2594,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;
|
||||
@@ -2601,32 +2612,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;
|
||||
@@ -2873,16 +2884,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;
|
||||
@@ -3048,37 +3059,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;
|
||||
@@ -3101,11 +3112,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;
|
||||
@@ -3330,7 +3341,7 @@ function userAvatarFallback(user) {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.adminUiScope .thumb--game {
|
||||
.adminUiScope .thumb--template {
|
||||
max-width: 150px;
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
@@ -3493,7 +3504,7 @@ function userAvatarFallback(user) {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.adminUiScope .customItemModal__createGameButton {
|
||||
.adminUiScope .customItemModal__createTemplateButton {
|
||||
justify-self: start;
|
||||
}
|
||||
.adminUiScope .customItemModal__body {
|
||||
@@ -3517,6 +3528,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;
|
||||
@@ -4479,8 +4503,8 @@ function userAvatarFallback(user) {
|
||||
}
|
||||
.adminUiScope .featuredOrderPanel,
|
||||
.adminUiScope .section--topGrid,
|
||||
.adminUiScope .gameManagerGrid,
|
||||
.adminUiScope .gameSettingsCard,
|
||||
.adminUiScope .templateManagerGrid,
|
||||
.adminUiScope .templateSettingsCard,
|
||||
.adminUiScope .toolbar,
|
||||
.adminUiScope .itemComposer,
|
||||
.adminUiScope .tierAdminCard,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import addColumnRightIcon from '../assets/icons/add_column_right.svg'
|
||||
import addRowBelowIcon from '../assets/icons/add_row_below.svg'
|
||||
import addPhotoAlternateIcon from '../assets/icons/add_photo_alternate.svg'
|
||||
import shareIcon from '../assets/icons/share.svg'
|
||||
import RightRailAd from '../components/RightRailAd.vue'
|
||||
import { api } from '../lib/api'
|
||||
import { editorNewPath, editorPath, loginPath, mePath, topicPath } from '../lib/paths'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
@@ -72,6 +73,7 @@ const favoriteCount = ref(0)
|
||||
const isFavorited = ref(false)
|
||||
const isRequestingTemplate = ref(false)
|
||||
const isDeleting = ref(false)
|
||||
const poolSearchQuery = ref('')
|
||||
|
||||
const boardEl = ref(null)
|
||||
const exportBoardEl = ref(null)
|
||||
@@ -85,7 +87,8 @@ const poolSortable = ref(null)
|
||||
const dropSortables = ref([])
|
||||
|
||||
const isNewTierList = computed(() => tierListId.value === 'new')
|
||||
const canEdit = computed(() => !!auth.user && (!ownerId.value || ownerId.value === auth.user.id))
|
||||
const isOwnTierList = computed(() => !!auth.user && !!ownerId.value && ownerId.value === auth.user.id)
|
||||
const canEdit = computed(() => !!auth.user && !previewMode.value && (!ownerId.value || ownerId.value === auth.user.id))
|
||||
const iconSizeOptions = [48, 64, 80, 96, 112]
|
||||
const hasCustomTitle = computed(() => !!(title.value || '').trim())
|
||||
const fallbackTimestamp = computed(() => (updatedAt.value ? updatedAt.value : Date.now()))
|
||||
@@ -113,7 +116,9 @@ const untitledWarning = computed(
|
||||
'제목 없이 저장된 티어표는 무분별한 도배 방지를 위해 관리자에 의해 임의 삭제될 수 있어요.'
|
||||
)
|
||||
const canFavorite = computed(() => !!auth.user && !isNewTierList.value && !canEdit.value)
|
||||
const canDuplicate = computed(() => !!auth.user && !isNewTierList.value && !canEdit.value)
|
||||
const canDuplicate = computed(() => !!auth.user && hasSavedTierList.value && !isOwnTierList.value)
|
||||
const canSwitchToViewerMode = computed(() => isOwnTierList.value && hasSavedTierList.value && !previewMode.value)
|
||||
const canSwitchToEditMode = computed(() => isOwnTierList.value && hasSavedTierList.value && previewMode.value)
|
||||
const copiedFromLabel = computed(() => {
|
||||
if (!sourceTierListId.value) return ''
|
||||
const parts = []
|
||||
@@ -122,6 +127,7 @@ const copiedFromLabel = computed(() => {
|
||||
return parts.join(' · ') || '복사해 온 티어표'
|
||||
})
|
||||
const customItems = computed(() => getOrderedItems().filter((item) => item?.origin === 'custom'))
|
||||
const normalizedPoolSearchQuery = computed(() => poolSearchQuery.value.trim().toLowerCase())
|
||||
const hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new')))
|
||||
const canRequestTemplateCreate = computed(
|
||||
() => canEdit.value && hasSavedTierList.value && templateId.value === 'freeform' && customItems.value.length > 0
|
||||
@@ -138,7 +144,6 @@ const shareTierListUrl = computed(() => {
|
||||
if (typeof window === 'undefined') return editorPath(templateId.value, savedTierListId, { preview: true })
|
||||
return new URL(editorPath(templateId.value, savedTierListId, { preview: true }), window.location.origin).toString()
|
||||
})
|
||||
|
||||
watch(error, (message) => {
|
||||
if (!message) return
|
||||
toast.error(message)
|
||||
@@ -166,8 +171,6 @@ function formatExportDate(ts) {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -194,6 +197,30 @@ function getOrderedItems() {
|
||||
return getOrderedItemIds().map((itemId) => itemsById.value[itemId]).filter(Boolean)
|
||||
}
|
||||
|
||||
function mergeLatestTemplateItemsIntoPool(savedItemsMap, savedPoolIds, currentTemplateItems, groupedIds, editable) {
|
||||
const nextMap = { ...(savedItemsMap || {}) }
|
||||
const nextPoolIds = Array.isArray(savedPoolIds) ? [...savedPoolIds] : []
|
||||
if (!editable) return { nextMap, nextPoolIds }
|
||||
|
||||
;(currentTemplateItems || []).forEach((item) => {
|
||||
if (!item?.id || nextMap[item.id]) return
|
||||
nextMap[item.id] = item
|
||||
if (!groupedIds?.has(item.id)) nextPoolIds.push(item.id)
|
||||
})
|
||||
|
||||
return { nextMap, nextPoolIds }
|
||||
}
|
||||
|
||||
function isPoolItemVisible(itemId) {
|
||||
const query = normalizedPoolSearchQuery.value
|
||||
if (!query) return true
|
||||
const item = itemsById.value[itemId]
|
||||
const label = String(item?.label || itemId || '').toLowerCase()
|
||||
return label.includes(query)
|
||||
}
|
||||
|
||||
const visiblePoolCount = computed(() => pool.value.filter((itemId) => isPoolItemVisible(itemId)).length)
|
||||
|
||||
function setIconSize(nextSize) {
|
||||
iconSize.value = nextSize
|
||||
}
|
||||
@@ -747,6 +774,16 @@ async function copyShareUrl() {
|
||||
}
|
||||
}
|
||||
|
||||
function openViewerMode() {
|
||||
if (!canSwitchToViewerMode.value) return
|
||||
router.push(editorPath(templateId.value, persistedTierListId.value || tierListId.value, { preview: true }))
|
||||
}
|
||||
|
||||
function openEditMode() {
|
||||
if (!canSwitchToEditMode.value) return
|
||||
router.push(editorPath(templateId.value, persistedTierListId.value || tierListId.value))
|
||||
}
|
||||
|
||||
function closeSaveModal() {
|
||||
isSaveModalOpen.value = false
|
||||
}
|
||||
@@ -897,6 +934,7 @@ onMounted(() => {
|
||||
return
|
||||
}
|
||||
|
||||
let currentTemplateItems = []
|
||||
try {
|
||||
const topicRes = await api.getTopic(templateId.value)
|
||||
templateName.value = topicRes.topic?.name || templateId.value
|
||||
@@ -904,8 +942,9 @@ onMounted(() => {
|
||||
id: img.id,
|
||||
src: img.src,
|
||||
label: img.label,
|
||||
origin: 'game',
|
||||
origin: 'template',
|
||||
}))
|
||||
currentTemplateItems = base
|
||||
const map = {}
|
||||
base.forEach((it) => (map[it.id] = it))
|
||||
itemsById.value = map
|
||||
@@ -934,14 +973,27 @@ onMounted(() => {
|
||||
sourceSnapshotAuthor.value = t.sourceSnapshotAuthor || ''
|
||||
favoriteCount.value = Number(t.favoriteCount || 0)
|
||||
isFavorited.value = !!t.isFavorited
|
||||
|
||||
if (!previewMode.value && !canEdit.value) {
|
||||
router.replace(editorPath(templateId.value, t.id, { preview: true }))
|
||||
return
|
||||
}
|
||||
|
||||
columns.value = normalizeLoadedColumns(t.groups)
|
||||
groups.value = normalizeLoadedGroups(t.groups, columns.value)
|
||||
const map = {}
|
||||
;(t.pool || []).forEach((it) => (map[it.id] = it))
|
||||
itemsById.value = map
|
||||
const grouped = new Set()
|
||||
groups.value.forEach((group) => group.itemIds.forEach((id) => grouped.add(id)))
|
||||
pool.value = Object.keys(itemsById.value).filter((id) => !grouped.has(id))
|
||||
const merged = mergeLatestTemplateItemsIntoPool(
|
||||
map,
|
||||
Object.keys(map).filter((id) => !grouped.has(id)),
|
||||
currentTemplateItems,
|
||||
grouped,
|
||||
canEdit.value && !previewMode.value
|
||||
)
|
||||
itemsById.value = merged.nextMap
|
||||
pool.value = merged.nextPoolIds
|
||||
} catch (e) {
|
||||
error.value = '티어표를 불러오지 못했어요.'
|
||||
}
|
||||
@@ -962,9 +1014,14 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<section v-if="previewMode" class="previewOnly" :style="{ '--thumb-size': `${iconSize}px` }">
|
||||
<header class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Preview</div>
|
||||
<h1 class="pageHead__title">{{ effectiveTitle }}</h1>
|
||||
<p v-if="description" class="pageHead__desc">{{ description }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="previewOnly__sheet">
|
||||
<div class="previewOnly__title">{{ effectiveTitle }}</div>
|
||||
<div v-if="description" class="previewOnly__description">{{ description }}</div>
|
||||
<div v-if="columns.length > 1" class="previewOnly__columns">
|
||||
<div class="previewOnly__columnsSpacer" aria-hidden="true"></div>
|
||||
<div class="previewOnly__columnsGrid" :style="{ '--column-count': columns.length }">
|
||||
@@ -1000,6 +1057,31 @@ onUnmounted(() => {
|
||||
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport :to="localRightRailTarget">
|
||||
<template v-if="globalRightRailOpen">
|
||||
<RightRailAd />
|
||||
|
||||
<div class="viewerSidebar__section">
|
||||
<div class="viewerSidebar__eyebrow">Viewer Mode</div>
|
||||
<div class="viewerSidebar__title">공유 티어표 보기</div>
|
||||
<p class="viewerSidebar__desc">
|
||||
{{ canSwitchToEditMode ? '지금은 드래그 없는 뷰어 모드입니다. 원하면 바로 수정 모드로 돌아갈 수 있어요.' : '드래그나 편집 없이 완성본만 보는 뷰어 모드입니다.' }}
|
||||
</p>
|
||||
<div class="viewerSidebar__actions">
|
||||
<button v-if="hasSavedTierList" class="btn btn--ghost viewerSidebar__button" type="button" @click="copyShareUrl">
|
||||
공유하기
|
||||
</button>
|
||||
<button v-if="canDuplicate" class="btn btn--save viewerSidebar__button" type="button" @click="duplicateCurrentTierList">
|
||||
내 티어표로 복사
|
||||
</button>
|
||||
<button v-if="canSwitchToEditMode" class="btn btn--save viewerSidebar__button" type="button" @click="openEditMode">
|
||||
수정 모드로 전환
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Teleport>
|
||||
</section>
|
||||
|
||||
<template v-else>
|
||||
@@ -1264,12 +1346,28 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="sidebar">
|
||||
<div class="sidebar__title">아이템</div>
|
||||
<div class="sidebar__titleRow">
|
||||
<div class="sidebar__title">아이템</div>
|
||||
<div class="sidebar__count">{{ visiblePoolCount }} / {{ pool.length }}</div>
|
||||
</div>
|
||||
<div class="sidebar__hint">
|
||||
{{ canEdit ? '등록된 아이템 리스트입니다. 드래그해서 표에 넣을 수 있습니다.' : '공개 티어표는 보기 전용입니다.' }}
|
||||
</div>
|
||||
<input
|
||||
v-model="poolSearchQuery"
|
||||
class="sidebar__search"
|
||||
type="text"
|
||||
maxlength="60"
|
||||
placeholder="아이템 이름 검색"
|
||||
/>
|
||||
<div ref="poolEl" class="pool" data-list-type="pool">
|
||||
<div v-for="id in pool" :key="id" class="poolItem" :class="{ 'poolItem--readonly': !canEdit }" :data-item-id="id">
|
||||
<div
|
||||
v-for="id in pool"
|
||||
:key="id"
|
||||
class="poolItem"
|
||||
:class="{ 'poolItem--readonly': !canEdit, 'poolItem--hidden': !isPoolItemVisible(id) }"
|
||||
:data-item-id="id"
|
||||
>
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
||||
<div v-if="!canEdit" class="poolItem__state">미배치</div>
|
||||
@@ -1309,6 +1407,11 @@ onUnmounted(() => {
|
||||
<div
|
||||
class="editorSidebar__thumbFrame"
|
||||
:class="{ 'editorSidebar__thumbFrame--active': isThumbnailDragActive }"
|
||||
:role="canEdit ? 'button' : undefined"
|
||||
:tabindex="canEdit ? 0 : undefined"
|
||||
@click="canEdit ? openThumbnailFile() : null"
|
||||
@keydown.enter.prevent="canEdit ? openThumbnailFile() : null"
|
||||
@keydown.space.prevent="canEdit ? openThumbnailFile() : null"
|
||||
@dragenter.prevent="onThumbnailDragEnter"
|
||||
@dragover.prevent="onThumbnailDragEnter"
|
||||
@dragleave="onThumbnailDragLeave"
|
||||
@@ -1318,7 +1421,6 @@ onUnmounted(() => {
|
||||
<div v-else class="editorSidebar__thumbEmpty">대표 썸네일</div>
|
||||
<div class="editorSidebar__thumbOverlay">드래그 또는 클릭으로 썸네일 추가</div>
|
||||
</div>
|
||||
<button v-if="canEdit" class="btn btn--ghost editorSidebar__button" @click="openThumbnailFile">파일 업로드</button>
|
||||
<div v-if="pendingThumbnailFile" class="editorSidebar__fileName">{{ pendingThumbnailFile.name }}</div>
|
||||
</div>
|
||||
|
||||
@@ -1366,6 +1468,7 @@ onUnmounted(() => {
|
||||
<button v-if="canEdit" class="btn btn--save editorSidebar__button" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
|
||||
</div>
|
||||
<div class="editorSidebar__utilityLinks">
|
||||
<button v-if="canSwitchToViewerMode" class="editorSidebar__utilityLink" @click="openViewerMode">뷰어 모드로 보기</button>
|
||||
<button v-if="hasSavedTierList" class="editorSidebar__utilityLink editorSidebar__utilityLink--share" @click="copyShareUrl">
|
||||
<SvgIcon :src="shareIcon" :size="16" />
|
||||
<span>공유하기</span>
|
||||
@@ -1440,30 +1543,18 @@ onUnmounted(() => {
|
||||
cursor: pointer;
|
||||
}
|
||||
.previewOnly {
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(96, 165, 250, 0.14), transparent 38%),
|
||||
var(--theme-shell-bg);
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.previewOnly__sheet {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.previewOnly__title {
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.previewOnly__description {
|
||||
margin-top: -8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
opacity: 0.76;
|
||||
padding: 18px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--theme-card-border);
|
||||
background: var(--theme-card-bg);
|
||||
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
|
||||
}
|
||||
.previewOnly__columns {
|
||||
display: grid;
|
||||
@@ -1562,6 +1653,48 @@ onUnmounted(() => {
|
||||
color: var(--theme-text-soft);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.viewerSidebar__section {
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 18px;
|
||||
border-radius: 22px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-pill-bg);
|
||||
}
|
||||
|
||||
.viewerSidebar__eyebrow {
|
||||
color: var(--theme-text-faint);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.viewerSidebar__title {
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--theme-text-strong);
|
||||
}
|
||||
|
||||
.viewerSidebar__desc {
|
||||
margin: 0;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.viewerSidebar__actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.viewerSidebar__button {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
margin-top: 0;
|
||||
}
|
||||
.toggleSwitch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -2443,6 +2576,30 @@ onUnmounted(() => {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.sidebar__titleRow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.sidebar__count {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--theme-text-soft);
|
||||
}
|
||||
.sidebar__search {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding: 11px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-card-bg);
|
||||
color: var(--theme-text);
|
||||
}
|
||||
.sidebar__search::placeholder {
|
||||
color: var(--theme-text-faint);
|
||||
}
|
||||
.pool {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
|
||||
@@ -2490,6 +2647,9 @@ onUnmounted(() => {
|
||||
text-transform: uppercase;
|
||||
color: var(--theme-text-soft);
|
||||
}
|
||||
.poolItem--hidden {
|
||||
display: none;
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -96,7 +96,8 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pageHead">
|
||||
<section class="pageWrap">
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Collection</div>
|
||||
<h2 class="pageHead__title">{{ topicTitle }}</h2>
|
||||
@@ -106,10 +107,10 @@ watch(
|
||||
<input v-model="query" class="input" placeholder="제목 또는 작성자 검색" @keydown.enter.prevent="submitSearch" />
|
||||
<button class="btn" @click="submitSearch">검색</button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<section class="panel">
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<section class="panel">
|
||||
<div v-if="tierLists.length === 0" class="empty">아직 공개 티어표가 없어요.</div>
|
||||
<div v-else class="list" :class="{ 'list--table': isListView }">
|
||||
<article v-for="t in tierLists" :key="t.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
|
||||
@@ -137,6 +138,7 @@ watch(
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -319,7 +321,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;
|
||||
}
|
||||
Reference in New Issue
Block a user