Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a87e3b1ec | |||
| 136db137ec | |||
| 1fabf66f04 | |||
| 9b97a7c23b | |||
| 9b0a6d8f15 | |||
| 5af5202455 | |||
| 6b6676ceec | |||
| de640de4a1 | |||
| 20955e277c | |||
| 1ed08d1e34 | |||
| a733c97991 | |||
| 31613e4613 | |||
| d5621362f1 | |||
| caaddb8448 | |||
| 20186f7fe2 | |||
| 77605791fb | |||
| 9bb64b52f3 | |||
| c4d896ce36 | |||
| 1957f30341 | |||
| 8d257e21ff |
@@ -74,12 +74,14 @@ app.use(async (req, res, next) => {
|
||||
await ensureData()
|
||||
next()
|
||||
} catch (e) {
|
||||
console.error('[backend] db init failed', e)
|
||||
res.status(500).json({ error: 'db_init_failed' })
|
||||
}
|
||||
})
|
||||
|
||||
app.use('/api/auth', authRoutes)
|
||||
app.use('/api/games', gamesRoutes)
|
||||
app.use('/api/topics', gamesRoutes)
|
||||
app.use('/api/tierlists', tierListsRoutes)
|
||||
app.use('/api/admin', adminRoutes)
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ const DB_USER = process.env.DB_USER || 'root'
|
||||
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_GAME_ID = 'freeform'
|
||||
const FREEFORM_TOPIC_ID = 'freeform'
|
||||
const FREEFORM_GAME_ID = FREEFORM_TOPIC_ID
|
||||
|
||||
let poolPromise = null
|
||||
let initPromise = null
|
||||
@@ -72,6 +73,8 @@ function mapGameRow(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
topicId: row.id,
|
||||
topicName: row.name,
|
||||
thumbnailSrc: row.thumbnail_src || '',
|
||||
isPublic: row.is_public == null ? true : !!row.is_public,
|
||||
displayRank: row.display_rank == null ? null : Number(row.display_rank),
|
||||
@@ -83,7 +86,8 @@ function mapGameItemRow(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
id: row.id,
|
||||
gameId: row.game_id,
|
||||
topicId: row.topic_id,
|
||||
gameId: row.topic_id,
|
||||
src: row.src,
|
||||
label: row.label,
|
||||
displayOrder: row.display_order == null ? null : Number(row.display_order),
|
||||
@@ -131,8 +135,10 @@ function mapTierListRow(row) {
|
||||
authorName: getUserDisplayName(row),
|
||||
authorAccountName: getUserAccountName(row),
|
||||
authorAvatarSrc: row.avatar_src || '',
|
||||
gameId: row.game_id,
|
||||
gameName: row.game_name || '',
|
||||
topicId: row.topic_id,
|
||||
topicName: row.topic_name || '',
|
||||
gameId: row.topic_id,
|
||||
gameName: row.topic_name || '',
|
||||
title: row.title,
|
||||
thumbnailSrc: row.thumbnail_src || '',
|
||||
description: row.description || '',
|
||||
@@ -159,13 +165,17 @@ function mapTemplateRequestRow(row) {
|
||||
requesterAccountName: getUserAccountName(row),
|
||||
requesterAvatarSrc: row.requester_avatar_src || '',
|
||||
sourceTierListId: row.source_tierlist_id || '',
|
||||
sourceGameId: row.source_game_id,
|
||||
sourceGameName: row.source_game_name || '',
|
||||
sourceTopicId: row.source_topic_id,
|
||||
sourceTopicName: row.source_topic_name || '',
|
||||
sourceGameId: row.source_topic_id,
|
||||
sourceGameName: row.source_topic_name || '',
|
||||
sourceTierListTitle: row.title_snapshot || '',
|
||||
sourceDescription: row.description_snapshot || '',
|
||||
thumbnailSrc: row.thumbnail_src_snapshot || '',
|
||||
targetGameId: row.target_game_id || '',
|
||||
targetGameName: row.target_game_name || '',
|
||||
targetTopicId: row.target_topic_id || '',
|
||||
targetTopicName: row.target_topic_name || '',
|
||||
targetGameId: row.target_topic_id || '',
|
||||
targetGameName: row.target_topic_name || '',
|
||||
status: row.status,
|
||||
items: parseJson(row.items_json, []),
|
||||
snapshotGroups: parseJson(row.groups_json, []),
|
||||
@@ -230,6 +240,16 @@ async function query(sql, params = []) {
|
||||
return rows
|
||||
}
|
||||
|
||||
async function tableExists(name) {
|
||||
const rows = await query('SHOW TABLES LIKE ?', [name])
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
async function columnExists(tableName, columnName) {
|
||||
const rows = await query(`SHOW COLUMNS FROM \`${tableName}\` LIKE ?`, [columnName])
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
async function closePool() {
|
||||
if (!poolPromise) return
|
||||
const pool = await poolPromise
|
||||
@@ -241,6 +261,10 @@ 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,
|
||||
@@ -254,7 +278,7 @@ async function ensureSchema() {
|
||||
`)
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS games (
|
||||
CREATE TABLE IF NOT EXISTS topics (
|
||||
id VARCHAR(120) PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
|
||||
@@ -264,33 +288,52 @@ async function ensureSchema() {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
const gameIsPublicColumns = await query("SHOW COLUMNS FROM games LIKE 'is_public'")
|
||||
const gameIsPublicColumns = await query("SHOW COLUMNS FROM topics LIKE 'is_public'")
|
||||
if (!gameIsPublicColumns.length) {
|
||||
await query('ALTER TABLE games ADD COLUMN is_public TINYINT(1) NOT NULL DEFAULT 1 AFTER thumbnail_src')
|
||||
await query('UPDATE games SET is_public = 1 WHERE is_public IS NULL')
|
||||
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')
|
||||
}
|
||||
|
||||
const displayRankColumns = await query("SHOW COLUMNS FROM games LIKE 'display_rank'")
|
||||
const displayRankColumns = await query("SHOW COLUMNS FROM topics LIKE 'display_rank'")
|
||||
if (!displayRankColumns.length) {
|
||||
await query('ALTER TABLE games ADD COLUMN display_rank INT NULL DEFAULT NULL AFTER thumbnail_src')
|
||||
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 game_items (
|
||||
CREATE TABLE IF NOT EXISTS topic_items (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
game_id VARCHAR(120) NOT NULL,
|
||||
topic_id VARCHAR(120) NOT NULL,
|
||||
src VARCHAR(255) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
display_order INT NULL DEFAULT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
INDEX idx_game_items_game_id (game_id),
|
||||
CONSTRAINT fk_game_items_game FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
|
||||
INDEX idx_topic_items_topic_id (topic_id),
|
||||
CONSTRAINT fk_topic_items_topic FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
const gameItemDisplayOrderColumns = await query("SHOW COLUMNS FROM game_items LIKE 'display_order'")
|
||||
const gameItemDisplayOrderColumns = await query("SHOW COLUMNS FROM topic_items LIKE 'display_order'")
|
||||
if (!gameItemDisplayOrderColumns.length) {
|
||||
await query('ALTER TABLE game_items ADD COLUMN display_order INT NULL DEFAULT NULL AFTER label')
|
||||
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(`
|
||||
@@ -309,7 +352,7 @@ async function ensureSchema() {
|
||||
CREATE TABLE IF NOT EXISTS tierlists (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
author_id VARCHAR(64) NOT NULL,
|
||||
game_id VARCHAR(120) NOT NULL,
|
||||
topic_id VARCHAR(120) NOT NULL,
|
||||
title VARCHAR(120) NOT NULL,
|
||||
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL,
|
||||
@@ -324,10 +367,10 @@ async function ensureSchema() {
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
INDEX idx_tierlists_author_id (author_id),
|
||||
INDEX idx_tierlists_game_id (game_id),
|
||||
INDEX idx_tierlists_public_game_updated (is_public, game_id, updated_at),
|
||||
INDEX idx_tierlists_topic_id (topic_id),
|
||||
INDEX idx_tierlists_public_topic_updated (is_public, topic_id, updated_at),
|
||||
CONSTRAINT fk_tierlists_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tierlists_game FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
|
||||
CONSTRAINT fk_tierlists_topic FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
@@ -344,17 +387,25 @@ async function ensureSchema() {
|
||||
`)
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS favorite_games (
|
||||
CREATE TABLE IF NOT EXISTS favorite_topics (
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
game_id VARCHAR(120) NOT NULL,
|
||||
topic_id VARCHAR(120) NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
PRIMARY KEY (user_id, game_id),
|
||||
INDEX idx_favorite_games_game_id (game_id),
|
||||
CONSTRAINT fk_favorite_games_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_favorite_games_game FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
|
||||
PRIMARY KEY (user_id, topic_id),
|
||||
INDEX idx_favorite_topics_topic_id (topic_id),
|
||||
CONSTRAINT fk_favorite_topics_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_favorite_topics_topic FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE
|
||||
) 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,
|
||||
@@ -399,8 +450,8 @@ async function ensureSchema() {
|
||||
request_type VARCHAR(20) NOT NULL,
|
||||
requester_id VARCHAR(64) NOT NULL,
|
||||
source_tierlist_id VARCHAR(64) NOT NULL,
|
||||
source_game_id VARCHAR(120) NOT NULL,
|
||||
target_game_id VARCHAR(120) NOT NULL DEFAULT '',
|
||||
source_topic_id VARCHAR(120) NOT NULL,
|
||||
target_topic_id VARCHAR(120) NOT NULL DEFAULT '',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
title_snapshot VARCHAR(120) NOT NULL,
|
||||
description_snapshot TEXT NOT NULL,
|
||||
@@ -424,17 +475,23 @@ async function ensureSchema() {
|
||||
if (!templateRequestTypeColumns.length) {
|
||||
await query("ALTER TABLE template_requests ADD COLUMN request_type VARCHAR(20) NOT NULL DEFAULT 'create' AFTER id")
|
||||
}
|
||||
const templateRequestSourceGameColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'source_game_id'")
|
||||
const templateRequestSourceGameColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'source_topic_id'")
|
||||
if (!templateRequestSourceGameColumns.length) {
|
||||
await query("ALTER TABLE template_requests ADD COLUMN source_game_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER source_tierlist_id")
|
||||
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 templateRequestTargetGameColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'target_game_id'")
|
||||
const templateRequestTargetGameColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'target_topic_id'")
|
||||
if (!templateRequestTargetGameColumns.length) {
|
||||
await query("ALTER TABLE template_requests ADD COLUMN target_game_id VARCHAR(120) NOT NULL DEFAULT '' AFTER source_game_id")
|
||||
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) {
|
||||
await query("ALTER TABLE template_requests ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending' AFTER target_game_id")
|
||||
await query("ALTER TABLE template_requests ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending' AFTER target_topic_id")
|
||||
}
|
||||
const templateRequestGroupsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'groups_json'")
|
||||
if (!templateRequestGroupsColumns.length) {
|
||||
@@ -453,6 +510,13 @@ async function ensureSchema() {
|
||||
if (!tierListThumbnailColumns.length) {
|
||||
await query("ALTER TABLE tierlists ADD COLUMN thumbnail_src VARCHAR(255) NOT NULL DEFAULT '' AFTER title")
|
||||
}
|
||||
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) {
|
||||
await query("ALTER TABLE tierlists ADD COLUMN show_character_names TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public")
|
||||
@@ -478,19 +542,19 @@ async function ensureSchema() {
|
||||
|
||||
await query(
|
||||
`
|
||||
INSERT INTO games (id, name, thumbnail_src, created_at)
|
||||
INSERT INTO topics (id, name, thumbnail_src, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE name = VALUES(name)
|
||||
`,
|
||||
[FREEFORM_GAME_ID, '직접 티어표 만들기', '', now()]
|
||||
[FREEFORM_TOPIC_ID, '직접 티어표 만들기', '', now()]
|
||||
)
|
||||
|
||||
const countRows = await query('SELECT COUNT(*) AS count FROM games')
|
||||
const countRows = await query('SELECT COUNT(*) AS count FROM topics')
|
||||
if (Number(countRows[0]?.count || 0) <= 1) {
|
||||
const createdAt = now()
|
||||
await query(
|
||||
`
|
||||
INSERT INTO games (id, name, thumbnail_src, created_at)
|
||||
INSERT INTO topics (id, name, thumbnail_src, created_at)
|
||||
VALUES
|
||||
(?, ?, ?, ?),
|
||||
(?, ?, ?, ?)
|
||||
@@ -500,7 +564,7 @@ async function ensureSchema() {
|
||||
|
||||
await query(
|
||||
`
|
||||
INSERT INTO game_items (id, game_id, src, label, created_at)
|
||||
INSERT INTO topic_items (id, topic_id, src, label, created_at)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?),
|
||||
(?, ?, ?, ?, ?)
|
||||
@@ -662,7 +726,7 @@ async function listGames(currentUserId = '', options = {}) {
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT id, name, thumbnail_src, is_public, display_rank, created_at
|
||||
FROM games
|
||||
FROM topics
|
||||
WHERE id <> ?
|
||||
${includePrivate ? '' : 'AND is_public = 1'}
|
||||
ORDER BY
|
||||
@@ -671,13 +735,13 @@ async function listGames(currentUserId = '', options = {}) {
|
||||
created_at DESC,
|
||||
name ASC
|
||||
`,
|
||||
[FREEFORM_GAME_ID]
|
||||
[FREEFORM_TOPIC_ID]
|
||||
)
|
||||
const games = rows.map(mapGameRow)
|
||||
if (!currentUserId) return games.map((game) => ({ ...game, isFavorited: false }))
|
||||
|
||||
const favoriteRows = await query('SELECT game_id FROM favorite_games WHERE user_id = ?', [currentUserId])
|
||||
const favoriteSet = new Set(favoriteRows.map((row) => row.game_id))
|
||||
const favoriteRows = await query('SELECT topic_id FROM favorite_topics WHERE user_id = ?', [currentUserId])
|
||||
const favoriteSet = new Set(favoriteRows.map((row) => row.topic_id))
|
||||
return games.map((game) => ({
|
||||
...game,
|
||||
isFavorited: favoriteSet.has(game.id),
|
||||
@@ -685,16 +749,16 @@ async function listGames(currentUserId = '', options = {}) {
|
||||
}
|
||||
|
||||
async function findGameById(id) {
|
||||
const rows = await query('SELECT id, name, thumbnail_src, is_public, display_rank, created_at FROM games WHERE id = ? LIMIT 1', [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])
|
||||
}
|
||||
|
||||
async function listGameItems(gameId) {
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT id, game_id, src, label, display_order, created_at
|
||||
FROM game_items
|
||||
WHERE game_id = ?
|
||||
SELECT id, topic_id, src, label, display_order, created_at
|
||||
FROM topic_items
|
||||
WHERE topic_id = ?
|
||||
ORDER BY
|
||||
CASE WHEN display_order IS NULL THEN 1 ELSE 0 END ASC,
|
||||
display_order ASC,
|
||||
@@ -707,7 +771,7 @@ async function listGameItems(gameId) {
|
||||
}
|
||||
|
||||
async function findGameItemById(itemId) {
|
||||
const rows = await query('SELECT id, game_id, src, label, display_order, created_at FROM game_items WHERE id = ? LIMIT 1', [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])
|
||||
}
|
||||
|
||||
@@ -719,7 +783,7 @@ async function getGameDetail(gameId) {
|
||||
}
|
||||
|
||||
async function createGame({ id, name, isPublic = true }) {
|
||||
await query('INSERT INTO games (id, name, thumbnail_src, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?)', [
|
||||
await query('INSERT INTO topics (id, name, thumbnail_src, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?)', [
|
||||
id,
|
||||
name,
|
||||
'',
|
||||
@@ -731,12 +795,12 @@ async function createGame({ id, name, isPublic = true }) {
|
||||
}
|
||||
|
||||
async function updateGameThumbnail(gameId, thumbnailSrc) {
|
||||
await query('UPDATE games SET thumbnail_src = ? WHERE id = ?', [thumbnailSrc, gameId])
|
||||
await query('UPDATE topics SET thumbnail_src = ? WHERE id = ?', [thumbnailSrc, gameId])
|
||||
return findGameById(gameId)
|
||||
}
|
||||
|
||||
async function updateGameVisibility(gameId, isPublic) {
|
||||
await query('UPDATE games SET is_public = ? WHERE id = ?', [isPublic ? 1 : 0, gameId])
|
||||
await query('UPDATE topics SET is_public = ? WHERE id = ?', [isPublic ? 1 : 0, gameId])
|
||||
return findGameById(gameId)
|
||||
}
|
||||
|
||||
@@ -838,8 +902,8 @@ async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
|
||||
|
||||
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
|
||||
query("SELECT avatar_src FROM users WHERE avatar_src <> ''"),
|
||||
query("SELECT thumbnail_src FROM games WHERE thumbnail_src <> ''"),
|
||||
query("SELECT src FROM game_items WHERE src <> ''"),
|
||||
query("SELECT thumbnail_src FROM topics WHERE thumbnail_src <> ''"),
|
||||
query("SELECT src FROM topic_items WHERE src <> ''"),
|
||||
query("SELECT src FROM custom_items WHERE src <> ''"),
|
||||
query("SELECT thumbnail_src, pool_json FROM tierlists"),
|
||||
query("SELECT thumbnail_src_snapshot, items_json, board_items_json FROM template_requests"),
|
||||
@@ -891,8 +955,8 @@ async function listReferencedUploadUsage() {
|
||||
|
||||
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
|
||||
query("SELECT avatar_src FROM users WHERE avatar_src <> ''"),
|
||||
query("SELECT thumbnail_src FROM games WHERE thumbnail_src <> ''"),
|
||||
query("SELECT src FROM game_items WHERE src <> ''"),
|
||||
query("SELECT thumbnail_src FROM topics WHERE thumbnail_src <> ''"),
|
||||
query("SELECT src FROM topic_items WHERE src <> ''"),
|
||||
query("SELECT src FROM custom_items WHERE src <> ''"),
|
||||
query("SELECT id, thumbnail_src, pool_json FROM tierlists"),
|
||||
query("SELECT id, thumbnail_src_snapshot, items_json, board_items_json FROM template_requests"),
|
||||
@@ -934,8 +998,8 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
|
||||
|
||||
const [userResult, gameResult, gameItemResult, customItemResult] = await Promise.all([
|
||||
query('UPDATE users SET avatar_src = ? WHERE avatar_src = ?', [toSrc, fromSrc]),
|
||||
query('UPDATE games SET thumbnail_src = ? WHERE thumbnail_src = ?', [toSrc, fromSrc]),
|
||||
query('UPDATE game_items SET src = ? WHERE 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]),
|
||||
])
|
||||
|
||||
@@ -1099,8 +1163,8 @@ async function cleanupMissingUploadReferences() {
|
||||
|
||||
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
|
||||
query("SELECT id, avatar_src FROM users WHERE avatar_src <> ''"),
|
||||
query("SELECT id, thumbnail_src FROM games WHERE thumbnail_src <> ''"),
|
||||
query("SELECT id, src FROM game_items WHERE src <> ''"),
|
||||
query("SELECT id, thumbnail_src FROM topics WHERE thumbnail_src <> ''"),
|
||||
query("SELECT id, src FROM topic_items WHERE src <> ''"),
|
||||
query("SELECT id, src FROM custom_items WHERE src <> ''"),
|
||||
query("SELECT id, thumbnail_src, groups_json, pool_json FROM tierlists"),
|
||||
query("SELECT id, thumbnail_src_snapshot, groups_json, items_json, board_items_json FROM template_requests"),
|
||||
@@ -1114,7 +1178,7 @@ async function cleanupMissingUploadReferences() {
|
||||
|
||||
for (const row of gameRows) {
|
||||
if (await fileExistsForUploadSrc(row.thumbnail_src)) continue
|
||||
await query('UPDATE games SET thumbnail_src = ? WHERE id = ?', ['', row.id])
|
||||
await query('UPDATE topics SET thumbnail_src = ? WHERE id = ?', ['', row.id])
|
||||
stats.clearedGameThumbnails += 1
|
||||
}
|
||||
|
||||
@@ -1269,10 +1333,10 @@ async function clearImageOptimizationJobs({ month } = {}) {
|
||||
}
|
||||
async function createGameItem({ id, gameId, src, label }) {
|
||||
const createdAt = now()
|
||||
const minOrderRows = await query('SELECT MIN(display_order) AS min_display_order FROM game_items WHERE game_id = ?', [gameId])
|
||||
const minOrderRows = await query('SELECT MIN(display_order) AS min_display_order FROM topic_items WHERE topic_id = ?', [gameId])
|
||||
const nextDisplayOrder =
|
||||
minOrderRows[0]?.min_display_order == null ? 0 : Number(minOrderRows[0].min_display_order) - 1
|
||||
await query('INSERT INTO game_items (id, game_id, src, label, display_order, created_at) VALUES (?, ?, ?, ?, ?, ?)', [
|
||||
await query('INSERT INTO topic_items (id, topic_id, src, label, display_order, created_at) VALUES (?, ?, ?, ?, ?, ?)', [
|
||||
id,
|
||||
gameId,
|
||||
src,
|
||||
@@ -1280,13 +1344,13 @@ async function createGameItem({ id, gameId, src, label }) {
|
||||
nextDisplayOrder,
|
||||
createdAt,
|
||||
])
|
||||
const rows = await query('SELECT id, game_id, src, label, display_order, created_at FROM game_items WHERE id = ? LIMIT 1', [id])
|
||||
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])
|
||||
}
|
||||
|
||||
async function updateGameItemLabel(itemId, label) {
|
||||
await query('UPDATE game_items SET label = ? WHERE id = ?', [label, itemId])
|
||||
const rows = await query('SELECT id, game_id, src, label, display_order, created_at FROM game_items WHERE id = ? LIMIT 1', [itemId])
|
||||
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])
|
||||
}
|
||||
|
||||
@@ -1299,7 +1363,7 @@ async function updateGameItemDisplayOrder(gameId, itemIds) {
|
||||
const finalIds = [...orderedIds, ...remainingIds]
|
||||
|
||||
await Promise.all(
|
||||
finalIds.map((itemId, index) => query('UPDATE game_items SET display_order = ? WHERE id = ? AND game_id = ?', [index + 1, itemId, gameId]))
|
||||
finalIds.map((itemId, index) => query('UPDATE topic_items SET display_order = ? WHERE id = ? AND topic_id = ?', [index + 1, itemId, gameId]))
|
||||
)
|
||||
|
||||
return listGameItems(gameId)
|
||||
@@ -1334,15 +1398,15 @@ async function updateImageAssetLabel(assetId, label) {
|
||||
}
|
||||
|
||||
async function deleteGameItem(itemId) {
|
||||
const gameItemRows = await query('SELECT game_id FROM game_items WHERE id = ? LIMIT 1', [itemId])
|
||||
const gameId = gameItemRows[0]?.game_id
|
||||
const gameItemRows = await query('SELECT topic_id FROM topic_items WHERE id = ? LIMIT 1', [itemId])
|
||||
const gameId = gameItemRows[0]?.topic_id
|
||||
|
||||
if (gameId) {
|
||||
const tierListRows = await query(
|
||||
`
|
||||
SELECT id, author_id, game_id, title, description, is_public, groups_json, pool_json, created_at, updated_at
|
||||
SELECT id, author_id, topic_id, title, description, is_public, groups_json, pool_json, created_at, updated_at
|
||||
FROM tierlists
|
||||
WHERE game_id = ?
|
||||
WHERE topic_id = ?
|
||||
`,
|
||||
[gameId]
|
||||
)
|
||||
@@ -1362,21 +1426,21 @@ async function deleteGameItem(itemId) {
|
||||
}
|
||||
}
|
||||
|
||||
await query('DELETE FROM game_items WHERE id = ?', [itemId])
|
||||
await query('DELETE FROM topic_items WHERE id = ?', [itemId])
|
||||
}
|
||||
|
||||
async function deleteGame(gameId) {
|
||||
await query('DELETE FROM games WHERE id = ?', [gameId])
|
||||
await query('DELETE FROM topics WHERE id = ?', [gameId])
|
||||
}
|
||||
|
||||
async function updateGameDisplayOrder(gameIds) {
|
||||
const normalizedIds = Array.from(new Set((gameIds || []).filter((id) => id && id !== FREEFORM_GAME_ID))).slice(0, 50)
|
||||
const normalizedIds = Array.from(new Set((gameIds || []).filter((id) => id && id !== FREEFORM_TOPIC_ID))).slice(0, 50)
|
||||
|
||||
await query('UPDATE games SET display_rank = NULL WHERE id <> ?', [FREEFORM_GAME_ID])
|
||||
await query('UPDATE topics SET display_rank = NULL WHERE id <> ?', [FREEFORM_TOPIC_ID])
|
||||
|
||||
await Promise.all(
|
||||
normalizedIds.map((gameId, index) =>
|
||||
query('UPDATE games SET display_rank = ? WHERE id = ? AND id <> ?', [index + 1, gameId, FREEFORM_GAME_ID])
|
||||
query('UPDATE topics SET display_rank = ? WHERE id = ? AND id <> ?', [index + 1, gameId, FREEFORM_TOPIC_ID])
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1438,9 +1502,9 @@ async function findCustomItemById(id) {
|
||||
async function getCustomItemUsageMeta() {
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT t.game_id, g.name AS game_name, t.groups_json, t.pool_json
|
||||
SELECT t.topic_id, tp.name AS topic_name, t.groups_json, t.pool_json
|
||||
FROM tierlists t
|
||||
LEFT JOIN games g ON g.id = t.game_id
|
||||
LEFT JOIN topics tp ON tp.id = t.topic_id
|
||||
`
|
||||
)
|
||||
const usageMap = new Map()
|
||||
@@ -1465,13 +1529,13 @@ async function getCustomItemUsageMeta() {
|
||||
}
|
||||
})
|
||||
|
||||
if (!row.game_id) return
|
||||
if (!row.topic_id) return
|
||||
|
||||
seenItemIds.forEach((itemId) => {
|
||||
if (!linkedGamesMap.has(itemId)) linkedGamesMap.set(itemId, new Map())
|
||||
linkedGamesMap.get(itemId).set(row.game_id, {
|
||||
id: row.game_id,
|
||||
name: row.game_name || row.game_id,
|
||||
linkedGamesMap.get(itemId).set(row.topic_id, {
|
||||
id: row.topic_id,
|
||||
name: row.topic_name || row.topic_id,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1511,14 +1575,14 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
`
|
||||
SELECT
|
||||
gi.id,
|
||||
gi.game_id,
|
||||
gi.topic_id,
|
||||
gi.src,
|
||||
gi.label,
|
||||
gi.created_at,
|
||||
g.name AS game_name
|
||||
FROM game_items gi
|
||||
INNER JOIN games g ON g.id = gi.game_id
|
||||
${hasQuery ? 'WHERE gi.label LIKE ? OR gi.src LIKE ? OR gi.game_id LIKE ? OR g.name LIKE ?' : ''}
|
||||
tp.name AS topic_name
|
||||
FROM topic_items gi
|
||||
INNER JOIN topics tp ON tp.id = gi.topic_id
|
||||
${hasQuery ? 'WHERE gi.label LIKE ? OR gi.src LIKE ? OR gi.topic_id LIKE ? OR tp.name LIKE ?' : ''}
|
||||
ORDER BY gi.created_at DESC
|
||||
`,
|
||||
hasQuery ? [search, search, search, search] : []
|
||||
@@ -1540,9 +1604,9 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
gameItemRows.forEach((row) => {
|
||||
if (!row?.src) return
|
||||
if (!templateLinkedBySrc.has(row.src)) templateLinkedBySrc.set(row.src, new Map())
|
||||
templateLinkedBySrc.get(row.src).set(row.game_id, {
|
||||
id: row.game_id,
|
||||
name: row.game_name || row.game_id,
|
||||
templateLinkedBySrc.get(row.src).set(row.topic_id, {
|
||||
id: row.topic_id,
|
||||
name: row.topic_name || row.topic_id,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1593,15 +1657,15 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
src: row.src,
|
||||
label: row.label,
|
||||
createdAt: Number(row.created_at),
|
||||
ownerName: row.game_name || row.game_id,
|
||||
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()),
|
||||
sourceType: 'template',
|
||||
sourceLabel: '관리자 템플릿',
|
||||
canDelete: true,
|
||||
sourceGameId: row.game_id,
|
||||
sourceGameName: row.game_name || row.game_id,
|
||||
sourceGameId: row.topic_id,
|
||||
sourceGameName: row.topic_name || row.topic_id,
|
||||
}))
|
||||
|
||||
const baseItems = [...customItems, ...templateItems, ...assetLibraryItems]
|
||||
@@ -1767,12 +1831,12 @@ function applyFavoriteMetaToTierLists(tierLists, favoriteStats) {
|
||||
}))
|
||||
}
|
||||
|
||||
async function listPublicTierLists(gameId, currentUserId = '', queryText = '') {
|
||||
async function listPublicTierLists(topicId, currentUserId = '', queryText = '') {
|
||||
const params = []
|
||||
let whereClause = 'WHERE t.is_public = 1'
|
||||
if (gameId) {
|
||||
whereClause += ' AND t.game_id = ?'
|
||||
params.push(gameId)
|
||||
if (topicId) {
|
||||
whereClause += ' AND t.topic_id = ?'
|
||||
params.push(topicId)
|
||||
}
|
||||
if ((queryText || '').trim()) {
|
||||
const search = `%${queryText.trim()}%`
|
||||
@@ -1784,7 +1848,7 @@ async function listPublicTierLists(gameId, currentUserId = '', queryText = '') {
|
||||
`
|
||||
SELECT
|
||||
t.id,
|
||||
t.game_id,
|
||||
t.topic_id,
|
||||
t.title,
|
||||
t.thumbnail_src,
|
||||
t.created_at,
|
||||
@@ -1804,7 +1868,8 @@ async function listPublicTierLists(gameId, currentUserId = '', queryText = '') {
|
||||
|
||||
const tierLists = rows.map((row) => ({
|
||||
id: row.id,
|
||||
gameId: row.game_id,
|
||||
topicId: row.topic_id,
|
||||
gameId: row.topic_id,
|
||||
title: row.title,
|
||||
thumbnailSrc: row.thumbnail_src || '',
|
||||
createdAt: Number(row.created_at),
|
||||
@@ -1830,7 +1895,7 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited
|
||||
|
||||
if ((queryText || '').trim()) {
|
||||
const search = `%${queryText.trim()}%`
|
||||
whereClause += ' AND (t.title LIKE ? OR g.name LIKE ? OR u.nickname LIKE ? OR u.email LIKE ?)'
|
||||
whereClause += ' AND (t.title LIKE ? OR tp.name LIKE ? OR u.nickname LIKE ? OR u.email LIKE ?)'
|
||||
params.push(search, search, search, search)
|
||||
}
|
||||
|
||||
@@ -1846,8 +1911,8 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited
|
||||
SELECT
|
||||
t.id,
|
||||
t.author_id,
|
||||
t.game_id,
|
||||
g.name AS game_name,
|
||||
t.topic_id,
|
||||
tp.name AS topic_name,
|
||||
t.title,
|
||||
t.thumbnail_src,
|
||||
t.description,
|
||||
@@ -1873,7 +1938,7 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited
|
||||
FROM favorite_tierlists f
|
||||
INNER JOIN tierlists t ON t.id = f.tierlist_id
|
||||
INNER JOIN users u ON u.id = t.author_id
|
||||
INNER JOIN games g ON g.id = t.game_id
|
||||
INNER JOIN topics tp ON tp.id = t.topic_id
|
||||
${whereClause}
|
||||
${orderClause}
|
||||
`,
|
||||
@@ -1893,7 +1958,7 @@ async function listUserTierLists(userId) {
|
||||
`
|
||||
SELECT
|
||||
t.id,
|
||||
t.game_id,
|
||||
t.topic_id,
|
||||
t.title,
|
||||
t.thumbnail_src,
|
||||
t.created_at,
|
||||
@@ -1912,7 +1977,8 @@ async function listUserTierLists(userId) {
|
||||
|
||||
const tierLists = rows.map((row) => ({
|
||||
id: row.id,
|
||||
gameId: row.game_id,
|
||||
topicId: row.topic_id,
|
||||
gameId: row.topic_id,
|
||||
title: row.title,
|
||||
thumbnailSrc: row.thumbnail_src || '',
|
||||
createdAt: Number(row.created_at),
|
||||
@@ -1958,25 +2024,26 @@ function getAutoThumbnailSrc(groups = [], pool = []) {
|
||||
return fallbackItem?.src || ''
|
||||
}
|
||||
|
||||
async function listAdminTierLists({ queryText = '', gameId = '', page = 1, limit = 50, currentUserId = '' } = {}) {
|
||||
async function listAdminTierLists({ queryText = '', gameId = '', topicId = '', page = 1, limit = 50, currentUserId = '' } = {}) {
|
||||
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
|
||||
const normalizedPage = Math.max(Number(page) || 1, 1)
|
||||
const hasQuery = !!(queryText || '').trim()
|
||||
const hasGameId = !!(gameId || '').trim()
|
||||
const resolvedTopicId = (topicId || gameId || '').trim()
|
||||
const hasGameId = !!resolvedTopicId
|
||||
const search = `%${(queryText || '').trim()}%`
|
||||
const whereParts = []
|
||||
const params = []
|
||||
|
||||
if (hasGameId) {
|
||||
whereParts.push('t.game_id = ?')
|
||||
params.push((gameId || '').trim())
|
||||
whereParts.push('t.topic_id = ?')
|
||||
params.push(resolvedTopicId)
|
||||
}
|
||||
|
||||
if (hasQuery) {
|
||||
whereParts.push(`(
|
||||
t.title LIKE ?
|
||||
OR g.name LIKE ?
|
||||
OR g.id LIKE ?
|
||||
OR tp.name LIKE ?
|
||||
OR tp.id LIKE ?
|
||||
OR u.email LIKE ?
|
||||
OR u.nickname LIKE ?
|
||||
)`)
|
||||
@@ -1990,8 +2057,8 @@ async function listAdminTierLists({ queryText = '', gameId = '', page = 1, limit
|
||||
SELECT
|
||||
t.id,
|
||||
t.author_id,
|
||||
t.game_id,
|
||||
g.name AS game_name,
|
||||
t.topic_id,
|
||||
tp.name AS topic_name,
|
||||
t.title,
|
||||
t.thumbnail_src,
|
||||
t.description,
|
||||
@@ -2010,7 +2077,7 @@ async function listAdminTierLists({ queryText = '', gameId = '', page = 1, limit
|
||||
u.avatar_src
|
||||
FROM tierlists t
|
||||
INNER JOIN users u ON u.id = t.author_id
|
||||
INNER JOIN games g ON g.id = t.game_id
|
||||
INNER JOIN topics tp ON tp.id = t.topic_id
|
||||
${whereClause}
|
||||
ORDER BY t.updated_at DESC, t.created_at DESC
|
||||
`,
|
||||
@@ -2044,23 +2111,24 @@ async function listAdminTierLists({ queryText = '', gameId = '', page = 1, limit
|
||||
}
|
||||
}
|
||||
|
||||
async function summarizeAdminTierLists({ queryText = '', gameId = '' } = {}) {
|
||||
async function summarizeAdminTierLists({ queryText = '', gameId = '', topicId = '' } = {}) {
|
||||
const hasQuery = !!(queryText || '').trim()
|
||||
const hasGameId = !!(gameId || '').trim()
|
||||
const resolvedTopicId = (topicId || gameId || '').trim()
|
||||
const hasGameId = !!resolvedTopicId
|
||||
const search = `%${(queryText || '').trim()}%`
|
||||
const whereParts = []
|
||||
const params = []
|
||||
|
||||
if (hasGameId) {
|
||||
whereParts.push('t.game_id = ?')
|
||||
params.push((gameId || '').trim())
|
||||
whereParts.push('t.topic_id = ?')
|
||||
params.push(resolvedTopicId)
|
||||
}
|
||||
|
||||
if (hasQuery) {
|
||||
whereParts.push(`(
|
||||
t.title LIKE ?
|
||||
OR g.name LIKE ?
|
||||
OR g.id LIKE ?
|
||||
OR tp.name LIKE ?
|
||||
OR tp.id LIKE ?
|
||||
OR u.email LIKE ?
|
||||
OR u.nickname LIKE ?
|
||||
)`)
|
||||
@@ -2073,7 +2141,7 @@ async function summarizeAdminTierLists({ queryText = '', gameId = '' } = {}) {
|
||||
SELECT t.is_public
|
||||
FROM tierlists t
|
||||
INNER JOIN users u ON u.id = t.author_id
|
||||
INNER JOIN games g ON g.id = t.game_id
|
||||
INNER JOIN topics tp ON tp.id = t.topic_id
|
||||
${whereClause}
|
||||
`,
|
||||
params
|
||||
@@ -2094,8 +2162,8 @@ async function findTierListById(id, currentUserId = '') {
|
||||
SELECT
|
||||
t.id,
|
||||
t.author_id,
|
||||
t.game_id,
|
||||
g.name AS game_name,
|
||||
t.topic_id,
|
||||
tp.name AS topic_name,
|
||||
t.title,
|
||||
t.thumbnail_src,
|
||||
t.description,
|
||||
@@ -2114,7 +2182,7 @@ async function findTierListById(id, currentUserId = '') {
|
||||
u.avatar_src
|
||||
FROM tierlists t
|
||||
INNER JOIN users u ON u.id = t.author_id
|
||||
INNER JOIN games g ON g.id = t.game_id
|
||||
INNER JOIN topics tp ON tp.id = t.topic_id
|
||||
WHERE t.id = ?
|
||||
LIMIT 1
|
||||
`,
|
||||
@@ -2146,6 +2214,8 @@ async function createTemplateRequest({
|
||||
sourceTierListId = '',
|
||||
sourceGameId,
|
||||
targetGameId = '',
|
||||
sourceTopicId = sourceGameId,
|
||||
targetTopicId = targetGameId,
|
||||
title,
|
||||
description = '',
|
||||
thumbnailSrc = '',
|
||||
@@ -2171,8 +2241,8 @@ async function createTemplateRequest({
|
||||
request_type,
|
||||
requester_id,
|
||||
source_tierlist_id,
|
||||
source_game_id,
|
||||
target_game_id,
|
||||
source_topic_id,
|
||||
target_topic_id,
|
||||
status,
|
||||
title_snapshot,
|
||||
description_snapshot,
|
||||
@@ -2191,8 +2261,8 @@ async function createTemplateRequest({
|
||||
type,
|
||||
requesterId,
|
||||
sourceTierListId || null,
|
||||
sourceGameId,
|
||||
targetGameId,
|
||||
sourceTopicId,
|
||||
targetTopicId,
|
||||
title,
|
||||
description,
|
||||
thumbnailSrc,
|
||||
@@ -2215,8 +2285,8 @@ async function findTemplateRequestById(id) {
|
||||
tr.request_type,
|
||||
tr.requester_id,
|
||||
tr.source_tierlist_id,
|
||||
tr.source_game_id,
|
||||
tr.target_game_id,
|
||||
tr.source_topic_id,
|
||||
tr.target_topic_id,
|
||||
tr.status,
|
||||
tr.title_snapshot,
|
||||
tr.description_snapshot,
|
||||
@@ -2230,12 +2300,12 @@ async function findTemplateRequestById(id) {
|
||||
u.nickname,
|
||||
u.email,
|
||||
u.avatar_src AS requester_avatar_src,
|
||||
sg.name AS source_game_name,
|
||||
tg.name AS target_game_name
|
||||
sg.name AS source_topic_name,
|
||||
tg.name AS target_topic_name
|
||||
FROM template_requests tr
|
||||
INNER JOIN users u ON u.id = tr.requester_id
|
||||
LEFT JOIN games sg ON sg.id = tr.source_game_id
|
||||
LEFT JOIN games tg ON tg.id = tr.target_game_id
|
||||
LEFT JOIN topics sg ON sg.id = tr.source_topic_id
|
||||
LEFT JOIN topics tg ON tg.id = tr.target_topic_id
|
||||
WHERE tr.id = ?
|
||||
LIMIT 1
|
||||
`,
|
||||
@@ -2257,8 +2327,8 @@ async function listAdminTemplateRequests({ status = 'pending', statuses = [] } =
|
||||
tr.request_type,
|
||||
tr.requester_id,
|
||||
tr.source_tierlist_id,
|
||||
tr.source_game_id,
|
||||
tr.target_game_id,
|
||||
tr.source_topic_id,
|
||||
tr.target_topic_id,
|
||||
tr.status,
|
||||
tr.title_snapshot,
|
||||
tr.description_snapshot,
|
||||
@@ -2272,12 +2342,12 @@ async function listAdminTemplateRequests({ status = 'pending', statuses = [] } =
|
||||
u.nickname,
|
||||
u.email,
|
||||
u.avatar_src AS requester_avatar_src,
|
||||
sg.name AS source_game_name,
|
||||
tg.name AS target_game_name
|
||||
sg.name AS source_topic_name,
|
||||
tg.name AS target_topic_name
|
||||
FROM template_requests tr
|
||||
INNER JOIN users u ON u.id = tr.requester_id
|
||||
LEFT JOIN games sg ON sg.id = tr.source_game_id
|
||||
LEFT JOIN games tg ON tg.id = tr.target_game_id
|
||||
LEFT JOIN topics sg ON sg.id = tr.source_topic_id
|
||||
LEFT JOIN topics tg ON tg.id = tr.target_topic_id
|
||||
WHERE tr.status IN (${placeholders})
|
||||
ORDER BY
|
||||
CASE tr.status
|
||||
@@ -2299,7 +2369,7 @@ async function updateTemplateRequestStatus({ id, status }) {
|
||||
}
|
||||
|
||||
async function updateTemplateRequestTargetGame({ id, targetGameId }) {
|
||||
await query('UPDATE template_requests SET target_game_id = ?, updated_at = ? WHERE id = ?', [targetGameId || '', now(), id])
|
||||
await query('UPDATE template_requests SET target_topic_id = ?, updated_at = ? WHERE id = ?', [targetGameId || '', now(), id])
|
||||
return findTemplateRequestById(id)
|
||||
}
|
||||
|
||||
@@ -2350,6 +2420,7 @@ async function saveTierList({
|
||||
id,
|
||||
authorId,
|
||||
gameId,
|
||||
topicId = gameId,
|
||||
title,
|
||||
thumbnailSrc = '',
|
||||
description,
|
||||
@@ -2383,11 +2454,11 @@ async function saveTierList({
|
||||
await query(
|
||||
`
|
||||
INSERT INTO tierlists (
|
||||
id, author_id, game_id, title, thumbnail_src, description, is_public, show_character_names, icon_size, source_tierlist_id, source_snapshot_title, source_snapshot_author, groups_json, pool_json, created_at, updated_at
|
||||
id, author_id, topic_id, title, thumbnail_src, description, is_public, show_character_names, icon_size, source_tierlist_id, source_snapshot_title, source_snapshot_author, groups_json, pool_json, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[nextId, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, Number(iconSize) || 80, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), createdAt, createdAt]
|
||||
[nextId, authorId, topicId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, Number(iconSize) || 80, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), createdAt, createdAt]
|
||||
)
|
||||
return findTierListById(nextId, authorId)
|
||||
}
|
||||
@@ -2400,7 +2471,8 @@ async function duplicateTierListForUser({ tierList, targetUserId }) {
|
||||
return saveTierList({
|
||||
id: duplicateId,
|
||||
authorId: targetUserId,
|
||||
gameId: tierList.gameId,
|
||||
gameId: tierList.topicId || tierList.gameId,
|
||||
topicId: tierList.topicId || tierList.gameId,
|
||||
title: copyTitle,
|
||||
thumbnailSrc: tierList.thumbnailSrc || '',
|
||||
description: tierList.description || '',
|
||||
@@ -2424,11 +2496,11 @@ async function unfavoriteTierList({ userId, tierListId }) {
|
||||
}
|
||||
|
||||
async function favoriteGame({ userId, gameId }) {
|
||||
await query('INSERT IGNORE INTO favorite_games (user_id, game_id, created_at) VALUES (?, ?, ?)', [userId, gameId, now()])
|
||||
await query('INSERT IGNORE INTO favorite_topics (user_id, topic_id, created_at) VALUES (?, ?, ?)', [userId, gameId, now()])
|
||||
}
|
||||
|
||||
async function unfavoriteGame({ userId, gameId }) {
|
||||
await query('DELETE FROM favorite_games WHERE user_id = ? AND game_id = ?', [userId, gameId])
|
||||
await query('DELETE FROM favorite_topics WHERE user_id = ? AND topic_id = ?', [userId, gameId])
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -54,6 +54,10 @@ const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
function getTemplateIdParam(req) {
|
||||
return req.params.templateId || req.params.gameId || ''
|
||||
}
|
||||
|
||||
function buildUploadFilename(file) {
|
||||
const ext = path.extname(file.originalname || '').toLowerCase()
|
||||
const safeExt = ext && /^[.a-z0-9]+$/.test(ext) ? ext : ''
|
||||
@@ -110,7 +114,7 @@ function canManageAdminRole(actingUser, primaryAdmin) {
|
||||
return !!actingUser?.isAdmin && primaryAdmin?.id === actingUser.id
|
||||
}
|
||||
|
||||
router.post('/games', requireAdmin, async (req, res) => {
|
||||
router.post(['/games', '/templates'], requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).max(60),
|
||||
@@ -126,24 +130,26 @@ router.post('/games', requireAdmin, async (req, res) => {
|
||||
const copiedThumb = await copyUploadIntoGameAsset(parsed.data.thumbnailSrc)
|
||||
await updateGameThumbnail(game.id, copiedThumb)
|
||||
}
|
||||
res.json({ game: await findGameById(game.id) })
|
||||
const template = await findGameById(game.id)
|
||||
res.json({ game: template, template })
|
||||
})
|
||||
|
||||
router.patch('/games/:gameId', requireAdmin, async (req, res) => {
|
||||
router.patch(['/games/:gameId', '/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 game = await findGameById(req.params.gameId)
|
||||
const templateId = getTemplateIdParam(req)
|
||||
const game = await findGameById(templateId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const updated = await updateGameVisibility(game.id, parsed.data.isPublic)
|
||||
res.json({ game: updated })
|
||||
res.json({ game: updated, template: updated })
|
||||
})
|
||||
|
||||
router.patch('/games/display-order', requireAdmin, async (req, res) => {
|
||||
router.patch(['/games/display-order', '/templates/display-order'], requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
gameIds: z.array(z.string().min(1)).max(50),
|
||||
})
|
||||
@@ -154,26 +160,28 @@ router.patch('/games/display-order', requireAdmin, async (req, res) => {
|
||||
const validGameIds = new Set(games.map((game) => game.id))
|
||||
const filteredIds = parsed.data.gameIds.filter((gameId) => validGameIds.has(gameId))
|
||||
const updatedGames = await updateGameDisplayOrder(filteredIds)
|
||||
res.json({ games: updatedGames })
|
||||
res.json({ games: updatedGames, templates: updatedGames })
|
||||
})
|
||||
|
||||
router.patch('/games/:gameId/items/display-order', requireAdmin, async (req, res) => {
|
||||
router.patch(['/games/:gameId/items/display-order', '/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 game = await findGameById(req.params.gameId)
|
||||
const templateId = getTemplateIdParam(req)
|
||||
const game = await findGameById(templateId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const items = await updateGameItemDisplayOrder(game.id, parsed.data.itemIds)
|
||||
res.json({ items })
|
||||
})
|
||||
|
||||
router.post('/games/:gameId/thumbnail', requireAdmin, upload.single('thumbnail'), async (req, res) => {
|
||||
router.post(['/games/:gameId/thumbnail', '/templates/:templateId/thumbnail'], requireAdmin, upload.single('thumbnail'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'file_required' })
|
||||
const game = await findGameById(req.params.gameId)
|
||||
const templateId = getTemplateIdParam(req)
|
||||
const game = await findGameById(templateId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const optimized = await writeOptimizedImage({
|
||||
@@ -185,14 +193,15 @@ router.post('/games/:gameId/thumbnail', requireAdmin, upload.single('thumbnail')
|
||||
quality: 84,
|
||||
})
|
||||
|
||||
const updated = await updateGameThumbnail(req.params.gameId, optimized.src)
|
||||
res.json({ game: updated })
|
||||
const updated = await updateGameThumbnail(templateId, optimized.src)
|
||||
res.json({ game: updated, template: updated })
|
||||
})
|
||||
|
||||
router.post('/games/:gameId/images', requireAdmin, upload.array('images', 50), async (req, res) => {
|
||||
router.post(['/games/:gameId/images', '/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 game = await findGameById(req.params.gameId)
|
||||
const templateId = getTemplateIdParam(req)
|
||||
const game = await findGameById(templateId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const labelsRaw = req.body?.labels
|
||||
@@ -223,19 +232,19 @@ router.post('/games/:gameId/images', requireAdmin, upload.array('images', 50), a
|
||||
res.json({ item: items[0], items })
|
||||
})
|
||||
|
||||
router.delete('/games/:gameId/items/:itemId', requireAdmin, async (req, res) => {
|
||||
const game = await findGameById(req.params.gameId)
|
||||
router.delete(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:itemId'], requireAdmin, async (req, res) => {
|
||||
const game = await findGameById(getTemplateIdParam(req))
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
await deleteGameItem(req.params.itemId)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
router.patch('/games/:gameId/items/:itemId', requireAdmin, async (req, res) => {
|
||||
router.patch(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:itemId'], requireAdmin, async (req, res) => {
|
||||
const schema = z.object({ label: z.string().trim().min(1).max(60) })
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const game = await findGameById(req.params.gameId)
|
||||
const game = await findGameById(getTemplateIdParam(req))
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const updated = await updateGameItemLabel(req.params.itemId, parsed.data.label)
|
||||
@@ -243,10 +252,11 @@ router.patch('/games/:gameId/items/:itemId', requireAdmin, async (req, res) => {
|
||||
res.json({ item: updated })
|
||||
})
|
||||
|
||||
router.delete('/games/:gameId', requireAdmin, async (req, res) => {
|
||||
const game = await findGameById(req.params.gameId)
|
||||
router.delete(['/games/:gameId', '/templates/:templateId'], requireAdmin, async (req, res) => {
|
||||
const templateId = getTemplateIdParam(req)
|
||||
const game = await findGameById(templateId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
await deleteGame(req.params.gameId)
|
||||
await deleteGame(templateId)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
@@ -298,6 +308,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
|
||||
router.get('/tierlists', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
topicId: z.string().trim().max(120).optional().default(''),
|
||||
gameId: z.string().trim().max(120).optional().default(''),
|
||||
page: z.coerce.number().int().min(1).optional().default(1),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
|
||||
@@ -307,6 +318,7 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
|
||||
|
||||
const result = await listAdminTierLists({
|
||||
queryText: parsed.data.q,
|
||||
topicId: parsed.data.topicId || parsed.data.gameId,
|
||||
gameId: parsed.data.gameId,
|
||||
page: parsed.data.page,
|
||||
limit: parsed.data.limit,
|
||||
@@ -318,6 +330,7 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
|
||||
router.get('/tierlists/stats', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
topicId: z.string().trim().max(120).optional().default(''),
|
||||
gameId: z.string().trim().max(120).optional().default(''),
|
||||
})
|
||||
const parsed = schema.safeParse(req.query)
|
||||
@@ -325,6 +338,7 @@ router.get('/tierlists/stats', requireAdmin, async (req, res) => {
|
||||
|
||||
const result = await summarizeAdminTierLists({
|
||||
queryText: parsed.data.q,
|
||||
topicId: parsed.data.topicId || parsed.data.gameId,
|
||||
gameId: parsed.data.gameId,
|
||||
})
|
||||
res.json(result)
|
||||
|
||||
@@ -6,7 +6,7 @@ const router = express.Router()
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const games = await listGames(req.session?.userId || '', { includePrivate: !!req.session?.isAdmin })
|
||||
res.json({ games })
|
||||
res.json({ games, topics: games })
|
||||
})
|
||||
|
||||
router.post('/:gameId/favorite', requireAuth, async (req, res) => {
|
||||
@@ -15,7 +15,7 @@ router.post('/:gameId/favorite', requireAuth, async (req, res) => {
|
||||
await favoriteGame({ userId: req.session.userId, gameId: game.id })
|
||||
const games = await listGames(req.session.userId)
|
||||
const updated = games.find((entry) => entry.id === game.id) || { ...game, isFavorited: true }
|
||||
res.json({ game: updated })
|
||||
res.json({ game: updated, topic: updated })
|
||||
})
|
||||
|
||||
router.delete('/:gameId/favorite', requireAuth, async (req, res) => {
|
||||
@@ -24,14 +24,14 @@ router.delete('/:gameId/favorite', requireAuth, async (req, res) => {
|
||||
await unfavoriteGame({ userId: req.session.userId, gameId: game.id })
|
||||
const games = await listGames(req.session.userId)
|
||||
const updated = games.find((entry) => entry.id === game.id) || { ...game, isFavorited: false }
|
||||
res.json({ game: updated })
|
||||
res.json({ game: updated, topic: updated })
|
||||
})
|
||||
|
||||
router.get('/:gameId', async (req, res) => {
|
||||
const detail = await getGameDetail(req.params.gameId)
|
||||
if (!detail) return res.status(404).json({ error: 'not_found' })
|
||||
if (!detail.game.isPublic && !req.session?.isAdmin) return res.status(404).json({ error: 'not_found' })
|
||||
res.json({ game: detail.game, items: detail.items })
|
||||
res.json({ game: detail.game, topic: detail.game, items: detail.items })
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -20,7 +20,7 @@ const { requireAuth } = require('../middleware/auth')
|
||||
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
|
||||
|
||||
const router = express.Router()
|
||||
const FREEFORM_GAME_ID = 'freeform'
|
||||
const FREEFORM_TOPIC_ID = 'freeform'
|
||||
const FREEFORM_DEFAULT_TITLE = '직접 티어표 만들기'
|
||||
|
||||
function normalizePoolItem(item) {
|
||||
@@ -61,7 +61,8 @@ const thumbnailUpload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024 }
|
||||
const templateRequestSchema = z.object({
|
||||
type: z.enum(['create', 'update']),
|
||||
sourceTierListId: z.string().max(64).optional().default(''),
|
||||
gameId: z.string().min(1).max(120),
|
||||
gameId: z.string().min(1).max(120).optional(),
|
||||
topicId: z.string().min(1).max(120).optional(),
|
||||
requestTitle: z.string().trim().min(1).max(120),
|
||||
requestDescription: z.string().trim().min(1).max(1000),
|
||||
thumbnailSrc: z.string().max(255).optional().default(''),
|
||||
@@ -72,7 +73,11 @@ const templateRequestSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).max(16),
|
||||
itemIds: z.array(z.string()).optional().default([]),
|
||||
}).passthrough()
|
||||
}).passthrough().superRefine((value, ctx) => {
|
||||
if (!(value.topicId || value.gameId)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'topicId_required', path: ['topicId'] })
|
||||
}
|
||||
})
|
||||
),
|
||||
boardItems: z.array(
|
||||
z.object({
|
||||
@@ -86,7 +91,8 @@ const templateRequestSchema = z.object({
|
||||
|
||||
const tierListUpsertSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
gameId: z.string().min(1),
|
||||
gameId: z.string().min(1).optional(),
|
||||
topicId: z.string().min(1).optional(),
|
||||
title: z.string().min(1).max(120),
|
||||
thumbnailSrc: z.string().max(255).optional().default(''),
|
||||
description: z.string().max(1000).optional().default(''),
|
||||
@@ -111,12 +117,16 @@ const tierListUpsertSchema = z.object({
|
||||
origin: z.enum(['game', 'custom']).default('game'),
|
||||
})
|
||||
),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (!(value.topicId || value.gameId)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'topicId_required', path: ['topicId'] })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/public', async (req, res) => {
|
||||
const gameId = req.query.gameId
|
||||
const topicId = typeof req.query.topicId === 'string' ? req.query.topicId : req.query.gameId
|
||||
const queryText = typeof req.query.q === 'string' ? req.query.q : ''
|
||||
const lists = await listPublicTierLists(gameId, req.session?.userId || '', queryText)
|
||||
const lists = await listPublicTierLists(topicId, req.session?.userId || '', queryText)
|
||||
res.json({ tierLists: lists })
|
||||
})
|
||||
|
||||
@@ -226,14 +236,15 @@ router.post('/template-request', requireAuth, async (req, res) => {
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const payload = parsed.data
|
||||
const topicId = payload.topicId || payload.gameId
|
||||
const normalizedBoardItems = payload.boardItems.map(normalizePoolItem)
|
||||
const customItems = normalizedBoardItems.filter((item) => item?.origin === 'custom')
|
||||
if (!customItems.length) return res.status(400).json({ error: 'custom_items_required' })
|
||||
|
||||
if (payload.type === 'create') {
|
||||
if (payload.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
|
||||
} else if (payload.gameId === FREEFORM_GAME_ID) {
|
||||
return res.status(400).json({ error: 'game_template_required' })
|
||||
if (topicId !== FREEFORM_TOPIC_ID) return res.status(400).json({ error: 'freeform_required' })
|
||||
} else if (topicId === FREEFORM_TOPIC_ID) {
|
||||
return res.status(400).json({ error: 'topic_template_required' })
|
||||
}
|
||||
|
||||
let sourceTierList = null
|
||||
@@ -251,8 +262,10 @@ router.post('/template-request', requireAuth, async (req, res) => {
|
||||
type: payload.type,
|
||||
requesterId: req.session.userId,
|
||||
sourceTierListId: sourceTierList?.id || '',
|
||||
sourceGameId: payload.gameId,
|
||||
targetGameId: payload.type === 'update' ? payload.gameId : '',
|
||||
sourceGameId: topicId,
|
||||
sourceTopicId: topicId,
|
||||
targetGameId: payload.type === 'update' ? topicId : '',
|
||||
targetTopicId: payload.type === 'update' ? topicId : '',
|
||||
title: payload.requestTitle,
|
||||
description: payload.requestDescription,
|
||||
thumbnailSrc: payload.thumbnailSrc || '',
|
||||
@@ -274,6 +287,7 @@ router.post('/', requireAuth, async (req, res) => {
|
||||
const parsed = tierListUpsertSchema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
const payload = parsed.data
|
||||
const topicId = payload.topicId || payload.gameId
|
||||
const normalizedPool = payload.pool.map(normalizePoolItem)
|
||||
|
||||
let existing = null
|
||||
@@ -284,7 +298,8 @@ router.post('/', requireAuth, async (req, res) => {
|
||||
const updated = await saveTierList({
|
||||
id: existing.id,
|
||||
authorId: existing.authorId,
|
||||
gameId: existing.gameId,
|
||||
gameId: existing.topicId || existing.gameId,
|
||||
topicId: existing.topicId || existing.gameId,
|
||||
title: payload.title,
|
||||
thumbnailSrc: payload.thumbnailSrc || '',
|
||||
description: payload.description || '',
|
||||
@@ -303,7 +318,8 @@ router.post('/', requireAuth, async (req, res) => {
|
||||
const created = await saveTierList({
|
||||
id: nanoid(),
|
||||
authorId: req.session.userId,
|
||||
gameId: payload.gameId,
|
||||
gameId: topicId,
|
||||
topicId,
|
||||
title: payload.title,
|
||||
thumbnailSrc: payload.thumbnailSrc || '',
|
||||
description: payload.description || '',
|
||||
|
||||
@@ -1,5 +1,71 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-04-02 v1.4.14
|
||||
- 기존 `/games` 주소 호환은 alias보다 redirect가 더 맞다고 판단했다. 이번 단계에선 주소는 유지하되 라우트 파라미터 의미는 항상 `topicId`로 정규화해 Vue Router 경고와 내부 분기를 함께 줄였다.
|
||||
- 운영 DB에 직접 `RENAME TABLE`과 컬럼 `CHANGE`를 거는 방식은 실제 환경에서 실패 여지가 커서, 마지막 스키마 전환도 새 topic 스키마를 먼저 만들고 기존 game 데이터를 복사하는 비파괴 마이그레이션이 더 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.13
|
||||
- 사용자 표면과 API 이름층까지 `topic/template`로 옮긴 뒤에는, DB 스키마도 실제로 따라오게 해야 이후 유지보수 비용이 덜 쌓이므로 `games` 계열 실명을 `topics` 계열로 마이그레이션하는 편이 맞다고 판단했다.
|
||||
- 다만 한 번에 응답 키까지 완전히 끊으면 프런트와 관리자 흐름이 너무 크게 흔들릴 수 있으므로, 이번 단계에서는 실제 저장 스키마는 `topic`으로 옮기고 응답의 `gameId / gameName`은 호환 키로 잠시 함께 유지하는 점진 마감이 가장 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.12
|
||||
- 프런트 이름만 바꾸는 단계가 끝난 뒤에는, 백엔드도 새 `/api/topics`, `/api/admin/templates` 경로를 열고 기존 `/games`는 호환용으로 남기는 점진 전환이 가장 안전하다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.11
|
||||
- 백엔드 `/api/games` 경로를 바로 바꾸기보다, 프런트 API 객체에서 먼저 `topic/template` 의미 이름을 제공하고 호출부를 옮기는 편이 위험이 훨씬 낮다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.10
|
||||
- 사용자 주소는 이미 `/topics`로 옮기기 시작했으므로, 라우트 이름과 기본 파라미터도 `topicHub / topicId` 기준으로 맞추고 기존 `gameId`는 호환 fallback으로만 남기는 편이 더 자연스럽다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.9
|
||||
- 경로 전환은 화면마다 문자열을 직접 고치는 방식보다, 공용 경로 헬퍼를 먼저 세워 주제·에디터·로그인 리다이렉트 흐름을 한 기준으로 묶는 편이 이후 리네이밍 비용을 훨씬 줄인다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.8
|
||||
- 주제 상세 화면 제목은 내부 ID를 잠깐 보여주는 것보다, 로딩 문구를 거쳐 실제 이름으로 자연스럽게 바뀌는 편이 사용자 체감상 더 안정적이라고 판단했다.
|
||||
- 주요 목록 화면은 `pageHead` 문법을 계속 통일해 두는 편이, 이후 검색/필터 툴바를 더 붙이더라도 구조를 예측하기 쉽다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.7
|
||||
- 주제 상세 컬렉션 화면도 즐겨찾기·나의 티어표와 같은 `pageHead` 문법으로 맞춰야, 네비게이션으로 이동하는 주요 화면들의 리듬이 더 자연스럽다고 판단했다.
|
||||
- 라우트 전환은 한 번에 `/games`를 없애기보다, 먼저 `/topics`를 기본 진입 경로로 세우고 기존 `/games`는 alias로 유지하는 점진 전환이 더 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.6
|
||||
- 내부 리네이밍 2단계는 관리자 화면처럼 상태와 액션이 많은 영역부터 정리해 두는 편이, 이후 `/games` 라우트와 API 계층을 손볼 때 위험을 줄이는 데 더 유리하다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.5
|
||||
- 내부 리네이밍은 한 번에 API와 DB까지 건드리기보다, 홈·주제 화면·에디터처럼 영향 범위가 비교적 명확한 프런트 핵심 흐름부터 `game` 의존 이름을 줄여 나가는 편이 더 안전하다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.4
|
||||
- 용어 정리 마무리 단계에서는 눈에 잘 띄는 영어 헤더를 그대로 두기보다, 홈과 관리자처럼 진입 빈도가 높은 화면의 상단 라벨까지 한국어로 맞춰야 전체 제품 인상이 더 자연스럽다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.3
|
||||
- 용어 전환은 메뉴 타이틀만 바꾸는 것으로 끝나지 않고, 관리자 작업 중 반복해서 보게 되는 토스트와 확인창까지 맞춰야 실제 체감 일관성이 살아난다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.2
|
||||
- 용어 정리를 시작한 뒤에는 일부 화면만 바꾸는 것보다, 관리자 모달과 확인 메시지처럼 실제 운영 중 많이 보는 문구도 함께 맞춰 주는 편이 체감 일관성이 더 높다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.1
|
||||
- 좌측 메뉴와 화면 타이틀의 명칭이 서로 다르면 사용자가 현재 위치를 직관적으로 매칭하기 어렵기 때문에, 메뉴 이름과 진입 타이틀을 같은 문구로 맞추는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.0
|
||||
- 서비스가 게임 외 주제 전반을 다룰 수 있는 단계에 온 만큼, 내부 모델명은 유지하더라도 사용자에게 보이는 주요 용어는 `주제 / 템플릿` 기준으로 먼저 정리하는 편이 맞다고 판단했다.
|
||||
- 대규모 내부 리네이밍은 API와 DB까지 손대야 하므로, 이번 단계에서는 사용자 화면 문구만 우선 바꾸고 내부 `game` 모델은 그대로 두는 점진적 전환이 더 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.93
|
||||
- 목록 카드 썸네일은 드래그 대상이 아니라 클릭 대상에 가깝기 때문에, 브라우저 기본 이미지 드래그 프리뷰는 전부 막아 두는 편이 UX 측면에서 맞다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.92
|
||||
- 왼쪽 레일 활성 메뉴도 로그인 토글과 같은 이동형 배경 문법을 쓰는 편이 앱 전체 인터랙션 언어를 더 일관되게 만든다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.91
|
||||
- 로그인/회원가입 탭은 즉시 배경 교체보다, 선택 배경이 실제로 이동하는 토글 문법이 더 직관적이고 상태 전환이 잘 읽힌다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.90
|
||||
- 경고 수준의 CSS 진단이라도 실제 의미 없는 속성이나 벤더 전용 속성 누락이라면 바로 정리해 두는 편이 이후 유지보수 피로를 줄인다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.89
|
||||
- 더 이상 참조되지 않는 Vite 기본 자산과 레거시 public 아이콘 묶음은 남겨둘수록 혼동만 커지므로, 실제 사용 파일만 남기고 정리하는 편이 맞다고 판단했다.
|
||||
- 공유용 썸네일은 코드 수정과 별개로 시각 자산 손질이 자주 일어날 수 있으므로, 이번처럼 워크트리에 이미 반영된 최신 이미지 수정본은 함께 릴리스에 포함하는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.88
|
||||
- 헤더의 `by zenn`은 이미 공통 카피라이트 링크가 생긴 뒤 역할이 겹치므로, 브랜드 영역은 서비스명 중심으로 정리하는 편이 맞다고 판단했다.
|
||||
- 외부 공유 미리보기는 메타 태그만 넣는 것보다 실제 전용 썸네일 자산을 함께 두는 편이 메신저/소셜/모바일 홈 화면까지 더 안정적으로 동작한다고 정리했다.
|
||||
|
||||
50
docs/todo.md
@@ -1,6 +1,34 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 단기 확인
|
||||
- `v1.4.14`부터는 DB 마이그레이션이 rename 대신 복사 기반으로 바뀌었으므로, 실제 운영 DB에서 서버 재시작 후 `topics` 계열 테이블과 `tierlists.topic_id`, `template_requests.source_topic_id/target_topic_id`가 기대대로 채워지는지 먼저 확인한다.
|
||||
- 레거시 `/games/...`와 `/editor/:gameId/...`는 redirect로 남겼으므로, 오래된 북마크 진입 후 주소가 `/topics/...`, `/editor/:topicId/...`로 자연스럽게 정규화되는지 한 번 더 QA한다.
|
||||
- `v1.4.13`부터 DB 실명도 `topics / topic_items / favorite_topics / topic_id` 기준으로 옮겼으므로, 기존 운영 DB에서 서버 재시작 후 자동 마이그레이션이 한 번만 자연스럽게 수행되는지 먼저 확인한다.
|
||||
- 백엔드 응답은 현재 `topicId / topicName`과 `gameId / gameName`을 함께 내려주고 있으므로, 다음 단계에서는 실제 프런트/관리자에서 더 이상 `gameId` fallback이 필요 없는 지점을 확인해 호환 키 제거 순서를 정한다.
|
||||
- 티어표 공개 목록, 관리자 전체 티어표 관리, 저장/요청 API는 `topicId`를 우선 받도록 바꿨으므로, 실제 브라우저에서 검색/저장/공유/관리자 필터가 모두 같은 파라미터 체계로 자연스럽게 이어지는지 한 번 더 QA한다.
|
||||
- `/api/topics`, `/api/admin/templates` alias를 연 뒤 프런트 호출도 새 경로로 옮겼으므로, 실제 브라우저에서 주제 목록/즐겨찾기/주제 상세/관리자 템플릿 관리가 모두 같은 세션으로 자연스럽게 동작하는지 한 번 더 QA한다.
|
||||
- 프런트 API 호출부는 `topic/template` 의미 이름으로 옮겼으므로, 다음 단계에서는 `api.js` 안의 레거시 alias를 얼마나 더 유지할지와 백엔드 API 경로를 실제로 바꿀지 범위를 정한다.
|
||||
- 관리자 템플릿/주제 화면과 홈·에디터·즐겨찾기에서 새 API 이름층으로 바뀐 뒤에도 저장, 즐겨찾기, 템플릿 생성, 아이템 정렬 흐름이 자연스러운지 한 번 더 QA한다.
|
||||
- `topicHub / topicId`를 기본 라우트 기준으로 세웠으므로, 기존 `/games/...` 북마크와 새 `/topics/...` 주소 양쪽에서 주제 상세와 에디터 진입이 모두 자연스럽게 이어지는지 한 번 더 QA한다.
|
||||
- 다음 단계에서는 `api.getGame`, `listGames`, `favoriteGame`처럼 남아 있는 프런트 API 이름을 어느 수준까지 `topic/template` 의미로 감쌀지 정리한다.
|
||||
- 경로 헬퍼를 사용자 주요 화면에 연결했으므로, 로그인 리다이렉트·공유 프리뷰·복사본 이동·주제 복귀 흐름이 실제 브라우저에서 모두 같은 주소 체계로 자연스럽게 이어지는지 한 번 더 QA한다.
|
||||
- 다음 단계에서는 `router/index.js`의 `gameHub`, `route.params.gameId`, `api.getGame`처럼 남아 있는 프런트 내부 이름도 어디까지 `topic/template` 의미로 감쌀지 범위를 더 좁힌다.
|
||||
- 주제 상세 화면 제목은 ID fallback 대신 로딩 문구를 쓰도록 바꿨으므로, 직접 진입·뒤로가기·다른 주제로 연속 이동할 때 헤더가 깜빡이거나 이전 제목을 잠깐 유지하지 않는지 한 번 더 QA한다.
|
||||
- 검색 결과 화면도 `pageHead` 구조로 맞췄으므로, 주요 목록 화면들 간 상단 여백과 타이포 리듬이 자연스러운지 한 번 더 비교 QA한다.
|
||||
- 주제 상세 컬렉션 화면은 `pageHead` 공통 레이아웃과 `/topics` 기본 경로로 옮겼으므로, 직접 진입·뒤로가기·검색 후 재진입 시 주소와 헤더 흐름이 자연스러운지 한 번 더 QA한다.
|
||||
- `/topics/:gameId`를 기본 경로로 세우고 `/games/:gameId`는 alias로 남겼으므로, 다음 단계에서는 에디터/검색/공유 흐름에서 어떤 링크를 새 경로로 더 전환할지 범위를 정한다.
|
||||
- 내부 리네이밍 2단계로 관리자 `selectedTemplate / templates / loadTemplate / refreshTemplates` 묶음까지 정리했으므로, 다음 단계에서는 `/games/:gameId` 라우트와 프런트 API 호출부를 어디까지 `topic/template` 의미로 감쌀지 범위를 먼저 정리한다.
|
||||
- 화면/문서 층의 용어 정리는 거의 마무리됐으므로, 다음 단계에서는 내부 `gameId / games` 모델을 실제로 옮길지, 아니면 라우트 alias/리다이렉트부터 둘지 점진 전환 순서를 정한다.
|
||||
- 관리자 화면까지 포함한 사용자 노출 `게임` 문구를 3차까지 정리했으므로, 실제 운영 흐름에서 뜨는 확인창/토스트/선택 모달까지 표현이 자연스러운지 한 번 더 QA한다.
|
||||
- 왼쪽 사이드 메뉴와 각 화면 타이틀을 한글 기준으로 맞췄으므로, 홈/나의 티어표/즐겨찾기/설정 진입 시 실제 체감이 자연스럽고 중복 표현이 어색하지 않은지 한 번 더 QA한다.
|
||||
- 사용자 노출 용어는 `주제 / 템플릿` 기준으로 계속 걷어내고 있으므로, 홈/주제 화면/관리자 템플릿 관리에서 어색하게 남은 `게임` 문구가 없는지 한 번 더 QA한다.
|
||||
- 내부 모델명은 아직 `game`을 유지하므로, 다음 단계에서는 문서와 보조 화면 문구를 더 정리할지, 아니면 내부 리네이밍 계획을 따로 잡을지 결정한다.
|
||||
- 주제 목록과 티어표 카드 썸네일은 기본 이미지 드래그를 막았으므로, 데스크톱 브라우저에서 클릭/드래그 시 원본 이미지 프리뷰가 더 이상 뜨지 않는지 한 번 더 QA한다.
|
||||
- 왼쪽 레일 활성 배경은 공용 인디케이터가 이동하는 방식으로 바뀌었으므로, 홈/내 티어표/즐겨찾기/설정 전환과 레일 접힘 상태 양쪽에서 위치 보정이 자연스러운지 한 번 더 QA한다.
|
||||
- 로그인 화면 상단 토글은 이동형 인디케이터로 바뀌었으므로, 데스크톱과 모바일에서 `로그인 / 회원가입` 전환 애니메이션이 어색하지 않고 포커스/클릭 상태도 자연스러운지 한 번 더 QA한다.
|
||||
- 관리자 카드 설명 줄임은 `line-clamp` 표준 속성까지 함께 선언했으므로, 실제 브라우저별 표시 차이가 없는지 한 번 더 QA한다.
|
||||
- 사용하지 않는 기본 자산을 정리했으므로, 배포본에서 누락 참조 없이 파비콘/공유 썸네일/좌측 레일 아이콘이 정상 노출되는지 한 번 더 QA한다.
|
||||
- 공유 썸네일 `og-card`는 이번에 이미지 수정본까지 함께 반영했으므로, 실제 메신저 미리보기에서 최신 그림이 캐시 갱신 후 정상 노출되는지 한 번 더 QA한다.
|
||||
- 홈페이지 공유 메타와 새 `og-card.png`는 이번에 처음 붙였으므로, 카카오톡/디스코드/슬랙/모바일 브라우저에서 제목·설명·썸네일이 기대대로 보이는지 한 번 더 QA한다.
|
||||
- 파비콘은 `svg + 32px png + apple-touch-icon` 조합으로 정리했으므로, 데스크톱 브라우저 탭과 iOS 홈 화면 추가에서 모두 정상 노출되는지 한 번 더 QA한다.
|
||||
- 티어표 `아이콘 크기`는 이제 저장 데이터로 승격됐으므로, 저장 후 재진입/프리뷰/복사본 생성에서 같은 크기가 유지되는지 한 번 더 QA한다.
|
||||
@@ -11,21 +39,21 @@
|
||||
- 관리자 템플릿 요청 카드는 썸네일 클릭이 새창 열기 역할로 바뀌었으므로, 썸네일 클릭과 `확인하기` 액션이 서로 헷갈리지 않는지 한 번 더 QA한다.
|
||||
- 티어표 만들기 화면의 `공유하기`는 저장된 티어표에서만 노출되므로, 저장 직후/수정 중/복사본/읽기 전용 상태 각각에서 노출 조건과 클립보드 복사가 자연스러운지 한 번 더 QA한다.
|
||||
- 우측 카피라이트는 이제 공통 오른쪽 레일 footer이므로, 관리자 화면뿐 아니라 홈/프로필 등 오른쪽 사이드가 보이는 화면에서도 같은 최하단 위치에 유지되는지 한 번 더 QA한다.
|
||||
- 왼쪽 레일 축소 상태의 하단 액션 아이콘은 홈과 게임 허브에서 서로 다른 아이콘을 쓰도록 나눴으므로, 실제로 두 문맥이 한눈에 구분되는지 한 번 더 QA한다.
|
||||
- 왼쪽 레일 축소 상태 최하단의 `티어표 만들기` 아이콘 버튼은 새로 추가했으므로, 홈/게임 허브에서 실제로 같은 위치 감각으로 동작하는지 한 번 더 QA한다.
|
||||
- 왼쪽 레일 축소 상태의 하단 액션 아이콘은 홈과 주제 화면에서 서로 다른 아이콘을 쓰도록 나눴으므로, 실제로 두 문맥이 한눈에 구분되는지 한 번 더 QA한다.
|
||||
- 왼쪽 레일 축소 상태 최하단의 `티어표 만들기` 아이콘 버튼은 새로 추가했으므로, 홈/주제 화면에서 실제로 같은 위치 감각으로 동작하는지 한 번 더 QA한다.
|
||||
- 관리자 우측 카피라이트 문구는 사이드바 내부 최하단으로 다시 옮겼으므로, 실제 관리자 화면에서 스크롤/창 크기 변화에도 계속 보이는지 한 번 더 QA한다.
|
||||
- 왼쪽 레일 축소 상태는 아이콘 줄 높이를 50px 기준으로 통일했으므로, 실제 데스크톱에서 아바타/검색/메뉴 아이콘이 시각적으로 같은 리듬으로 보이는지 한 번 더 QA한다.
|
||||
- 왼쪽 레일 검색은 이제 항상 게임 검색으로 홈으로 이동하므로, 홈이 아닌 화면에서 사이드 검색 후 게임 목록 결과로 자연스럽게 이동하는지 한 번 더 QA한다.
|
||||
- 왼쪽 레일 검색은 이제 항상 주제 템플릿 검색으로 홈으로 이동하므로, 홈이 아닌 화면에서 사이드 검색 후 주제 목록 결과로 자연스럽게 이동하는지 한 번 더 QA한다.
|
||||
- 앱 왼쪽 사이드 레일은 접힘 상태 레이아웃을 다시 손봤으므로, 데스크톱에서 접기/펼치기 반복 시 아바타 영역 높이, 아이콘 중앙 정렬, 검색 버튼 간격, 네비게이션 히트 영역이 모두 자연스러운지 한 번 더 QA한다.
|
||||
- 관리자 우측 사이드바 하단 카피라이트 링크는 새 창 외부 링크로 바꿨으므로, 실제 클릭 시 `zenn.town` 연결과 hover 대비가 자연스러운지 한 번 더 QA한다.
|
||||
- 관리자 아이템 상세 모달은 공통 패딩 예외 처리를 다시 넣었으므로, 대형 상세 모달과 일반 게임 선택 모달이 각각 기대한 크기로 보이는지 한 번 더 QA한다.
|
||||
- 아이템 관리 모달의 공용 게임 선택기에서는 이미 연결된 게임이 비활성화되므로, 실제 운영 데이터에서 중복 연결 방지와 `이미 추가됨` 표시가 기대대로 읽히는지 한 번 더 QA한다.
|
||||
- 공용 게임 선택 모달을 아이템 관리 모달에도 붙였으므로, 관리자 `게임 관리 / 전체 티어표 관리 / 아이템 관리` 세 흐름에서 같은 검색/선택 UX가 자연스럽게 느껴지는지 한 번 더 QA한다.
|
||||
- 관리자 `/admin/games?gameId=...` 직접 진입과 새로고침에서 선택 게임이 정상 복원되고 콘솔 오류가 없는지 한 번 더 QA한다.
|
||||
- 공용 `게임 선택` 검색 모달은 새로 붙였으므로, 게임 관리 선택/전체 티어표 관리 필터 양쪽에서 검색어 입력, 선택, 해제, 뒤로가기 흐름을 한 번 더 QA한다.
|
||||
- 관리자 `전체 티어표 관리`의 게임 필터와 관리 모달은 새로 붙였으므로, 실제 운영 데이터에서 필터 전환 후 공개/비공개 집계, 제목 수정, 비공개 전환, 삭제 흐름이 자연스럽게 이어지는지 한 번 더 QA한다.
|
||||
- 관리자 게임 관리 상단의 `전체 / 공개 / 비공개` 티어표 수치와 전체 티어표 관리 상단 집계가 실제 운영 데이터와 맞는지, 공개 전환 직후 숫자가 자연스럽게 갱신되는지 한 번 더 QA한다.
|
||||
- 관리자 아이템 상세 모달은 왼쪽 선택 카드와 오른쪽 상세 본문으로 다시 정리했으므로, 데스크톱/모바일에서 긴 게임 목록과 긴 참조 목록이 각각 자연스럽게 스크롤되는지 한 번 더 QA한다.
|
||||
- 관리자 아이템 상세 모달은 공통 패딩 예외 처리를 다시 넣었으므로, 대형 상세 모달과 일반 템플릿 선택 모달이 각각 기대한 크기로 보이는지 한 번 더 QA한다.
|
||||
- 아이템 관리 모달의 공용 템플릿 선택기에서는 이미 연결된 템플릿이 비활성화되므로, 실제 운영 데이터에서 중복 연결 방지와 `이미 추가됨` 표시가 기대대로 읽히는지 한 번 더 QA한다.
|
||||
- 공용 템플릿 선택 모달을 아이템 관리 모달에도 붙였으므로, 관리자 `템플릿 관리 / 전체 티어표 관리 / 아이템 관리` 세 흐름에서 같은 검색/선택 UX가 자연스럽게 느껴지는지 한 번 더 QA한다.
|
||||
- 관리자 `/admin/games?gameId=...` 직접 진입과 새로고침에서 선택 템플릿이 정상 복원되고 콘솔 오류가 없는지 한 번 더 QA한다.
|
||||
- 공용 `템플릿 선택` 검색 모달은 새로 붙였으므로, 템플릿 관리 선택/전체 티어표 관리 필터 양쪽에서 검색어 입력, 선택, 해제, 뒤로가기 흐름을 한 번 더 QA한다.
|
||||
- 관리자 `전체 티어표 관리`의 템플릿 필터와 관리 모달은 새로 붙였으므로, 실제 운영 데이터에서 필터 전환 후 공개/비공개 집계, 제목 수정, 비공개 전환, 삭제 흐름이 자연스럽게 이어지는지 한 번 더 QA한다.
|
||||
- 관리자 템플릿 관리 상단의 `전체 / 공개 / 비공개` 티어표 수치와 전체 티어표 관리 상단 집계가 실제 운영 데이터와 맞는지, 공개 전환 직후 숫자가 자연스럽게 갱신되는지 한 번 더 QA한다.
|
||||
- 관리자 아이템 상세 모달은 왼쪽 선택 카드와 오른쪽 상세 본문으로 다시 정리했으므로, 데스크톱/모바일에서 긴 템플릿 목록과 긴 참조 목록이 각각 자연스럽게 스크롤되는지 한 번 더 QA한다.
|
||||
- 관리자 계정으로 `/admin/...`을 직접 새로고침했을 때 홈으로 튕기지 않고 그대로 유지되는지, 실제 세션이 살아 있는 브라우저와 만료된 브라우저 각각에서 한 번 더 QA한다.
|
||||
- 티어표 만들기 화면의 보드 드롭존은 점선/높이/중앙 정렬로 존재감이 커졌으므로, 데스크톱과 모바일에서 파일 선택 버튼과 안내 문구 밀도를 한 번 더 QA한다.
|
||||
- 관리자 썸네일 드롭존과 기본 아이템 추가 드롭존은 에디터 드롭존과 시각 문법을 맞췄으므로, 라이트모드와 좁은 화면에서 아이콘 대비와 배경 밝기가 과하지 않은지 한 번 더 QA한다.
|
||||
|
||||
@@ -1,5 +1,79 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-02 v1.4.14
|
||||
- `/games/:gameId`, `/editor/:gameId/...` 레거시 주소는 Vue Router alias 대신 redirect로 정리해, `topicId` 기준 라우트와 섞일 때 뜨던 param mismatch 경고를 제거했다.
|
||||
- 운영 DB에서 바로 `RENAME/CHANGE`를 치던 초기 마이그레이션은 위험도가 높아, `topics / topic_items / favorite_topics / topic_id` 스키마를 안전하게 만들고 기존 `games` 계열 데이터를 복사해 오는 방식으로 바꿨다.
|
||||
- DB 초기화 실패 시 원인을 바로 확인할 수 있도록 백엔드에서 `db_init_failed` 응답 전에 실제 에러를 서버 로그에 남기도록 보강했다.
|
||||
|
||||
## 2026-04-02 v1.4.13
|
||||
- DB 실명 변경 마지막 단계로 `games / game_items / favorite_games`를 `topics / topic_items / favorite_topics` 기준으로 자동 마이그레이션하도록 정리하고, `tierlists.game_id`, `template_requests.source_game_id/target_game_id`도 각각 `topic_id`, `source_topic_id/target_topic_id`로 옮기게 했다.
|
||||
- 백엔드 저장/조회 쿼리는 이제 새 topic 스키마를 기준으로 동작하고, 응답에는 `topicId / topicName`을 기본으로 내려주되 기존 프런트가 바로 깨지지 않도록 `gameId / gameName`도 잠시 함께 유지했다.
|
||||
- 티어표 공개 목록, 관리자 전체 티어표 관리, 티어표 저장/요청 API는 `topicId`를 우선 받도록 정리하고 기존 `gameId`는 호환 입력으로만 남겨, 외부 표면과 실제 저장 스키마가 한 단계 더 가까워지게 맞췄다.
|
||||
|
||||
## 2026-04-02 v1.4.12
|
||||
- 백엔드에 `/api/topics`와 `/api/admin/templates` alias 경로를 추가하고, 주제/템플릿 응답도 `topic/topics`, `template/templates` 키를 함께 내려주도록 정리했다.
|
||||
- 프런트의 새 의미 이름은 이제 실제로도 `/api/topics`, `/api/admin/templates`를 타도록 연결해, 경로 이름과 호출 이름이 다시 어긋나지 않게 맞췄다.
|
||||
|
||||
## 2026-04-02 v1.4.11
|
||||
- 프런트 API 이름층을 한 단계 더 정리해 `listTopics / getTopic / favoriteTopic`, `updateAdminTemplate*`, `searchPublicTierListsByTopic` 같은 의미 기반 이름을 추가하고 실제 호출부도 이 기준으로 옮겼다.
|
||||
- 백엔드 경로와 응답 구조는 그대로 유지한 채 프런트에서 읽는 이름만 먼저 바꿔, 다음 단계의 API/모델 리네이밍 부담을 더 줄였다.
|
||||
|
||||
## 2026-04-02 v1.4.10
|
||||
- 주제 상세 라우트 이름을 `topicHub`로, 기본 경로 파라미터를 `topicId`로 바꾸고 기존 `gameId` 주소는 alias로 유지했다.
|
||||
- 앱 셸, 주제 상세, 티어표 편집기는 이제 내부에서 `topicId`를 우선 읽고, 레거시 주소로 들어온 경우에만 `gameId` fallback을 쓰도록 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.9
|
||||
- `frontend/src/lib/paths.js`를 추가해 주제 진입, 에디터 이동, 로그인 리다이렉트, 공유 프리뷰 주소 같은 사용자 표면 경로를 공용 함수로 모았다.
|
||||
- 홈, 주제 상세, 나의 티어표, 즐겨찾기, 검색 결과, 로그인, 설정, 관리자 미리보기, 티어표 편집기까지 이 경로 헬퍼를 쓰도록 바꿔 이후 `topics` 전환을 더 안전하게 이어갈 수 있는 기반을 만들었다.
|
||||
|
||||
## 2026-04-02 v1.4.8
|
||||
- 주제 상세 컬렉션 화면은 제목을 `topicId` fallback으로 먼저 노출하지 않도록 바꾸고, 주제 전환 시에는 로딩 문구를 거쳐 실제 이름으로 자연스럽게 바뀌게 정리했다.
|
||||
- 검색 결과 화면도 공통 `pageHead` 문법으로 맞춰 주요 목록 화면들의 상단 리듬을 한 번 더 통일했다.
|
||||
|
||||
## 2026-04-02 v1.4.7
|
||||
- 주제 선택 뒤에 들어가는 `Collection` 화면을 공통 `pageHead` 레이아웃으로 다시 맞추고, 검색 입력을 즐겨찾기 화면처럼 상단 우측 툴바로 정리했다.
|
||||
- `공개 티어표` 보조 설명 줄은 제거해 헤더 밀도를 줄였고, 사용자 진입 경로는 `/topics/:gameId`를 기본으로 전환하면서 기존 `/games/:gameId`는 alias로 유지했다.
|
||||
|
||||
## 2026-04-02 v1.4.6
|
||||
- 관리자 내부 리네이밍 2단계로 `AdminView`와 관련 composable/component의 핵심 상태명을 `selectedTemplate / templates / loadTemplate / refreshTemplates / createTemplate` 기준으로 정리했다.
|
||||
- 요청 검토, 템플릿 생성 모달, 아이템 추가/정렬, 템플릿 선택 모달 흐름도 같은 기준으로 맞춰, 관리자 화면을 읽을 때 내부 이름과 사용자 노출 용어가 덜 어긋나게 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.5
|
||||
- 내부 리네이밍 1단계를 시작해 홈, 주제 화면, 티어표 편집기, 앱 셸에서 `games / gameId / gameName` 중심의 로컬 상태명을 `templates / topicId / templateId / templateName` 계열로 먼저 정리했다.
|
||||
- 경로와 API는 그대로 둔 채 프런트 내부에서 자주 읽는 상태명부터 바꿔, 이후 `/games` 라우트와 관리자 상태를 손볼 때 의미 충돌이 덜 나도록 기반을 만들었다.
|
||||
|
||||
## 2026-04-02 v1.4.4
|
||||
- 홈 화면 `Topic Library`와 일부 영어 헤더를 `주제 선택 / 티어표 / 관리자 작업실 / 티어표 만들기 / 작업 공간`으로 정리해, 화면 타이틀과 상단 레이블까지 한국어 기준으로 거의 통일했다.
|
||||
|
||||
## 2026-04-02 v1.4.3
|
||||
- 관리자 토스트, 확인창, 요청 처리 안내처럼 실제로 자주 보이는 운영 문구까지 `주제 / 템플릿` 기준으로 한 번 더 정리해, 화면 제목뿐 아니라 작업 피드백도 더 일관되게 맞췄다.
|
||||
|
||||
## 2026-04-02 v1.4.2
|
||||
- 관리자 화면과 보조 모달에 남아 있던 사용자 노출 `게임` 문구를 추가로 걷어내고, `템플릿 / 주제` 기준 표현으로 더 통일했다.
|
||||
|
||||
## 2026-04-02 v1.4.1
|
||||
- 왼쪽 사이드 메뉴를 `주제 선택 / 나의 티어표 / 즐겨찾기 / 설정` 한글 문구로 통일하고, 해당 화면 진입 시 헤더 타이틀도 같은 이름 기준으로 맞췄다.
|
||||
|
||||
## 2026-04-02 v1.4.0
|
||||
- 사용자 노출 용어 1차 정리를 시작해 홈/좌측 레일/가이드/주제 화면에서는 `게임` 대신 `주제`, 관리자 핵심 화면에서는 `게임 관리` 대신 `템플릿 관리` 중심 표현으로 바꿨다.
|
||||
- 내부 데이터 모델과 API의 `gameId`, `/games` 구조는 아직 유지하고, 이번 단계는 화면 문구와 안내 텍스트를 먼저 정리하는 안전한 1차 리네이밍 범위로 제한했다.
|
||||
|
||||
## 2026-04-02 v1.3.93
|
||||
- 게임 목록, 티어표 리스트, 사용자 아바타 버튼 등 목록성 썸네일 이미지에 `draggable=\"false\"`를 적용해 브라우저 기본 이미지 드래그 프리뷰가 뜨지 않도록 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.92
|
||||
- 왼쪽 네비게이션의 활성 메뉴 배경은 개별 항목에 즉시 붙는 방식에서, 공용 인디케이터가 현재 메뉴 위치로 미끄러져 이동하는 토글형 인터랙션으로 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.91
|
||||
- 로그인 화면 상단의 `로그인 / 회원가입` 전환은 선택된 버튼 배경이 즉시 바뀌던 방식에서, 뒤쪽 하이라이트가 토글처럼 좌우로 미끄러져 이동하는 인터랙션으로 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.90
|
||||
- 관리자 화면 CSS 경고를 줄이기 위해 `display: block` 요소에 의미 없던 `vertical-align`을 제거하고, `line-clamp` 표준 속성을 함께 선언해 VS Code 진단을 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.89
|
||||
- 현재 코드에서 참조되지 않던 `frontend/public/icons.svg`, `frontend/src/assets/hero.png`, `frontend/src/assets/vite.svg`, `frontend/src/assets/vue.svg`를 삭제해 템플릿 잔재 자산을 정리함.
|
||||
- 홈페이지 공유용 `og-card.svg`, `og-card.png`는 이번 워크트리에서 직접 수정된 최신 이미지 상태를 그대로 반영해 함께 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.88
|
||||
- 중앙 워크스페이스 헤더의 `by zenn` 링크는 공통 카피라이트 footer가 이미 역할을 대신하므로 제거하고, 기본 서브타이틀도 서비스 설명 문구로 정리함.
|
||||
- 홈페이지 공유용 메타를 정리해 `title`, `description`, `canonical`, Open Graph, Twitter 카드 정보를 `tmaker.sori.studio` 기준으로 연결함.
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 613 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 32 KiB |
@@ -2,6 +2,7 @@
|
||||
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { editorNewPath, favoritesPath, homePath, loginPath, mePath } from './lib/paths'
|
||||
import { toApiUrl } from './lib/runtime'
|
||||
import { useToast } from './composables/useToast'
|
||||
import iconDockToLeft from './assets/icons/dock_to_left.svg'
|
||||
@@ -22,11 +23,12 @@ const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const { toasts, dismissToast } = useToast()
|
||||
const RIGHT_RAIL_COPYRIGHT_URL = 'https://zenn.town/@murabito'
|
||||
const currentTopicId = computed(() => route.params.topicId || route.params.gameId || '')
|
||||
|
||||
const leftRailCollapsed = ref(false)
|
||||
const rightRailOpen = ref(true)
|
||||
const searchQuery = ref('')
|
||||
const leftRailSearchPlaceholder = '게임 템플릿 검색'
|
||||
const leftRailSearchPlaceholder = '주제 템플릿 검색'
|
||||
const isCollapsedSearchOpen = ref(false)
|
||||
const isGuideModalOpen = ref(false)
|
||||
const themeMode = ref('dark')
|
||||
@@ -60,22 +62,23 @@ const shellStyle = computed(() => ({
|
||||
}))
|
||||
const leftNavItems = computed(() => {
|
||||
const items = [
|
||||
{ key: 'home', label: 'Games', path: '/', iconSrc: iconGridView },
|
||||
{ key: 'me', label: 'My Lists', path: '/me', iconSrc: iconLists, requiresAuth: true },
|
||||
{ key: 'favorites', label: 'Favorites', path: '/favorites', iconSrc: iconFavorite, requiresAuth: true },
|
||||
{ key: 'profile', label: 'Settings', path: '/profile', iconSrc: iconSettings, requiresAuth: true },
|
||||
{ key: 'home', label: '주제 선택', path: '/', iconSrc: iconGridView },
|
||||
{ key: 'me', label: '나의 티어표', path: '/me', iconSrc: iconLists, requiresAuth: true },
|
||||
{ key: 'favorites', label: '즐겨찾기', path: '/favorites', iconSrc: iconFavorite, requiresAuth: true },
|
||||
{ key: 'profile', label: '설정', path: '/profile', iconSrc: iconSettings, requiresAuth: true },
|
||||
]
|
||||
return items.filter((item) => !item.requiresAuth || (authReady.value && auth.user))
|
||||
})
|
||||
const activeLeftNavIndex = computed(() => leftNavItems.value.findIndex((item) => isRouteActive(item.path)))
|
||||
const showRightRailAction = computed(() => false)
|
||||
const showSettingsGuideButton = computed(() => route.name === 'profile')
|
||||
const guideSteps = [
|
||||
{
|
||||
id: 'select-game',
|
||||
title: '게임 또는 양식 선택',
|
||||
summary: '게임 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작합니다.',
|
||||
title: '주제 또는 양식 선택',
|
||||
summary: '주제 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작합니다.',
|
||||
description:
|
||||
'홈 화면에서는 게임 템플릿을 선택하거나 커스텀 티어표 만들기로 바로 새 보드를 열 수 있어요. 게임을 먼저 고르면 해당 게임의 공개 티어표도 같이 살펴볼 수 있어서, 완전히 처음 만드는지 기존 흐름을 참고할지 결정하기 쉽습니다.',
|
||||
'홈 화면에서는 주제 템플릿을 선택하거나 커스텀 티어표 만들기로 바로 새 보드를 열 수 있어요. 원하는 주제를 먼저 고르면 해당 주제의 공개 티어표도 같이 살펴볼 수 있어서, 완전히 처음 만드는지 기존 흐름을 참고할지 결정하기 쉽습니다.',
|
||||
},
|
||||
{
|
||||
id: 'arrange-board',
|
||||
@@ -89,7 +92,7 @@ const guideSteps = [
|
||||
title: '아이템 배치와 커스텀 추가',
|
||||
summary: '프리셋 아이템과 직접 올린 이미지를 드래그로 배치합니다.',
|
||||
description:
|
||||
'오른쪽 아이템 영역의 이미지를 원하는 칸으로 끌어다 놓으면 바로 배치됩니다. 게임 템플릿에 없는 이미지는 커스텀 이미지로 추가해 같이 쓸 수 있고, 이름 표시 옵션을 켜면 결과 이미지를 더 설명적으로 정리할 수 있어요.',
|
||||
'오른쪽 아이템 영역의 이미지를 원하는 칸으로 끌어다 놓으면 바로 배치됩니다. 주제 템플릿에 없는 이미지는 커스텀 이미지로 추가해 같이 쓸 수 있고, 이름 표시 옵션을 켜면 결과 이미지를 더 설명적으로 정리할 수 있어요.',
|
||||
},
|
||||
{
|
||||
id: 'save-share',
|
||||
@@ -108,23 +111,23 @@ const guideSteps = [
|
||||
{
|
||||
id: 'request-template-update',
|
||||
title: '템플릿 업그레이드 요청',
|
||||
summary: '현재 게임 템플릿에 공통 아이템을 추가해 달라고 관리자에게 요청합니다.',
|
||||
summary: '현재 템플릿에 공통 아이템을 추가해 달라고 관리자에게 요청합니다.',
|
||||
description:
|
||||
'직접 추가한 아이템 중 여러 사람이 함께 써도 좋을 것 같은 항목이 있다면 템플릿 업데이트 요청을 보낼 수 있어요. 요청 모달에서는 현재 티어표 제목과 설명을 기본값으로 가져오고, 필요하면 요청 제목과 설명을 더 다듬어 공통 템플릿에 왜 필요한지 설명할 수 있습니다.',
|
||||
},
|
||||
{
|
||||
id: 'request-new-template',
|
||||
title: '새 템플릿 추가 요청',
|
||||
summary: '아직 없는 게임이나 새로운 양식을 관리자에게 제안합니다.',
|
||||
summary: '아직 없는 주제나 새로운 양식을 관리자에게 제안합니다.',
|
||||
description:
|
||||
'원하는 게임 템플릿이 아직 없다면 새 템플릿 추가 요청으로 관리자에게 직접 제안할 수 있어요. 이때는 제목과 설명에 어떤 게임인지, 어떤 캐릭터나 항목이 기본으로 필요할지 적어두면 검토 속도가 훨씬 빨라집니다.',
|
||||
'원하는 주제 템플릿이 아직 없다면 새 템플릿 추가 요청으로 관리자에게 직접 제안할 수 있어요. 이때는 제목과 설명에 어떤 주제인지, 어떤 캐릭터나 항목이 기본으로 필요할지 적어두면 검토 속도가 훨씬 빨라집니다.',
|
||||
},
|
||||
{
|
||||
id: 'manage-library',
|
||||
title: '즐겨찾기와 내 티어표 관리',
|
||||
summary: '마음에 드는 템플릿과 저장한 결과물을 나중에 다시 쉽게 찾습니다.',
|
||||
description:
|
||||
'게임 템플릿은 즐겨찾기로 상단에 고정해둘 수 있고, 저장한 보드는 내 티어표에서 다시 열어 이어서 수정할 수 있어요. 자주 보는 템플릿, 공개 티어표, 내가 만든 결과물을 각각 다른 화면에서 정리해두면 이후 작업이 훨씬 빨라집니다.',
|
||||
'주제 템플릿은 즐겨찾기로 상단에 고정해둘 수 있고, 저장한 보드는 내 티어표에서 다시 열어 이어서 수정할 수 있어요. 자주 보는 템플릿, 공개 티어표, 내가 만든 결과물을 각각 다른 화면에서 정리해두면 이후 작업이 훨씬 빨라집니다.',
|
||||
},
|
||||
]
|
||||
const currentGuideStep = computed(() => guideSteps[guideStepIndex.value] || guideSteps[0])
|
||||
@@ -133,16 +136,16 @@ const isGuideNextDisabled = computed(() => guideStepIndex.value >= guideSteps.le
|
||||
const isLightTheme = computed(() => themeMode.value === 'light')
|
||||
const themeToggleLabel = computed(() => (isLightTheme.value ? '다크 모드' : '라이트 모드'))
|
||||
const showSettingsThemePanel = computed(() => false && route.name === 'profile')
|
||||
const showGameHubViewToggle = computed(() => route.name === 'gameHub')
|
||||
const gameHubViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
|
||||
const showTopicViewToggle = computed(() => route.name === 'topicHub')
|
||||
const topicViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
|
||||
const leftBottomPrimaryAction = computed(() => {
|
||||
if (!authReady.value) return null
|
||||
if (route.name === 'home' && auth.user) {
|
||||
return { label: '커스텀 티어표 만들기', to: '/editor/freeform/new', iconSrc: iconDashboardCustomize }
|
||||
return { label: '커스텀 티어표 만들기', to: editorNewPath('freeform'), iconSrc: iconDashboardCustomize }
|
||||
}
|
||||
if (route.name === 'gameHub') {
|
||||
const target = `/editor/${route.params.gameId}/new`
|
||||
return { label: '새 티어표 만들기', to: auth.user ? target : `/login?redirect=${target}`, iconSrc: iconAddNotes }
|
||||
if (route.name === 'topicHub') {
|
||||
const target = editorNewPath(currentTopicId.value)
|
||||
return { label: '새 티어표 만들기', to: auth.user ? target : loginPath(target), iconSrc: iconAddNotes }
|
||||
}
|
||||
return null
|
||||
})
|
||||
@@ -150,96 +153,96 @@ const leftBottomPrimaryAction = computed(() => {
|
||||
const routeMeta = computed(() => {
|
||||
if (route.name === 'home') {
|
||||
return {
|
||||
title: 'Tier Maker',
|
||||
subtitle: '게임 템플릿 선택과 커스텀 보드 시작',
|
||||
title: '주제 선택',
|
||||
subtitle: '주제 템플릿 선택과 커스텀 보드 시작',
|
||||
contextTitle: '빠른 시작',
|
||||
contextText: auth.user ? '커스텀 티어표를 만들거나 원하는 게임을 바로 선택할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
|
||||
contextText: auth.user ? '커스텀 티어표를 만들거나 원하는 주제를 바로 선택할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
|
||||
actionLabel: auth.user ? '커스텀 티어표 만들기' : '로그인하러 가기',
|
||||
action: () => {
|
||||
router.push(auth.user ? '/editor/freeform/new' : '/login')
|
||||
router.push(auth.user ? editorNewPath('freeform') : loginPath())
|
||||
},
|
||||
}
|
||||
}
|
||||
if (route.name === 'gameHub') {
|
||||
if (route.name === 'topicHub') {
|
||||
return {
|
||||
title: 'Game Boards',
|
||||
subtitle: '게임별 공개 티어표 탐색',
|
||||
title: '주제 티어표',
|
||||
subtitle: '주제별 공개 티어표 탐색',
|
||||
contextTitle: '작성 작업',
|
||||
contextText: auth.user ? '이 게임의 새 티어표를 만들거나 기존 공개 티어표를 확인할 수 있어요.' : '로그인 후 새 티어표를 만들 수 있어요.',
|
||||
contextText: auth.user ? '이 주제의 새 티어표를 만들거나 기존 공개 티어표를 확인할 수 있어요.' : '로그인 후 새 티어표를 만들 수 있어요.',
|
||||
actionLabel: auth.user ? '새 티어표 만들기' : '로그인하러 가기',
|
||||
action: () => {
|
||||
const target = `/editor/${route.params.gameId}/new`
|
||||
router.push(auth.user ? target : `/login?redirect=${target}`)
|
||||
const target = editorNewPath(currentTopicId.value)
|
||||
router.push(auth.user ? target : loginPath(target))
|
||||
},
|
||||
}
|
||||
}
|
||||
if (route.name === 'editEditor' || route.name === 'newEditor') {
|
||||
return {
|
||||
title: 'Deck Builder',
|
||||
title: '티어표 만들기',
|
||||
subtitle: '티어표 편집 및 공유',
|
||||
contextTitle: '편집 패널',
|
||||
contextText: '현재 편집 옵션은 중앙 화면 안에 유지되어 있습니다. 다음 단계에서 우측 패널로 정리해갈게요.',
|
||||
actionLabel: '게임 목록으로',
|
||||
action: () => router.push('/'),
|
||||
actionLabel: '주제 목록으로',
|
||||
action: () => router.push(homePath()),
|
||||
}
|
||||
}
|
||||
if (isAdminRoute.value) {
|
||||
return {
|
||||
title: 'Admin Workspace',
|
||||
subtitle: '게임·아이템·회원 관리',
|
||||
title: '관리자 작업실',
|
||||
subtitle: '템플릿·아이템·회원 관리',
|
||||
contextTitle: '운영 노트',
|
||||
contextText: '관리자 화면은 기능이 많아 우선 공통 셸 톤을 맞췄고, 세부 패널은 다음 단계에서 시안 방식으로 더 세밀하게 나눌 예정입니다.',
|
||||
actionLabel: '게임 목록으로',
|
||||
action: () => router.push('/'),
|
||||
actionLabel: '주제 목록으로',
|
||||
action: () => router.push(homePath()),
|
||||
}
|
||||
}
|
||||
if (route.name === 'me') {
|
||||
return {
|
||||
title: 'My Lists',
|
||||
subtitle: '내가 저장한 티어표',
|
||||
title: '나의 티어표',
|
||||
subtitle: '저장한 티어표 모아보기',
|
||||
contextTitle: '작성 이력',
|
||||
contextText: '최근 저장한 티어표를 열람하고 정리할 수 있어요.',
|
||||
actionLabel: '즐겨찾기 보기',
|
||||
action: () => router.push('/favorites'),
|
||||
action: () => router.push(favoritesPath()),
|
||||
}
|
||||
}
|
||||
if (route.name === 'favorites') {
|
||||
return {
|
||||
title: 'Favorites',
|
||||
title: '즐겨찾기',
|
||||
subtitle: '마음에 드는 티어표 모음',
|
||||
contextTitle: '정리 도구',
|
||||
contextText: '정렬과 검색으로 원하는 티어표를 빠르게 다시 찾을 수 있어요.',
|
||||
actionLabel: '내 티어표 보기',
|
||||
action: () => router.push('/me'),
|
||||
actionLabel: '나의 티어표 보기',
|
||||
action: () => router.push(mePath()),
|
||||
}
|
||||
}
|
||||
if (route.name === 'profile') {
|
||||
return {
|
||||
title: 'Profile',
|
||||
title: '설정',
|
||||
subtitle: '프로필 및 계정 설정',
|
||||
contextTitle: '계정 관리',
|
||||
contextText: '아바타와 닉네임을 관리하고 표시 정보를 확인할 수 있어요.',
|
||||
actionLabel: '내 티어표 보기',
|
||||
action: () => router.push('/me'),
|
||||
actionLabel: '나의 티어표 보기',
|
||||
action: () => router.push(mePath()),
|
||||
}
|
||||
}
|
||||
if (route.name === 'search') {
|
||||
return {
|
||||
title: 'Search',
|
||||
title: '검색',
|
||||
subtitle: '전체 공개 티어표 검색 결과',
|
||||
contextTitle: '검색',
|
||||
contextText: '제목, 작성자 기준으로 공개 티어표를 전체 검색할 수 있어요.',
|
||||
actionLabel: '홈으로',
|
||||
action: () => router.push('/'),
|
||||
action: () => router.push(homePath()),
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: 'Tier Maker',
|
||||
subtitle: '게임 템플릿으로 만드는 티어표',
|
||||
contextTitle: 'Workspace',
|
||||
subtitle: '주제 템플릿으로 만드는 티어표',
|
||||
contextTitle: '작업 공간',
|
||||
contextText: '현재 화면에 맞는 도구와 안내를 여기에 배치할 수 있습니다.',
|
||||
actionLabel: '홈으로',
|
||||
action: () => router.push('/'),
|
||||
action: () => router.push(homePath()),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -343,8 +346,8 @@ function toggleRightRail() {
|
||||
}
|
||||
}
|
||||
|
||||
function setGameHubViewMode(mode) {
|
||||
if (route.name !== 'gameHub') return
|
||||
function setTopicViewMode(mode) {
|
||||
if (route.name !== 'topicHub') return
|
||||
const nextQuery = { ...route.query }
|
||||
if (mode === 'list') nextQuery.view = 'list'
|
||||
else delete nextQuery.view
|
||||
@@ -394,7 +397,7 @@ function handleLeftRailSearch() {
|
||||
function submitGlobalSearch() {
|
||||
const query = (searchQuery.value || '').trim()
|
||||
isCollapsedSearchOpen.value = false
|
||||
router.push(query ? `/?q=${encodeURIComponent(query)}` : '/')
|
||||
router.push(homePath(query))
|
||||
}
|
||||
|
||||
|
||||
@@ -428,7 +431,7 @@ function submitGlobalSearch() {
|
||||
<div class="leftRail__content">
|
||||
<div v-if="authReady && auth.user" class="appUserCard">
|
||||
<div class="appUserCard__button">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" />
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" draggable="false" />
|
||||
<div v-else class="appUserCard__avatar appUserCard__avatar--fallback">{{ accountName[0]?.toUpperCase() || 'U' }}</div>
|
||||
<div class="appUserCard__meta">
|
||||
<div class="appUserCard__name">{{ accountName }}</div>
|
||||
@@ -446,7 +449,12 @@ function submitGlobalSearch() {
|
||||
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : leftRailSearchPlaceholder" />
|
||||
</form>
|
||||
|
||||
<nav class="leftNav">
|
||||
<nav
|
||||
class="leftNav"
|
||||
:class="{ 'leftNav--hasActive': activeLeftNavIndex >= 0 }"
|
||||
:style="{ '--left-nav-active-index': String(Math.max(activeLeftNavIndex, 0)) }"
|
||||
>
|
||||
<span class="leftNav__indicator" aria-hidden="true"></span>
|
||||
<RouterLink
|
||||
v-for="item in leftNavItems"
|
||||
:key="item.key"
|
||||
@@ -493,11 +501,11 @@ function submitGlobalSearch() {
|
||||
<span class="workspaceHead__brandTitle">Tier Maker</span>
|
||||
</div>
|
||||
<div class="workspaceHead__actions">
|
||||
<div v-if="showGameHubViewToggle" class="viewToggle" role="group" aria-label="티어표 보기 방식">
|
||||
<button class="ghostIcon ghostIcon--iconOnly" :class="{ 'ghostIcon--active': gameHubViewMode === 'grid' }" type="button" aria-label="그리드 보기" @click="setGameHubViewMode('grid')">
|
||||
<div v-if="showTopicViewToggle" class="viewToggle" role="group" aria-label="티어표 보기 방식">
|
||||
<button class="ghostIcon ghostIcon--iconOnly" :class="{ 'ghostIcon--active': topicViewMode === 'grid' }" type="button" aria-label="그리드 보기" @click="setTopicViewMode('grid')">
|
||||
<SvgIcon :src="iconGridView" :size="24" />
|
||||
</button>
|
||||
<button class="ghostIcon ghostIcon--iconOnly" :class="{ 'ghostIcon--active': gameHubViewMode === 'list' }" type="button" aria-label="리스트 보기" @click="setGameHubViewMode('list')">
|
||||
<button class="ghostIcon ghostIcon--iconOnly" :class="{ 'ghostIcon--active': topicViewMode === 'list' }" type="button" aria-label="리스트 보기" @click="setTopicViewMode('list')">
|
||||
<SvgIcon :src="iconLists" :size="24" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -911,19 +919,45 @@ function submitGlobalSearch() {
|
||||
}
|
||||
|
||||
.leftNav {
|
||||
--left-nav-gap: 8px;
|
||||
--left-nav-item-height: 50px;
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
gap: var(--left-nav-gap);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.leftNav__indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--left-nav-item-height);
|
||||
border-radius: 14px;
|
||||
background: var(--theme-surface-soft-3);
|
||||
transform: translateY(calc(var(--left-nav-active-index, 0) * (var(--left-nav-item-height) + var(--left-nav-gap))));
|
||||
transition: transform 240ms ease, opacity 200ms ease;
|
||||
opacity: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leftNav--hasActive .leftNav__indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.leftNav__item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: var(--left-nav-item-height);
|
||||
gap: 12px;
|
||||
padding: 11px 12px;
|
||||
border-radius: 14px;
|
||||
color: var(--theme-text-muted);
|
||||
text-decoration: none;
|
||||
transition: background 180ms ease, color 180ms ease, transform 180ms ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.leftNav__label {
|
||||
@@ -936,7 +970,6 @@ function submitGlobalSearch() {
|
||||
|
||||
.leftNav__item--active,
|
||||
.leftNav__item.router-link-active {
|
||||
background: var(--theme-surface-soft-3);
|
||||
color: var(--theme-text-strong);
|
||||
}
|
||||
|
||||
@@ -999,7 +1032,7 @@ function submitGlobalSearch() {
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .leftNav {
|
||||
gap: 10px;
|
||||
--left-nav-gap: 10px;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .leftNav__item {
|
||||
|
||||
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 496 B |
@@ -1,13 +1,13 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
featuredGames: { type: Array, required: true },
|
||||
availableGamesForFeatured: { type: Array, required: true },
|
||||
featuredGameIds: { type: Array, required: true },
|
||||
featuredTemplates: { type: Array, required: true },
|
||||
availableTemplatesForFeatured: { type: Array, required: true },
|
||||
featuredTemplateIds: { type: Array, required: true },
|
||||
featuredListRef: { type: Function, required: true },
|
||||
saveFeaturedOrder: { type: Function, required: true },
|
||||
moveFeaturedGame: { type: Function, required: true },
|
||||
removeFeaturedGame: { type: Function, required: true },
|
||||
addFeaturedGame: { type: Function, required: true },
|
||||
moveFeaturedTemplate: { type: Function, required: true },
|
||||
removeFeaturedTemplate: { type: Function, required: true },
|
||||
addFeaturedTemplate: { type: Function, required: true },
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -16,7 +16,7 @@ const props = defineProps({
|
||||
<div class="sectionHeader">
|
||||
<div>
|
||||
<div class="panel__title">홈 화면 상단 고정 순서</div>
|
||||
<div class="hint hint--tight">여기에 넣은 게임은 지정한 순서대로 먼저 노출되고, 나머지 게임은 최근 생성순으로 뒤에 이어집니다. 최대 50개까지 설정할 수 있어요.</div>
|
||||
<div class="hint hint--tight">여기에 넣은 템플릿은 지정한 순서대로 먼저 노출되고, 나머지 템플릿은 최근 생성순으로 뒤에 이어집니다. 최대 50개까지 설정할 수 있어요.</div>
|
||||
</div>
|
||||
<button class="btn btn--primary" @click="props.saveFeaturedOrder">순서 저장</button>
|
||||
</div>
|
||||
@@ -24,38 +24,38 @@ const props = defineProps({
|
||||
<div class="featuredOrderPanel">
|
||||
<div class="featuredOrderPanel__list">
|
||||
<div class="section__title">상단 고정 목록</div>
|
||||
<div v-if="!props.featuredGames.length" class="hint">아직 상단 고정 게임이 없어요.</div>
|
||||
<div v-if="!props.featuredTemplates.length" class="hint">아직 상단 고정 템플릿이 없어요.</div>
|
||||
<div v-else :ref="props.featuredListRef" class="featuredList">
|
||||
<article v-for="(game, index) in props.featuredGames" :key="game.id" class="featuredCard" :data-featured-id="game.id">
|
||||
<article v-for="(template, index) in props.featuredTemplates" :key="template.id" class="featuredCard" :data-featured-id="template.id">
|
||||
<div class="featuredCard__meta">
|
||||
<span class="featuredCard__rank">{{ index + 1 }}</span>
|
||||
<div>
|
||||
<div class="featuredCard__title">{{ game.name }}</div>
|
||||
<div class="featuredCard__id">{{ game.id }}</div>
|
||||
<div class="featuredCard__title">{{ template.name }}</div>
|
||||
<div class="featuredCard__id">{{ template.id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="featuredCard__actions">
|
||||
<button class="btn btn--ghost btn--small" data-featured-handle>드래그</button>
|
||||
<button class="btn btn--ghost btn--small" :disabled="index === 0" @click="props.moveFeaturedGame(game.id, -1)">위로</button>
|
||||
<button class="btn btn--ghost btn--small" :disabled="index === props.featuredGames.length - 1" @click="props.moveFeaturedGame(game.id, 1)">아래로</button>
|
||||
<button class="btn btn--danger btn--small" @click="props.removeFeaturedGame(game.id)">제외</button>
|
||||
<button class="btn btn--ghost btn--small" :disabled="index === 0" @click="props.moveFeaturedTemplate(template.id, -1)">위로</button>
|
||||
<button class="btn btn--ghost btn--small" :disabled="index === props.featuredTemplates.length - 1" @click="props.moveFeaturedTemplate(template.id, 1)">아래로</button>
|
||||
<button class="btn btn--danger btn--small" @click="props.removeFeaturedTemplate(template.id)">제외</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="featuredOrderPanel__picker">
|
||||
<div class="section__title">게임 추가</div>
|
||||
<div class="section__title">템플릿 추가</div>
|
||||
<div class="featuredPickerList">
|
||||
<button
|
||||
v-for="game in props.availableGamesForFeatured"
|
||||
:key="game.id"
|
||||
v-for="template in props.availableTemplatesForFeatured"
|
||||
:key="template.id"
|
||||
class="featuredPickerItem"
|
||||
:disabled="props.featuredGameIds.length >= 50"
|
||||
@click="props.addFeaturedGame(game.id)"
|
||||
:disabled="props.featuredTemplateIds.length >= 50"
|
||||
@click="props.addFeaturedTemplate(template.id)"
|
||||
>
|
||||
<span>{{ game.name }}</span>
|
||||
<span class="featuredPickerItem__id">{{ game.id }}</span>
|
||||
<span>{{ template.name }}</span>
|
||||
<span class="featuredPickerItem__id">{{ template.id }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,10 +8,10 @@ const props = defineProps({
|
||||
templateRequestSourceUrl: { type: Function, required: true },
|
||||
stagedRequestDraftCount: { type: Number, required: true },
|
||||
appliedRequestItemCount: { type: Number, required: true },
|
||||
openGameCreateModal: { type: Function, required: true },
|
||||
openTemplateCreateModal: { type: Function, required: true },
|
||||
isGameLoading: { type: Boolean, required: true },
|
||||
hasSelectedGame: { type: Boolean, required: true },
|
||||
selectedGame: { type: Object, default: null },
|
||||
hasSelectedTemplate: { type: Boolean, required: true },
|
||||
selectedTemplate: { type: Object, default: null },
|
||||
displayThumbnailUrl: { type: String, default: '' },
|
||||
canApplyThumbnail: { type: Boolean, required: true },
|
||||
gameVisibilitySaving: { type: Boolean, required: true },
|
||||
@@ -24,8 +24,8 @@ const props = defineProps({
|
||||
onThumbDrop: { type: Function, required: true },
|
||||
isThumbDragOver: { type: Boolean, required: true },
|
||||
uploadThumbnail: { type: Function, required: true },
|
||||
removeGame: { type: Function, required: true },
|
||||
toggleSelectedGameVisibility: { type: Function, required: true },
|
||||
removeTemplate: { type: Function, required: true },
|
||||
toggleSelectedTemplateVisibility: { type: Function, required: true },
|
||||
itemFileInputRef: { type: Function, required: true },
|
||||
onFile: { type: Function, required: true },
|
||||
isItemDragOver: { type: Boolean, required: true },
|
||||
@@ -39,12 +39,12 @@ const props = defineProps({
|
||||
canAddItem: { type: Boolean, required: true },
|
||||
uploadItem: { type: Function, required: true },
|
||||
removeUploadDraft: { type: Function, required: true },
|
||||
hasGameItemOrderChanges: { type: Boolean, required: true },
|
||||
saveGameItemOrder: { type: Function, required: true },
|
||||
hasTemplateItemOrderChanges: { type: Boolean, required: true },
|
||||
saveTemplateItemOrder: { type: Function, required: true },
|
||||
gameItemListRef: { type: Function, required: true },
|
||||
saveGameItemLabel: { type: Function, required: true },
|
||||
removeGameItem: { type: Function, required: true },
|
||||
selectedGameId: { type: String, default: '' },
|
||||
saveTemplateItemLabel: { type: Function, required: true },
|
||||
removeTemplateItem: { type: Function, required: true },
|
||||
selectedTemplateId: { type: String, default: '' },
|
||||
})
|
||||
|
||||
function setGameItemListElement(el) {
|
||||
@@ -67,17 +67,17 @@ function setThumbFileElement(el) {
|
||||
props.activeTemplateRequest.type === 'create'
|
||||
? (props.activeTemplateRequest.targetGameId
|
||||
? '이미 연결된 신규 템플릿이 있어요. 필요한 아이템만 골라 저장하세요.'
|
||||
: '새 게임을 한 번 만든 뒤 필요한 아이템만 골라 저장하세요.')
|
||||
: '새 템플릿을 한 번 만든 뒤 필요한 아이템만 골라 저장하세요.')
|
||||
: '필요한 아이템만 남긴 뒤 기본 아이템 추가 버튼으로 반영하세요.'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="requestWorkspace__stats">
|
||||
<span class="pill pill--accent">{{ props.activeTemplateRequest.type === 'create' ? '신규 게임 요청' : '기존 게임 업데이트' }}</span>
|
||||
<span class="pill pill--accent">{{ props.activeTemplateRequest.type === 'create' ? '신규 템플릿 요청' : '기존 템플릿 업데이트' }}</span>
|
||||
<span class="pill">요청 아이템 {{ props.stagedRequestDraftCount }}개</span>
|
||||
<span v-if="props.appliedRequestItemCount" class="pill pill--soft">이미 반영 {{ props.appliedRequestItemCount }}개</span>
|
||||
<span v-if="props.activeTemplateRequest.type === 'create' && (props.activeTemplateRequest.targetGameName || props.activeTemplateRequest.targetGameId)" class="pill pill--soft">
|
||||
연결된 게임 · {{ props.activeTemplateRequest.targetGameName || props.activeTemplateRequest.targetGameId }}
|
||||
연결된 템플릿 · {{ props.activeTemplateRequest.targetGameName || props.activeTemplateRequest.targetGameId }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,20 +95,20 @@ function setThumbFileElement(el) {
|
||||
v-if="props.activeTemplateRequest.type === 'create' && !props.activeTemplateRequest.targetGameId"
|
||||
class="btn btn--ghost btn--small"
|
||||
type="button"
|
||||
@click="props.openGameCreateModal"
|
||||
@click="props.openTemplateCreateModal"
|
||||
>
|
||||
새 게임 만들기
|
||||
새 템플릿 만들기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="props.isGameLoading" class="panel panel--empty">
|
||||
<div class="emptyState">
|
||||
<div class="emptyState__title">게임 정보를 불러오는 중이에요.</div>
|
||||
<div class="emptyState__desc">선택한 게임의 썸네일과 기본 아이템을 곧 표시합니다.</div>
|
||||
<div class="emptyState__title">템플릿 정보를 불러오는 중이에요.</div>
|
||||
<div class="emptyState__desc">선택한 템플릿의 썸네일과 기본 아이템을 곧 표시합니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="props.hasSelectedGame" class="panel">
|
||||
<div v-else-if="props.hasSelectedTemplate" class="panel">
|
||||
<section class="adminCard gameSettingsCard">
|
||||
<div class="gameSettingsCard__media">
|
||||
<input :ref="setThumbFileElement" type="file" accept="image/*" class="srOnlyInput" @change="props.onThumb" />
|
||||
@@ -122,7 +122,7 @@ function setThumbFileElement(el) {
|
||||
@dragleave="props.onThumbDragLeave"
|
||||
@drop="props.onThumbDrop"
|
||||
>
|
||||
<img v-if="props.displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="props.displayThumbnailUrl" :alt="props.selectedGame.game.name" />
|
||||
<img v-if="props.displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="props.displayThumbnailUrl" :alt="props.selectedTemplate.game.name" />
|
||||
<div v-else class="selectedThumb selectedThumb--empty selectedThumb--sidebar">대표 썸네일</div>
|
||||
<div class="thumbDropZone__copy">
|
||||
<div class="thumbDropZone__iconWrap">
|
||||
@@ -133,16 +133,16 @@ function setThumbFileElement(el) {
|
||||
</button>
|
||||
</div>
|
||||
<div class="gameSettingsCard__body">
|
||||
<div class="panel__title">게임 설정</div>
|
||||
<div class="gameSettingsCard__meta">{{ props.selectedGame.game.name }} · {{ props.selectedGame.game.id }}</div>
|
||||
<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.selectedGame.game.isPublic" type="checkbox" @change="props.toggleSelectedGameVisibility($event.target.checked)" />
|
||||
<span class="toggleSwitch__label">{{ props.selectedGame.game.isPublic ? '템플릿 공개중' : '비공개 상태' }}</span>
|
||||
<input :checked="!!props.selectedTemplate.game.isPublic" type="checkbox" @change="props.toggleSelectedTemplateVisibility($event.target.checked)" />
|
||||
<span class="toggleSwitch__label">{{ props.selectedTemplate.game.isPublic ? '템플릿 공개중' : '비공개 상태' }}</span>
|
||||
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
|
||||
</label>
|
||||
<div class="gameSettingsCard__actions">
|
||||
<button class="btn" :disabled="!props.canApplyThumbnail" @click="props.uploadThumbnail">썸네일 적용</button>
|
||||
<button class="btn btn--danger" @click="props.removeGame">게임 삭제</button>
|
||||
<button class="btn btn--danger" @click="props.removeTemplate">템플릿 삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -212,11 +212,11 @@ function setThumbFileElement(el) {
|
||||
<div class="section__title">현재 기본 아이템 목록</div>
|
||||
<div class="hint hint--tight">드래그해서 기본 노출 순서를 바꿀 수 있어요. 저장 전까지는 화면에서만 순서가 바뀝니다.</div>
|
||||
</div>
|
||||
<button class="btn btn--primary btn--small" :disabled="!props.hasGameItemOrderChanges" @click="props.saveGameItemOrder">순서 저장</button>
|
||||
<button class="btn btn--primary btn--small" :disabled="!props.hasTemplateItemOrderChanges" @click="props.saveTemplateItemOrder">순서 저장</button>
|
||||
</div>
|
||||
<div v-if="!props.selectedGame?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div>
|
||||
<div v-if="!props.selectedTemplate?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div>
|
||||
<div v-else :ref="setGameItemListElement" class="thumbGrid">
|
||||
<div v-for="item in props.selectedGame.items" :key="item.id" class="thumbCard" :data-game-item-id="item.id">
|
||||
<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" />
|
||||
<input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" data-no-drag />
|
||||
<div class="thumbCard__actions">
|
||||
@@ -224,11 +224,11 @@ function setThumbFileElement(el) {
|
||||
class="btn btn--ghost btn--small"
|
||||
data-no-drag
|
||||
:disabled="item.isSavingLabel || !item.draftLabel?.trim() || item.draftLabel.trim() === item.label"
|
||||
@click="props.saveGameItemLabel(item)"
|
||||
@click="props.saveTemplateItemLabel(item)"
|
||||
>
|
||||
{{ item.isSavingLabel ? '저장중...' : '이름 저장' }}
|
||||
</button>
|
||||
<button class="btn btn--danger btn--small" data-no-drag @click="props.removeGameItem(item.id)">아이템 삭제</button>
|
||||
<button class="btn btn--danger btn--small" data-no-drag @click="props.removeTemplateItem(item.id)">아이템 삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,9 +236,9 @@ function setThumbFileElement(el) {
|
||||
</div>
|
||||
<div v-else class="panel panel--empty">
|
||||
<div class="emptyState">
|
||||
<div class="emptyState__title">게임을 선택해 주세요.</div>
|
||||
<div v-if="props.activeTemplateRequest?.type === 'create'" class="hint hint--tight">진행 중인 신규 게임 요청이 있어요. 위의 `새 게임 만들기`로 게임을 만든 뒤 아이템을 추가할 수 있습니다.</div>
|
||||
<div v-if="props.selectedGameId" class="hint hint--tight">선택한 게임을 찾지 못했거나 로딩 중 오류가 발생했어요. 다시 선택해보세요.</div>
|
||||
<div class="emptyState__title">템플릿을 선택해 주세요.</div>
|
||||
<div v-if="props.activeTemplateRequest?.type === 'create'" class="hint hint--tight">진행 중인 신규 템플릿 요청이 있어요. 위의 `새 템플릿 만들기`로 템플릿을 만든 뒤 아이템을 추가할 수 있습니다.</div>
|
||||
<div v-if="props.selectedTemplateId" class="hint hint--tight">선택한 템플릿을 찾지 못했거나 로딩 중 오류가 발생했어요. 다시 선택해보세요.</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -53,18 +53,18 @@ const props = defineProps({
|
||||
<div class="templateRequestCard__thumbMeta">
|
||||
<template v-if="request.type === 'create'">
|
||||
<label class="templateRequestField">
|
||||
<span class="templateRequestField__label">게임 이름</span>
|
||||
<input v-model="request.draftGameName" class="input" placeholder="새 게임 이름" />
|
||||
<span class="templateRequestField__label">템플릿 이름</span>
|
||||
<input v-model="request.draftGameName" class="input" placeholder="새 템플릿 이름" />
|
||||
</label>
|
||||
<label class="templateRequestField">
|
||||
<span class="templateRequestField__label">게임 ID</span>
|
||||
<input v-model="request.draftGameId" class="input" placeholder="임시 게임 ID" />
|
||||
<span class="templateRequestField__label">템플릿 ID</span>
|
||||
<input v-model="request.draftGameId" class="input" placeholder="임시 템플릿 ID" />
|
||||
</label>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="templateRequestCard__thumbLabel">게임 이름</div>
|
||||
<div class="templateRequestCard__thumbLabel">템플릿 이름</div>
|
||||
<div class="templateRequestCard__thumbValue">{{ request.draftGameName || request.sourceGameName || '-' }}</div>
|
||||
<div class="templateRequestCard__thumbLabel">게임 ID</div>
|
||||
<div class="templateRequestCard__thumbLabel">템플릿 ID</div>
|
||||
<div class="templateRequestCard__thumbValue">{{ request.draftGameId || request.sourceGameId || '-' }}</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -111,7 +111,7 @@ const props = defineProps({
|
||||
request.isHandling
|
||||
? '이동중...'
|
||||
: request.type === 'create' && (request.targetGameName || request.targetGameId)
|
||||
? '연결된 게임 열기'
|
||||
? '연결된 템플릿 열기'
|
||||
: '확인하기'
|
||||
}}
|
||||
</button>
|
||||
|
||||
@@ -15,13 +15,13 @@ export function useAdminCustomItems({
|
||||
modalTargetCustomItem,
|
||||
customItemModalDraftLabel,
|
||||
customItemModalLabelSaving,
|
||||
customItemModalTargetGameId,
|
||||
games,
|
||||
selectedGameId,
|
||||
customItemModalTargetTemplateId,
|
||||
templates,
|
||||
selectedTemplateId,
|
||||
refreshCustomItems,
|
||||
loadGame,
|
||||
loadTemplate,
|
||||
setTab,
|
||||
selectAdminGame,
|
||||
selectAdminTemplate,
|
||||
resetMessages,
|
||||
success,
|
||||
error,
|
||||
@@ -59,7 +59,7 @@ export function useAdminCustomItems({
|
||||
function openCustomItemModal(item) {
|
||||
modalTargetCustomItem.value = item || null
|
||||
customItemModalDraftLabel.value = item?.label || ''
|
||||
customItemModalTargetGameId.value = ''
|
||||
customItemModalTargetTemplateId.value = ''
|
||||
customItemModalOpen.value = true
|
||||
pushCustomItemModalHistoryState()
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export function useAdminCustomItems({
|
||||
modalTargetCustomItem.value = null
|
||||
customItemModalDraftLabel.value = ''
|
||||
customItemModalLabelSaving.value = false
|
||||
customItemModalTargetGameId.value = ''
|
||||
customItemModalTargetTemplateId.value = ''
|
||||
|
||||
if (fromPopState) {
|
||||
customItemModalHistoryActive.value = false
|
||||
@@ -97,12 +97,12 @@ export function useAdminCustomItems({
|
||||
customItemDeleteModalOpen.value = false
|
||||
}
|
||||
|
||||
function jumpToGameAdmin(gameId) {
|
||||
if (!gameId) return
|
||||
function jumpToTemplateAdmin(templateId) {
|
||||
if (!templateId) return
|
||||
closeCustomItemModal()
|
||||
setTab('game-admin')
|
||||
nextTick(() => {
|
||||
selectAdminGame(gameId)
|
||||
selectAdminTemplate(templateId)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -160,18 +160,19 @@ export function useAdminCustomItems({
|
||||
|
||||
async function promoteCustomItem(item) {
|
||||
resetMessages()
|
||||
if (!customItemModalTargetGameId.value) {
|
||||
error.value = '추가할 게임을 먼저 선택해주세요.'
|
||||
if (!customItemModalTargetTemplateId.value) {
|
||||
error.value = '추가할 템플릿을 먼저 선택해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
item.isPromoting = true
|
||||
await api.promoteAdminCustomItem(item.id, { gameId: customItemModalTargetGameId.value })
|
||||
const targetGameName = games.value.find((game) => game.id === customItemModalTargetGameId.value)?.name || customItemModalTargetGameId.value
|
||||
if (selectedGameId.value === customItemModalTargetGameId.value) await loadGame()
|
||||
await api.promoteAdminTemplateItem(item.id, { gameId: customItemModalTargetTemplateId.value })
|
||||
const targetTemplateName =
|
||||
templates.value.find((template) => template.id === customItemModalTargetTemplateId.value)?.name || customItemModalTargetTemplateId.value
|
||||
if (selectedTemplateId.value === customItemModalTargetTemplateId.value) await loadTemplate()
|
||||
closeCustomItemModal()
|
||||
success.value = `"${item.label}" 이미지를 ${targetGameName} 템플릿으로 추가했어요.`
|
||||
success.value = `"${item.label}" 이미지를 ${targetTemplateName} 템플릿으로 추가했어요.`
|
||||
} catch (e) {
|
||||
error.value = '선택한 이미지를 템플릿으로 추가하지 못했어요.'
|
||||
} finally {
|
||||
@@ -189,7 +190,7 @@ export function useAdminCustomItems({
|
||||
closeCustomItemModal,
|
||||
openCustomItemDeleteModal,
|
||||
closeCustomItemDeleteModal,
|
||||
jumpToGameAdmin,
|
||||
jumpToTemplateAdmin,
|
||||
removeCustomItem,
|
||||
removeUnusedCustomItems,
|
||||
saveCustomItemModalLabel,
|
||||
|
||||
@@ -5,8 +5,8 @@ export function useAdminFeaturedGames({
|
||||
api,
|
||||
featuredListEl,
|
||||
featuredSortable,
|
||||
featuredGameIds,
|
||||
games,
|
||||
featuredTemplateIds,
|
||||
templates,
|
||||
resetMessages,
|
||||
success,
|
||||
error,
|
||||
@@ -31,63 +31,63 @@ export function useAdminFeaturedGames({
|
||||
chosenClass: 'chosen',
|
||||
onEnd: (evt) => {
|
||||
if (evt.oldIndex == null || evt.newIndex == null || evt.oldIndex === evt.newIndex) return
|
||||
const nextIds = [...featuredGameIds.value]
|
||||
const nextIds = [...featuredTemplateIds.value]
|
||||
const [moved] = nextIds.splice(evt.oldIndex, 1)
|
||||
nextIds.splice(evt.newIndex, 0, moved)
|
||||
featuredGameIds.value = nextIds
|
||||
featuredTemplateIds.value = nextIds
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function addFeaturedGame(gameId) {
|
||||
function addFeaturedTemplate(templateId) {
|
||||
resetMessages()
|
||||
if (!gameId || featuredGameIds.value.includes(gameId)) return
|
||||
if (featuredGameIds.value.length >= 50) {
|
||||
error.value = '상단 고정 게임은 최대 50개까지만 설정할 수 있어요.'
|
||||
if (!templateId || featuredTemplateIds.value.includes(templateId)) return
|
||||
if (featuredTemplateIds.value.length >= 50) {
|
||||
error.value = '상단 고정 템플릿은 최대 50개까지만 설정할 수 있어요.'
|
||||
return
|
||||
}
|
||||
featuredGameIds.value = [...featuredGameIds.value, gameId]
|
||||
featuredTemplateIds.value = [...featuredTemplateIds.value, templateId]
|
||||
syncFeaturedSortable()
|
||||
}
|
||||
|
||||
function removeFeaturedGame(gameId) {
|
||||
function removeFeaturedTemplate(templateId) {
|
||||
resetMessages()
|
||||
featuredGameIds.value = featuredGameIds.value.filter((id) => id !== gameId)
|
||||
featuredTemplateIds.value = featuredTemplateIds.value.filter((id) => id !== templateId)
|
||||
syncFeaturedSortable()
|
||||
}
|
||||
|
||||
function moveFeaturedGame(gameId, direction) {
|
||||
const currentIndex = featuredGameIds.value.indexOf(gameId)
|
||||
function moveFeaturedTemplate(templateId, direction) {
|
||||
const currentIndex = featuredTemplateIds.value.indexOf(templateId)
|
||||
const nextIndex = currentIndex + direction
|
||||
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= featuredGameIds.value.length) return
|
||||
const nextIds = [...featuredGameIds.value]
|
||||
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= featuredTemplateIds.value.length) return
|
||||
const nextIds = [...featuredTemplateIds.value]
|
||||
const [moved] = nextIds.splice(currentIndex, 1)
|
||||
nextIds.splice(nextIndex, 0, moved)
|
||||
featuredGameIds.value = nextIds
|
||||
featuredTemplateIds.value = nextIds
|
||||
syncFeaturedSortable()
|
||||
}
|
||||
|
||||
async function saveFeaturedOrder() {
|
||||
resetMessages()
|
||||
try {
|
||||
const data = await api.updateAdminGameDisplayOrder({ gameIds: featuredGameIds.value })
|
||||
games.value = data.games || []
|
||||
featuredGameIds.value = games.value
|
||||
.filter((game) => game.displayRank != null)
|
||||
const data = await api.updateAdminTemplateDisplayOrder({ gameIds: featuredTemplateIds.value })
|
||||
templates.value = data.games || []
|
||||
featuredTemplateIds.value = templates.value
|
||||
.filter((template) => template.displayRank != null)
|
||||
.sort((a, b) => a.displayRank - b.displayRank)
|
||||
.map((game) => game.id)
|
||||
success.value = '홈 화면 게임 순서를 저장했어요.'
|
||||
.map((template) => template.id)
|
||||
success.value = '홈 화면 템플릿 순서를 저장했어요.'
|
||||
} catch (e) {
|
||||
error.value = '게임 순서 저장에 실패했어요.'
|
||||
error.value = '템플릿 순서 저장에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
destroyFeaturedSortable,
|
||||
syncFeaturedSortable,
|
||||
addFeaturedGame,
|
||||
removeFeaturedGame,
|
||||
moveFeaturedGame,
|
||||
addFeaturedTemplate,
|
||||
removeFeaturedTemplate,
|
||||
moveFeaturedTemplate,
|
||||
saveFeaturedOrder,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import Sortable from 'sortablejs'
|
||||
export function useAdminGameManager({
|
||||
api,
|
||||
toApiUrl,
|
||||
selectedGameId,
|
||||
selectedGame,
|
||||
selectedTemplateId,
|
||||
selectedTemplate,
|
||||
uploadFiles,
|
||||
uploadItemDrafts,
|
||||
thumbFile,
|
||||
@@ -18,15 +18,15 @@ export function useAdminGameManager({
|
||||
activeTemplateRequest,
|
||||
templateRequests,
|
||||
customItemModalOpen,
|
||||
customItemModalTargetGameId,
|
||||
newGameId,
|
||||
newGameName,
|
||||
newGameIsPublic,
|
||||
customItemModalTargetTemplateId,
|
||||
newTemplateId,
|
||||
newTemplateName,
|
||||
newTemplateIsPublic,
|
||||
clearPreviewUrl,
|
||||
resetFileInput,
|
||||
resetUploadState,
|
||||
refreshGames,
|
||||
closeGameCreateModal,
|
||||
refreshTemplates,
|
||||
closeTemplateCreateModal,
|
||||
resetMessages,
|
||||
success,
|
||||
error,
|
||||
@@ -59,7 +59,7 @@ export function useAdminGameManager({
|
||||
async function syncGameItemSortable() {
|
||||
await nextTick()
|
||||
destroyGameItemSortable()
|
||||
if (!gameItemListEl.value || !selectedGame.value?.items?.length) return
|
||||
if (!gameItemListEl.value || !selectedTemplate.value?.items?.length) return
|
||||
|
||||
gameItemSortable.value = Sortable.create(gameItemListEl.value, {
|
||||
animation: 160,
|
||||
@@ -73,11 +73,11 @@ export function useAdminGameManager({
|
||||
chosenClass: 'chosen',
|
||||
onEnd: (evt) => {
|
||||
if (evt.oldIndex == null || evt.newIndex == null || evt.oldIndex === evt.newIndex) return
|
||||
const nextItems = [...(selectedGame.value?.items || [])]
|
||||
const nextItems = [...(selectedTemplate.value?.items || [])]
|
||||
const [moved] = nextItems.splice(evt.oldIndex, 1)
|
||||
nextItems.splice(evt.newIndex, 0, moved)
|
||||
selectedGame.value = {
|
||||
...selectedGame.value,
|
||||
selectedTemplate.value = {
|
||||
...selectedTemplate.value,
|
||||
items: nextItems,
|
||||
}
|
||||
},
|
||||
@@ -87,7 +87,7 @@ export function useAdminGameManager({
|
||||
function mergeRequestItemsIntoDrafts(request) {
|
||||
const requestId = request?.id
|
||||
if (!requestId) return
|
||||
const existingGameSrcs = new Set((selectedGame.value?.items || []).map((item) => normalizeDraftSrc(item?.src)).filter(Boolean))
|
||||
const existingTemplateSrcs = new Set((selectedTemplate.value?.items || []).map((item) => normalizeDraftSrc(item?.src)).filter(Boolean))
|
||||
const existingKeys = new Set(uploadItemDrafts.value.map((draft) => `${draft.kind}:${draft.requestId || ''}:${draft.itemId || draft.file?.name || ''}`))
|
||||
const nextRequestDrafts = (request.items || [])
|
||||
.filter((item) => item?.src)
|
||||
@@ -100,7 +100,7 @@ export function useAdminGameManager({
|
||||
sourceName: requestItemFilename(item),
|
||||
src: item.src,
|
||||
}))
|
||||
.filter((draft) => !existingGameSrcs.has(normalizeDraftSrc(draft.src)))
|
||||
.filter((draft) => !existingTemplateSrcs.has(normalizeDraftSrc(draft.src)))
|
||||
.filter((draft) => !existingKeys.has(`${draft.kind}:${draft.requestId}:${draft.itemId}`))
|
||||
|
||||
if (nextRequestDrafts.length) {
|
||||
@@ -117,13 +117,13 @@ export function useAdminGameManager({
|
||||
uploadFiles.value = uploadItemDrafts.value.filter((draft) => draft.kind === 'file').map((draft) => draft.file).filter(Boolean)
|
||||
}
|
||||
|
||||
async function loadGame(options = {}) {
|
||||
async function loadTemplate(options = {}) {
|
||||
const preserveUploadState = !!options.preserveUploadState
|
||||
resetMessages()
|
||||
if (!preserveUploadState) resetUploadState()
|
||||
|
||||
if (!selectedGameId.value) {
|
||||
selectedGame.value = null
|
||||
if (!selectedTemplateId.value) {
|
||||
selectedTemplate.value = null
|
||||
savedGameItemOrderIds.value = []
|
||||
destroyGameItemSortable()
|
||||
return
|
||||
@@ -131,8 +131,8 @@ export function useAdminGameManager({
|
||||
|
||||
try {
|
||||
isGameLoading.value = true
|
||||
const data = await api.getGame(selectedGameId.value)
|
||||
selectedGame.value = {
|
||||
const data = await api.getTopic(selectedTemplateId.value)
|
||||
selectedTemplate.value = {
|
||||
...data,
|
||||
items: (data.items || []).map((item) => ({
|
||||
...item,
|
||||
@@ -142,27 +142,27 @@ export function useAdminGameManager({
|
||||
savedGameItemOrderIds.value = (data.items || []).map((item) => item.id)
|
||||
await syncGameItemSortable()
|
||||
} catch (e) {
|
||||
selectedGame.value = null
|
||||
error.value = '게임 정보를 불러오지 못했어요.'
|
||||
selectedTemplate.value = null
|
||||
error.value = '템플릿 정보를 불러오지 못했어요.'
|
||||
} finally {
|
||||
isGameLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createGame(options = {}) {
|
||||
const nextGameId = typeof options.gameId === 'string' ? options.gameId.trim() : newGameId.value.trim()
|
||||
const nextGameName = typeof options.gameName === 'string' ? options.gameName.trim() : newGameName.value.trim()
|
||||
async function createTemplate(options = {}) {
|
||||
const nextGameId = typeof options.gameId === 'string' ? options.gameId.trim() : newTemplateId.value.trim()
|
||||
const nextGameName = typeof options.gameName === 'string' ? options.gameName.trim() : newTemplateName.value.trim()
|
||||
const preserveUploadState = !!options.preserveUploadState
|
||||
resetMessages()
|
||||
try {
|
||||
const res = await fetch(toApiUrl('/api/admin/games'), {
|
||||
const res = await fetch(toApiUrl('/api/admin/templates'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: nextGameId,
|
||||
name: nextGameName,
|
||||
isPublic: !!newGameIsPublic.value,
|
||||
isPublic: !!newTemplateIsPublic.value,
|
||||
thumbnailSrc: activeTemplateRequest.value?.type === 'create' ? (activeTemplateRequest.value?.thumbnailSrc || '') : '',
|
||||
}),
|
||||
})
|
||||
@@ -187,18 +187,18 @@ export function useAdminGameManager({
|
||||
})
|
||||
}
|
||||
}
|
||||
await refreshGames()
|
||||
selectedGameId.value = data.game.id
|
||||
if (customItemModalOpen.value) customItemModalTargetGameId.value = data.game.id
|
||||
closeGameCreateModal()
|
||||
await loadGame({ preserveUploadState })
|
||||
await refreshTemplates()
|
||||
selectedTemplateId.value = data.game.id
|
||||
if (customItemModalOpen.value) customItemModalTargetTemplateId.value = data.game.id
|
||||
closeTemplateCreateModal()
|
||||
await loadTemplate({ preserveUploadState })
|
||||
if (!preserveUploadState && activeTemplateRequest.value?.id) {
|
||||
const sourceRequest = templateRequests.value.find((entry) => entry.id === activeTemplateRequest.value.id) || activeTemplateRequest.value
|
||||
mergeRequestItemsIntoDrafts(sourceRequest)
|
||||
}
|
||||
success.value = '게임이 생성됐어요. 이어서 썸네일과 기본 아이템을 관리할 수 있어요.'
|
||||
success.value = '템플릿이 생성됐어요. 이어서 썸네일과 기본 아이템을 관리할 수 있어요.'
|
||||
} catch (e) {
|
||||
error.value = '게임 생성 실패(관리자 권한/중복 ID 확인)'
|
||||
error.value = '템플릿 생성 실패(관리자 권한/중복 ID 확인)'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,22 +254,22 @@ export function useAdminGameManager({
|
||||
}
|
||||
|
||||
try {
|
||||
if (!selectedGameId.value && activeTemplateRequest.value?.type === 'create' && !activeTemplateRequest.value?.targetGameId) {
|
||||
if (!selectedTemplateId.value && activeTemplateRequest.value?.type === 'create' && !activeTemplateRequest.value?.targetGameId) {
|
||||
const draftGameId = (activeTemplateRequest.value?.draftGameId || '').trim()
|
||||
const draftGameName = (activeTemplateRequest.value?.draftGameName || '').trim()
|
||||
if (!draftGameId || !draftGameName) {
|
||||
error.value = '먼저 신규 템플릿의 게임 이름과 게임 ID를 저장해주세요.'
|
||||
error.value = '먼저 신규 템플릿의 이름과 템플릿 ID를 저장해주세요.'
|
||||
return
|
||||
}
|
||||
await createGame({
|
||||
await createTemplate({
|
||||
gameId: draftGameId,
|
||||
gameName: draftGameName,
|
||||
preserveUploadState: true,
|
||||
})
|
||||
}
|
||||
|
||||
if (!selectedGameId.value) {
|
||||
error.value = '게임을 먼저 선택해주세요.'
|
||||
if (!selectedTemplateId.value) {
|
||||
error.value = '템플릿을 먼저 선택해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -283,7 +283,7 @@ export function useAdminGameManager({
|
||||
fd.append('images', entry.file)
|
||||
fd.append('labels', entry.label.trim())
|
||||
})
|
||||
const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedGameId.value)}/images`), {
|
||||
const res = await fetch(toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/images`), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: fd,
|
||||
@@ -297,7 +297,7 @@ export function useAdminGameManager({
|
||||
for (const requestId of requestIds) {
|
||||
const draftsForRequest = requestDrafts.filter((entry) => entry.requestId === requestId)
|
||||
const result = await api.promoteAdminTemplateRequestItems(requestId, {
|
||||
gameId: selectedGameId.value,
|
||||
gameId: selectedTemplateId.value,
|
||||
itemIds: draftsForRequest.map((entry) => entry.itemId).filter(Boolean),
|
||||
itemSrcs: draftsForRequest.map((entry) => entry.src).filter(Boolean),
|
||||
itemLabels: draftsForRequest.reduce((acc, entry) => {
|
||||
@@ -310,8 +310,8 @@ export function useAdminGameManager({
|
||||
}
|
||||
|
||||
resetUploadState()
|
||||
await loadGame()
|
||||
success.value = `게임 기본 아이템 ${uploadCount}개 추가를 완료했어요.`
|
||||
await loadTemplate()
|
||||
success.value = `템플릿 기본 아이템 ${uploadCount}개 추가를 완료했어요.`
|
||||
} catch (e) {
|
||||
const apiError = e?.data?.error || ''
|
||||
if (apiError === 'no_items_selected') {
|
||||
@@ -320,27 +320,27 @@ export function useAdminGameManager({
|
||||
}
|
||||
if (apiError === 'promote_items_failed') {
|
||||
const detail = e?.data?.detail ? ` (${e.data.detail})` : ''
|
||||
error.value = `요청 아이템을 게임 기본 아이템으로 옮기지 못했어요.${detail}`
|
||||
error.value = `요청 아이템을 템플릿 기본 아이템으로 옮기지 못했어요.${detail}`
|
||||
return
|
||||
}
|
||||
if (apiError === 'game_not_found') {
|
||||
error.value = '선택한 게임을 찾지 못했어요.'
|
||||
error.value = '선택한 템플릿을 찾지 못했어요.'
|
||||
return
|
||||
}
|
||||
error.value = '아이템 추가에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGameItemOrder() {
|
||||
async function saveTemplateItemOrder() {
|
||||
resetMessages()
|
||||
if (!selectedGameId.value || !selectedGame.value?.items?.length) return
|
||||
if (!selectedTemplateId.value || !selectedTemplate.value?.items?.length) return
|
||||
|
||||
try {
|
||||
const data = await api.updateAdminGameItemDisplayOrder(selectedGameId.value, {
|
||||
itemIds: selectedGame.value.items.map((item) => item.id),
|
||||
const data = await api.updateAdminTemplateItemDisplayOrder(selectedTemplateId.value, {
|
||||
itemIds: selectedTemplate.value.items.map((item) => item.id),
|
||||
})
|
||||
selectedGame.value = {
|
||||
...selectedGame.value,
|
||||
selectedTemplate.value = {
|
||||
...selectedTemplate.value,
|
||||
items: (data.items || []).map((item) => ({
|
||||
...item,
|
||||
draftLabel: item.label,
|
||||
@@ -360,13 +360,13 @@ export function useAdminGameManager({
|
||||
syncGameItemSortable,
|
||||
mergeRequestItemsIntoDrafts,
|
||||
removeUploadDraft,
|
||||
loadGame,
|
||||
createGame,
|
||||
loadTemplate,
|
||||
createTemplate,
|
||||
handleItemFiles,
|
||||
onFile,
|
||||
openItemFilePicker,
|
||||
clearItemFiles,
|
||||
uploadItem,
|
||||
saveGameItemOrder,
|
||||
saveTemplateItemOrder,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { editorPath } from '../lib/paths'
|
||||
|
||||
export function useAdminTemplateRequests({
|
||||
api,
|
||||
activeTemplateRequest,
|
||||
refreshTemplateRequests,
|
||||
setTab,
|
||||
openGameCreateModal,
|
||||
newGameId,
|
||||
newGameName,
|
||||
selectAdminGame,
|
||||
openTemplateCreateModal,
|
||||
newTemplateId,
|
||||
newTemplateName,
|
||||
selectAdminTemplate,
|
||||
mergeRequestItemsIntoDrafts,
|
||||
resetMessages,
|
||||
success,
|
||||
@@ -37,12 +39,12 @@ export function useAdminTemplateRequests({
|
||||
|
||||
function templateRequestSourceUrl(request) {
|
||||
if (!request?.sourceGameId || !request?.sourceTierListId) return ''
|
||||
return `/editor/${request.sourceGameId}/${request.sourceTierListId}?preview=1`
|
||||
return editorPath(request.sourceGameId, request.sourceTierListId, { preview: true })
|
||||
}
|
||||
|
||||
function templateRequestReviewHint(request) {
|
||||
if (request.type === 'create') return '게임 생성 후 필요한 아이템만 골라 추가하고, 끝나면 여기서 처리 완료하세요.'
|
||||
return '확인하기를 누르면 게임 관리 화면에 요청 아이템이 임시 추가되어 필요한 것만 선별 저장할 수 있어요.'
|
||||
if (request.type === 'create') return '템플릿 생성 후 필요한 아이템만 골라 추가하고, 끝나면 여기서 처리 완료하세요.'
|
||||
return '확인하기를 누르면 템플릿 관리 화면에 요청 아이템이 임시 추가되어 필요한 것만 선별 저장할 수 있어요.'
|
||||
}
|
||||
|
||||
async function startTemplateRequestReview(request) {
|
||||
@@ -65,19 +67,19 @@ export function useAdminTemplateRequests({
|
||||
if (request.type === 'create') {
|
||||
const linkedGameId = syncedRequest.targetGameId || ''
|
||||
if (linkedGameId) {
|
||||
await selectAdminGame(linkedGameId)
|
||||
await selectAdminTemplate(linkedGameId)
|
||||
} else {
|
||||
openGameCreateModal()
|
||||
newGameId.value = (syncedRequest.draftGameId || '').trim()
|
||||
newGameName.value = (syncedRequest.draftGameName || '').trim()
|
||||
openTemplateCreateModal()
|
||||
newTemplateId.value = (syncedRequest.draftGameId || '').trim()
|
||||
newTemplateName.value = (syncedRequest.draftGameName || '').trim()
|
||||
}
|
||||
mergeRequestItemsIntoDrafts(syncedRequest)
|
||||
} else {
|
||||
const nextGameId = syncedRequest.targetGameId || syncedRequest.sourceGameId || ''
|
||||
if (nextGameId) await selectAdminGame(nextGameId)
|
||||
if (nextGameId) await selectAdminTemplate(nextGameId)
|
||||
mergeRequestItemsIntoDrafts(syncedRequest)
|
||||
}
|
||||
success.value = '요청 아이템을 게임 관리 화면으로 가져왔어요. 필요한 항목만 골라서 추가해 주세요.'
|
||||
success.value = '요청 아이템을 템플릿 관리 화면으로 가져왔어요. 필요한 항목만 골라서 추가해 주세요.'
|
||||
} catch (e) {
|
||||
error.value = '요청 확인 단계로 이동하지 못했어요.'
|
||||
} finally {
|
||||
|
||||
@@ -30,27 +30,27 @@ export const api = {
|
||||
login: ({ email, password }) => request('/api/auth/login', { method: 'POST', body: { email, password } }),
|
||||
logout: () => request('/api/auth/logout', { method: 'POST' }),
|
||||
|
||||
listGames: () => request('/api/games'),
|
||||
getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`),
|
||||
favoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'POST' }),
|
||||
unfavoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'DELETE' }),
|
||||
updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }),
|
||||
updateAdminGameItemDisplayOrder: (gameId, payload) =>
|
||||
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/display-order`, { method: 'PATCH', body: payload }),
|
||||
updateAdminGame: (gameId, payload) =>
|
||||
request(`/api/admin/games/${encodeURIComponent(gameId)}`, { method: 'PATCH', body: payload }),
|
||||
updateAdminGameItem: (gameId, itemId, payload) =>
|
||||
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
|
||||
listTopics: () => request('/api/topics'),
|
||||
getTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}`),
|
||||
favoriteTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}/favorite`, { method: 'POST' }),
|
||||
unfavoriteTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}/favorite`, { method: 'DELETE' }),
|
||||
updateAdminTemplateDisplayOrder: (payload) => request('/api/admin/templates/display-order', { method: 'PATCH', body: payload }),
|
||||
updateAdminTemplateItemDisplayOrder: (templateId, payload) =>
|
||||
request(`/api/admin/templates/${encodeURIComponent(templateId)}/items/display-order`, { method: 'PATCH', body: payload }),
|
||||
updateAdminTemplate: (templateId, payload) =>
|
||||
request(`/api/admin/templates/${encodeURIComponent(templateId)}`, { method: 'PATCH', body: payload }),
|
||||
updateAdminTemplateItem: (templateId, itemId, payload) =>
|
||||
request(`/api/admin/templates/${encodeURIComponent(templateId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
|
||||
listAdminCustomItems: ({ q = '', page = 1, limit = 50, filter = 'all' } = {}) =>
|
||||
request(
|
||||
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}`
|
||||
),
|
||||
listAdminTierLists: ({ q = '', gameId = '', page = 1, limit = 50 } = {}) =>
|
||||
listAdminTierLists: ({ q = '', topicId = '', gameId = '', page = 1, limit = 50 } = {}) =>
|
||||
request(
|
||||
`/api/admin/tierlists?q=${encodeURIComponent(q)}&gameId=${encodeURIComponent(gameId)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`
|
||||
`/api/admin/tierlists?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId || gameId)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`
|
||||
),
|
||||
getAdminTierListStats: ({ q = '', gameId = '' } = {}) =>
|
||||
request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&gameId=${encodeURIComponent(gameId)}`),
|
||||
getAdminTierListStats: ({ q = '', topicId = '', gameId = '' } = {}) =>
|
||||
request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId || gameId)}`),
|
||||
updateAdminTierList: (tierListId, payload) =>
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'PATCH', body: payload }),
|
||||
deleteAdminTierList: (tierListId) =>
|
||||
@@ -66,13 +66,13 @@ export const api = {
|
||||
cleanupAdminMissingImageReferences: () => request('/api/admin/image-assets/missing/cleanup', { method: 'POST', body: {} }),
|
||||
listAdminUnusedImageAssets: ({ limit = 100, minAgeHours = 24 } = {}) => request(`/api/admin/image-assets/orphans?limit=${encodeURIComponent(limit)}&minAgeHours=${encodeURIComponent(minAgeHours)}`),
|
||||
cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }),
|
||||
promoteAdminCustomItem: (itemId, payload) =>
|
||||
promoteAdminTemplateItem: (itemId, payload) =>
|
||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
|
||||
updateAdminCustomItemLabel: (itemId, payload) =>
|
||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/label`, { method: 'PATCH', body: payload }),
|
||||
promoteAdminTierListItems: (tierListId, payload) =>
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/promote-items`, { method: 'POST', body: payload }),
|
||||
createAdminGameTemplateFromTierList: (tierListId, payload) =>
|
||||
createAdminTemplateFromTierList: (tierListId, payload) =>
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-game-template`, { method: 'POST', body: payload }),
|
||||
startAdminTemplateRequestReview: (requestId) =>
|
||||
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/review`, { method: 'POST', body: {} }),
|
||||
@@ -111,10 +111,10 @@ export const api = {
|
||||
},
|
||||
deleteAdminUser: (userId) => request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }),
|
||||
|
||||
listPublicTierLists: (gameId) =>
|
||||
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}`),
|
||||
searchPublicTierLists: (gameId, q = '') =>
|
||||
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}&q=${encodeURIComponent(q || '')}`),
|
||||
listPublicTierListsByTopic: (topicId) =>
|
||||
request(`/api/tierlists/public?topicId=${encodeURIComponent(topicId || '')}`),
|
||||
searchPublicTierListsByTopic: (topicId, q = '') =>
|
||||
request(`/api/tierlists/public?topicId=${encodeURIComponent(topicId || '')}&q=${encodeURIComponent(q || '')}`),
|
||||
searchAllPublicTierLists: (q = '') => request(`/api/tierlists/public?q=${encodeURIComponent(q || '')}`),
|
||||
listMyTierLists: () => request('/api/tierlists/me'),
|
||||
listMyFavoriteTierLists: ({ q = '', sort = 'favorited' } = {}) =>
|
||||
@@ -146,4 +146,24 @@ export const api = {
|
||||
deleteAdminCustomItem: (itemId) => request(`/api/admin/custom-items/${encodeURIComponent(itemId)}`, { method: 'DELETE' }),
|
||||
deleteAdminUnusedCustomItems: ({ q = '' } = {}) =>
|
||||
request(`/api/admin/custom-items?q=${encodeURIComponent(q)}`, { method: 'DELETE' }),
|
||||
|
||||
listGames: () => request('/api/games'),
|
||||
getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`),
|
||||
favoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'POST' }),
|
||||
unfavoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'DELETE' }),
|
||||
updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }),
|
||||
updateAdminGameItemDisplayOrder: (gameId, payload) =>
|
||||
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/display-order`, { method: 'PATCH', body: payload }),
|
||||
updateAdminGame: (gameId, payload) =>
|
||||
request(`/api/admin/games/${encodeURIComponent(gameId)}`, { method: 'PATCH', body: payload }),
|
||||
updateAdminGameItem: (gameId, itemId, payload) =>
|
||||
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
|
||||
promoteAdminCustomItem: (itemId, payload) =>
|
||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
|
||||
createAdminGameTemplateFromTierList: (tierListId, payload) =>
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-game-template`, { method: 'POST', body: payload }),
|
||||
listPublicTierLists: (gameId) =>
|
||||
request(`/api/tierlists/public?topicId=${encodeURIComponent(gameId || '')}`),
|
||||
searchPublicTierLists: (gameId, q = '') =>
|
||||
request(`/api/tierlists/public?topicId=${encodeURIComponent(gameId || '')}&q=${encodeURIComponent(q || '')}`),
|
||||
}
|
||||
|
||||
38
frontend/src/lib/paths.js
Normal file
@@ -0,0 +1,38 @@
|
||||
function encodeSegment(value) {
|
||||
return encodeURIComponent(String(value || '').trim())
|
||||
}
|
||||
|
||||
export function homePath(query = '') {
|
||||
const normalized = String(query || '').trim()
|
||||
return normalized ? `/?q=${encodeURIComponent(normalized)}` : '/'
|
||||
}
|
||||
|
||||
export function loginPath(redirect = '') {
|
||||
const normalized = String(redirect || '').trim()
|
||||
return normalized ? `/login?redirect=${encodeURIComponent(normalized)}` : '/login'
|
||||
}
|
||||
|
||||
export function topicPath(topicId) {
|
||||
return `/topics/${encodeSegment(topicId)}`
|
||||
}
|
||||
|
||||
export function editorNewPath(topicId) {
|
||||
return `/editor/${encodeSegment(topicId)}/new`
|
||||
}
|
||||
|
||||
export function editorPath(topicId, tierListId, { preview = false } = {}) {
|
||||
const base = `/editor/${encodeSegment(topicId)}/${encodeSegment(tierListId)}`
|
||||
return preview ? `${base}?preview=1` : base
|
||||
}
|
||||
|
||||
export function mePath() {
|
||||
return '/me'
|
||||
}
|
||||
|
||||
export function favoritesPath() {
|
||||
return '/favorites'
|
||||
}
|
||||
|
||||
export function profilePath() {
|
||||
return '/profile'
|
||||
}
|
||||
@@ -16,9 +16,15 @@ export function createRouter() {
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: HomeView },
|
||||
{ path: '/games/:gameId', name: 'gameHub', component: GameHubView },
|
||||
{ path: '/editor/:gameId/new', name: 'newEditor', component: TierEditorView },
|
||||
{ path: '/editor/:gameId/:tierListId', name: 'editEditor', component: TierEditorView },
|
||||
{ path: '/games/:gameId', redirect: (to) => `/topics/${encodeURIComponent(String(to.params.gameId || ''))}` },
|
||||
{ path: '/topics/:topicId', name: 'topicHub', component: GameHubView },
|
||||
{ path: '/editor/:gameId/new', redirect: (to) => `/editor/${encodeURIComponent(String(to.params.gameId || ''))}/new` },
|
||||
{ path: '/editor/:topicId/new', name: 'newEditor', component: TierEditorView },
|
||||
{
|
||||
path: '/editor/:gameId/:tierListId',
|
||||
redirect: (to) => `/editor/${encodeURIComponent(String(to.params.gameId || ''))}/${encodeURIComponent(String(to.params.tierListId || ''))}`,
|
||||
},
|
||||
{ path: '/editor/:topicId/:tierListId', name: 'editEditor', component: TierEditorView },
|
||||
{ path: '/login', name: 'login', component: LoginView },
|
||||
{ path: '/me', name: 'me', component: MyTierListsView },
|
||||
{ path: '/favorites', name: 'favorites', component: FavoriteTierListsView },
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import { editorPath, loginPath } from '../lib/paths'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
@@ -42,12 +43,12 @@ async function loadFavorites() {
|
||||
favorites.value = data.tierLists || []
|
||||
} catch (e) {
|
||||
toast.error('로그인이 필요해요.')
|
||||
router.push('/login?redirect=/favorites')
|
||||
router.push(loginPath('/favorites'))
|
||||
}
|
||||
}
|
||||
|
||||
function openTierList(tierList) {
|
||||
router.push(`/editor/${tierList.gameId}/${tierList.id}`)
|
||||
router.push(editorPath(tierList.gameId, tierList.id))
|
||||
}
|
||||
|
||||
onMounted(loadFavorites)
|
||||
@@ -58,11 +59,11 @@ onMounted(loadFavorites)
|
||||
<div class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Collection</div>
|
||||
<h2 class="pageHead__title">내 즐겨찾기</h2>
|
||||
<h2 class="pageHead__title">즐겨찾기</h2>
|
||||
<div class="pageHead__desc">마음에 드는 티어표를 모아보고, 원하는 기준으로 정렬할 수 있어요.</div>
|
||||
</div>
|
||||
<div class="pageHead__aside toolbar">
|
||||
<input v-model="query" class="input" placeholder="제목, 게임, 작성자 검색" @keydown.enter.prevent="loadFavorites" />
|
||||
<input v-model="query" class="input" placeholder="제목, 주제, 작성자 검색" @keydown.enter.prevent="loadFavorites" />
|
||||
<select v-model="sort" class="select" @change="loadFavorites">
|
||||
<option value="favorited">즐겨찾기한 순</option>
|
||||
<option value="updated">최신 업데이트순</option>
|
||||
@@ -77,7 +78,7 @@ onMounted(loadFavorites)
|
||||
<article v-for="tierList in favorites" :key="tierList.id" class="boardCard">
|
||||
<button class="boardCard__body" @click="openTierList(tierList)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" />
|
||||
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" draggable="false" />
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="boardCard__head">
|
||||
@@ -87,7 +88,7 @@ onMounted(loadFavorites)
|
||||
</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<div class="boardCard__author">
|
||||
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" />
|
||||
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" draggable="false" />
|
||||
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
|
||||
<span class="boardCard__authorName">{{ displayNameOf(tierList) }}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { editorNewPath, editorPath, loginPath } from '../lib/paths'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const gameId = computed(() => route.params.gameId)
|
||||
|
||||
const gameName = ref('')
|
||||
const topicId = computed(() => route.params.topicId || route.params.gameId)
|
||||
const topicName = ref('')
|
||||
const tierLists = ref([])
|
||||
const error = ref('')
|
||||
const query = ref('')
|
||||
const brokenThumbnailIds = ref({})
|
||||
const isTopicLoading = ref(false)
|
||||
const isListView = computed(() => route.query.view === 'list')
|
||||
const topicTitle = computed(() => topicName.value || (isTopicLoading.value ? '주제 불러오는 중...' : ''))
|
||||
|
||||
function fmt(ts) {
|
||||
return new Date(ts).toLocaleDateString(undefined, {
|
||||
@@ -48,68 +50,72 @@ function handleThumbnailError(tierListId) {
|
||||
brokenThumbnailIds.value = { ...brokenThumbnailIds.value, [tierListId]: true }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadTierLists()
|
||||
})
|
||||
|
||||
async function loadTierLists() {
|
||||
isTopicLoading.value = true
|
||||
try {
|
||||
const [gameRes, listRes] = await Promise.all([
|
||||
api.getGame(gameId.value),
|
||||
api.searchPublicTierLists(gameId.value, query.value),
|
||||
api.getTopic(topicId.value),
|
||||
api.searchPublicTierListsByTopic(topicId.value, query.value),
|
||||
])
|
||||
gameName.value = gameRes.game?.name || gameId.value
|
||||
topicName.value = gameRes.game?.name || ''
|
||||
brokenThumbnailIds.value = {}
|
||||
tierLists.value = listRes.tierLists || []
|
||||
} catch (e) {
|
||||
error.value = '게임 정보를 불러오지 못했어요.'
|
||||
error.value = '주제 정보를 불러오지 못했어요.'
|
||||
} finally {
|
||||
isTopicLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function createNew() {
|
||||
const target = editorNewPath(topicId.value)
|
||||
if (!auth.user) {
|
||||
router.push(`/login?redirect=/editor/${gameId.value}/new`)
|
||||
router.push(loginPath(target))
|
||||
return
|
||||
}
|
||||
router.push(`/editor/${gameId.value}/new`)
|
||||
router.push(target)
|
||||
}
|
||||
|
||||
function openTierList(id) {
|
||||
router.push(`/editor/${gameId.value}/${id}`)
|
||||
router.push(editorPath(topicId.value, id))
|
||||
}
|
||||
|
||||
function submitSearch() {
|
||||
loadTierLists()
|
||||
}
|
||||
|
||||
watch(
|
||||
topicId,
|
||||
() => {
|
||||
topicName.value = ''
|
||||
error.value = ''
|
||||
loadTierLists()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="dashboardHero">
|
||||
<div class="dashboardHero__left">
|
||||
<div class="dashboardHero__eyebrow">Collection</div>
|
||||
<h2 class="dashboardHero__title">{{ gameName || gameId }}</h2>
|
||||
<p class="dashboardHero__desc">이 게임의 공개 티어표를 탐색하고, 바로 새 보드를 만들어 같은 흐름으로 이어갈 수 있어요.</p>
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Collection</div>
|
||||
<h2 class="pageHead__title">{{ topicTitle }}</h2>
|
||||
<div class="pageHead__desc">이 주제의 공개 티어표를 같은 카드 레이아웃으로 살펴보고 이어서 새 티어표를 만들 수 있어요.</div>
|
||||
</div>
|
||||
<div class="pageHead__aside toolbar">
|
||||
<input v-model="query" class="input" placeholder="제목 또는 작성자 검색" @keydown.enter.prevent="submitSearch" />
|
||||
<button class="btn" @click="submitSearch">검색</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<section class="panel">
|
||||
<div class="panel__head">
|
||||
<div>
|
||||
<div class="panel__title">공개 티어표</div>
|
||||
<div class="panel__sub">제목이나 작성자로 빠르게 좁혀볼 수 있어요.</div>
|
||||
</div>
|
||||
<div class="searchBar">
|
||||
<input v-model="query" class="searchBar__input" placeholder="제목 또는 작성자 검색" @keydown.enter.prevent="submitSearch" />
|
||||
<button class="searchBar__button" @click="submitSearch">검색</button>
|
||||
</div>
|
||||
</div>
|
||||
<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 }">
|
||||
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" @click="openTierList(t.id)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" alt="" @error="handleThumbnailError(t.id)" />
|
||||
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" alt="" draggable="false" @error="handleThumbnailError(t.id)" />
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="boardCard__head">
|
||||
@@ -121,7 +127,7 @@ function submitSearch() {
|
||||
</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<div class="boardCard__author">
|
||||
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
|
||||
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" draggable="false" />
|
||||
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
|
||||
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
|
||||
</div>
|
||||
@@ -135,72 +141,17 @@ function submitSearch() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboardHero {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px 2px 18px;
|
||||
}
|
||||
.dashboardHero__left {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.dashboardHero__eyebrow {
|
||||
font-size: 12px;
|
||||
color: var(--theme-text-soft);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.dashboardHero__title {
|
||||
margin: 4px 0 6px;
|
||||
font-size: 32px;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--theme-text-strong);
|
||||
}
|
||||
.dashboardHero__desc {
|
||||
margin: 0;
|
||||
color: var(--theme-text-muted);
|
||||
max-width: 720px;
|
||||
}
|
||||
.panel {
|
||||
/* border: 1px solid var(--theme-border); */
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.error {
|
||||
margin: 10px 0 14px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--theme-danger-border);
|
||||
background: var(--theme-danger-bg);
|
||||
}
|
||||
.panel__title {
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
}
|
||||
.panel__sub {
|
||||
margin-top: 6px;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.panel__head {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.searchBar {
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.searchBar__input {
|
||||
.input {
|
||||
min-width: 240px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
@@ -208,8 +159,8 @@ function submitSearch() {
|
||||
background: var(--theme-surface-soft);
|
||||
color: var(--theme-text);
|
||||
}
|
||||
.searchBar__button {
|
||||
padding: 11px 14px;
|
||||
.btn {
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-surface-soft-2);
|
||||
@@ -217,6 +168,13 @@ function submitSearch() {
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.error {
|
||||
margin: 10px 0 14px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--theme-danger-border);
|
||||
background: var(--theme-danger-bg);
|
||||
}
|
||||
.empty {
|
||||
opacity: 0.75;
|
||||
}
|
||||
@@ -405,7 +363,11 @@ function submitSearch() {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.searchBar__input {
|
||||
.toolbar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -5,18 +5,19 @@ import { api } from '../lib/api'
|
||||
import SvgIcon from '../components/SvgIcon.vue'
|
||||
import kidStarIcon from '../assets/icons/kid_star.svg'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { loginPath, topicPath } from '../lib/paths'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const items = ref([])
|
||||
const templateRecords = ref([])
|
||||
const error = ref('')
|
||||
const loadingFavoriteId = ref('')
|
||||
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim().toLowerCase() : ''))
|
||||
const games = computed(() => {
|
||||
const filtered = items.value
|
||||
const templates = computed(() => {
|
||||
const filtered = templateRecords.value
|
||||
.filter((item) => item.id !== 'freeform')
|
||||
.filter((item) => {
|
||||
if (!query.value) return true
|
||||
@@ -33,34 +34,34 @@ const games = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
async function loadGames() {
|
||||
async function loadTemplates() {
|
||||
try {
|
||||
const data = await api.listGames()
|
||||
items.value = data.games || []
|
||||
const data = await api.listTopics()
|
||||
templateRecords.value = data.games || []
|
||||
} catch (e) {
|
||||
error.value = '백엔드에 연결할 수 없어요. backend 서버가 실행 중인지 확인해주세요.'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadGames)
|
||||
watch(() => auth.user?.id, loadGames)
|
||||
onMounted(loadTemplates)
|
||||
watch(() => auth.user?.id, loadTemplates)
|
||||
|
||||
function goGame(gameId) {
|
||||
router.push(`/games/${gameId}`)
|
||||
function openTopic(templateId) {
|
||||
router.push(topicPath(templateId))
|
||||
}
|
||||
|
||||
async function toggleFavorite(game, event) {
|
||||
async function toggleFavorite(template, event) {
|
||||
event?.stopPropagation()
|
||||
if (!auth.user) {
|
||||
router.push(`/login?redirect=${encodeURIComponent(route.fullPath || '/')}`)
|
||||
router.push(loginPath(route.fullPath || '/'))
|
||||
return
|
||||
}
|
||||
if (!game?.id || loadingFavoriteId.value === game.id) return
|
||||
if (!template?.id || loadingFavoriteId.value === template.id) return
|
||||
|
||||
try {
|
||||
loadingFavoriteId.value = game.id
|
||||
const res = game.isFavorited ? await api.unfavoriteGame(game.id) : await api.favoriteGame(game.id)
|
||||
items.value = items.value.map((entry) => (entry.id === game.id ? { ...entry, ...res.game } : entry))
|
||||
loadingFavoriteId.value = template.id
|
||||
const res = template.isFavorited ? await api.unfavoriteTopic(template.id) : await api.favoriteTopic(template.id)
|
||||
templateRecords.value = templateRecords.value.map((entry) => (entry.id === template.id ? { ...entry, ...res.game } : entry))
|
||||
} catch (e) {
|
||||
error.value = '즐겨찾기 변경에 실패했어요.'
|
||||
} finally {
|
||||
@@ -68,46 +69,46 @@ async function toggleFavorite(game, event) {
|
||||
}
|
||||
}
|
||||
|
||||
function thumbUrl(g) {
|
||||
return g.thumbnailSrc ? toApiUrl(g.thumbnailSrc) : ''
|
||||
function templateThumbUrl(template) {
|
||||
return template.thumbnailSrc ? toApiUrl(template.thumbnailSrc) : ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Workspace</div>
|
||||
<h1 class="pageHead__title">Game Library</h1>
|
||||
<p class="pageHead__desc">자주 쓰는 게임 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 수 있어요.</p>
|
||||
<p v-if="query" class="pageHead__searchState">"{{ query }}"에 맞는 게임 템플릿만 보고 있어요.</p>
|
||||
<div class="pageHead__eyebrow">Topic</div>
|
||||
<h1 class="pageHead__title">주제 선택</h1>
|
||||
<p class="pageHead__desc">자주 쓰는 주제 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 수 있어요.</p>
|
||||
<p v-if="query" class="pageHead__searchState">"{{ query }}"에 맞는 주제 템플릿만 보고 있어요.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<TransitionGroup v-if="games.length" name="libraryCard" tag="section" class="libraryGrid">
|
||||
<article v-for="g in games" :key="g.id" class="libraryCard">
|
||||
<TransitionGroup v-if="templates.length" name="libraryCard" tag="section" class="libraryGrid">
|
||||
<article v-for="template in templates" :key="template.id" class="libraryCard">
|
||||
<button
|
||||
class="libraryCard__favorite"
|
||||
type="button"
|
||||
:class="{ 'libraryCard__favorite--active': g.isFavorited }"
|
||||
:disabled="loadingFavoriteId === g.id"
|
||||
@click.stop="toggleFavorite(g, $event)"
|
||||
:class="{ 'libraryCard__favorite--active': template.isFavorited }"
|
||||
:disabled="loadingFavoriteId === template.id"
|
||||
@click.stop="toggleFavorite(template, $event)"
|
||||
>
|
||||
<SvgIcon class="libraryCard__favoriteIcon" :src="kidStarIcon" :size="18" />
|
||||
</button>
|
||||
<button class="libraryCard__main" type="button" @click="goGame(g.id)">
|
||||
<button class="libraryCard__main" type="button" @click="openTopic(template.id)">
|
||||
<div class="libraryCard__thumbWrap">
|
||||
<img v-if="thumbUrl(g)" class="libraryCard__thumb" :src="thumbUrl(g)" :alt="g.name" />
|
||||
<img v-if="templateThumbUrl(template)" class="libraryCard__thumb" :src="templateThumbUrl(template)" :alt="template.name" draggable="false" />
|
||||
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="libraryCard__body">
|
||||
<div class="libraryCard__title">{{ g.name }}</div>
|
||||
<div class="libraryCard__meta">{{ g.id }}</div>
|
||||
<div class="libraryCard__title">{{ template.name }}</div>
|
||||
<div class="libraryCard__meta">{{ template.id }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
</TransitionGroup>
|
||||
<div v-else class="libraryEmpty">{{ query ? '검색어에 맞는 게임 템플릿이 없어요.' : '표시할 게임 템플릿이 없어요.' }}</div>
|
||||
<div v-else class="libraryEmpty">{{ query ? '검색어에 맞는 주제 템플릿이 없어요.' : '표시할 주제 템플릿이 없어요.' }}</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
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()
|
||||
@@ -36,7 +37,7 @@ const checkingSession = computed(() => !authReady.value || auth.status === 'load
|
||||
onMounted(async () => {
|
||||
if (!auth.hydrated) await auth.refresh()
|
||||
if (auth.user) {
|
||||
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : '/me')
|
||||
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : mePath())
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -51,7 +52,7 @@ watch(
|
||||
() => [auth.hydrated, auth.user],
|
||||
([hydrated, user]) => {
|
||||
if (!hydrated || !user) return
|
||||
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : '/me')
|
||||
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : mePath())
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
@@ -65,7 +66,7 @@ async function submit() {
|
||||
try {
|
||||
if (mode.value === 'signup') await auth.signup(email.value, password.value)
|
||||
else await auth.login(email.value, password.value)
|
||||
router.push(typeof route.query.redirect === 'string' ? route.query.redirect : '/me')
|
||||
router.push(typeof route.query.redirect === 'string' ? route.query.redirect : mePath())
|
||||
} catch (e) {
|
||||
error.value = '로그인/회원가입에 실패했어요.'
|
||||
}
|
||||
@@ -87,7 +88,8 @@ async function submit() {
|
||||
</section>
|
||||
|
||||
<section v-else class="authScreen">
|
||||
<div class="authTabs" role="tablist" aria-label="로그인 또는 회원가입">
|
||||
<div class="authTabs" :class="{ 'authTabs--signup': mode === 'signup' }" role="tablist" aria-label="로그인 또는 회원가입">
|
||||
<span class="authTabs__indicator" aria-hidden="true"></span>
|
||||
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'login' }" @click="mode = 'login'">
|
||||
로그인
|
||||
</button>
|
||||
@@ -132,7 +134,7 @@ async function submit() {
|
||||
<div v-if="!hasUsers" class="roleBadge">첫 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>
|
||||
|
||||
<div class="authActions">
|
||||
<button class="secondaryAction" type="button" @click="router.push('/')">취소</button>
|
||||
<button class="secondaryAction" type="button" @click="router.push(homePath())">취소</button>
|
||||
<button class="primaryAction" type="submit">{{ submitLabel }}</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -159,16 +161,37 @@ async function submit() {
|
||||
}
|
||||
|
||||
.authTabs {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
gap: 0;
|
||||
width: fit-content;
|
||||
padding: 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-pill-bg);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.authTabs__indicator {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
width: calc(50% - 6px);
|
||||
height: calc(100% - 12px);
|
||||
border-radius: 999px;
|
||||
background: rgba(76, 133, 245, 0.22);
|
||||
box-shadow: inset 0 0 0 1px rgba(120, 169, 255, 0.1);
|
||||
transform: translateX(0);
|
||||
transition: transform 220ms ease, background-color 220ms ease, box-shadow 220ms ease;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.authTabs--signup .authTabs__indicator {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.authTabs__button {
|
||||
position: relative;
|
||||
min-width: 112px;
|
||||
padding: 10px 16px;
|
||||
border: 0;
|
||||
@@ -177,10 +200,11 @@ async function submit() {
|
||||
color: var(--theme-text-muted);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: color 180ms ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.authTabs__button--active {
|
||||
background: rgba(76, 133, 245, 0.22);
|
||||
color: var(--theme-text-strong);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import { editorPath, loginPath } from '../lib/paths'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
@@ -54,22 +55,20 @@ onMounted(async () => {
|
||||
myLists.value = data.tierLists || []
|
||||
} catch (e) {
|
||||
toast.error('로그인이 필요해요.')
|
||||
router.push('/login?redirect=/me')
|
||||
router.push(loginPath('/me'))
|
||||
}
|
||||
})
|
||||
|
||||
function openList(t) {
|
||||
router.push(
|
||||
"/editor/" + t.gameId + "/" + t.id,
|
||||
)
|
||||
router.push(editorPath(t.gameId, t.id))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Library</div>
|
||||
<h2 class="pageHead__title">내 티어표</h2>
|
||||
<div class="pageHead__eyebrow">Tier Lists</div>
|
||||
<h2 class="pageHead__title">나의 티어표</h2>
|
||||
<div class="pageHead__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 수 있어요.</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -85,6 +84,7 @@ function openList(t) {
|
||||
class="boardCard__thumb"
|
||||
:src="tierListThumbnailUrl(t)"
|
||||
alt=""
|
||||
draggable="false"
|
||||
@error="handleThumbnailError(t.id)"
|
||||
/>
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
@@ -96,7 +96,7 @@ function openList(t) {
|
||||
</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<div class="boardCard__author">
|
||||
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
|
||||
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" draggable="false" />
|
||||
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
|
||||
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { homePath, loginPath } from '../lib/paths'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
@@ -40,7 +41,7 @@ const displayInitial = computed(() => {
|
||||
onMounted(async () => {
|
||||
if (!auth.hydrated) await auth.refresh()
|
||||
if (!auth.user) {
|
||||
router.replace('/login')
|
||||
router.replace(loginPath())
|
||||
return
|
||||
}
|
||||
nickname.value = auth.user?.nickname || ''
|
||||
@@ -112,7 +113,7 @@ async function saveProfile() {
|
||||
async function logout() {
|
||||
await auth.logout()
|
||||
toast.success('로그아웃했어요.')
|
||||
router.push('/')
|
||||
router.push(homePath())
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -121,7 +122,7 @@ async function logout() {
|
||||
<header class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Account</div>
|
||||
<h2 class="pageHead__title">Settings</h2>
|
||||
<h2 class="pageHead__title">설정</h2>
|
||||
<div class="pageHead__desc">계정 정보를 간결하게 정리하고, 프로필 이미지를 클릭해서 바로 변경할 수 있어요.</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -134,7 +135,7 @@ async function logout() {
|
||||
<div class="settingsIdentity">
|
||||
<div class="avatarButtonWrap">
|
||||
<button class="avatarButton" type="button" @click="openAvatarPicker">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="avatarButton__image" alt="avatar" />
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="avatarButton__image" alt="avatar" draggable="false" />
|
||||
<div v-else class="avatarButton__fallback">{{ displayInitial }}</div>
|
||||
<div class="avatarButton__overlay">
|
||||
<span>{{ avatarUrl ? '이미지 변경' : '이미지 추가' }}</span>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { editorPath } from '../lib/paths'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -37,7 +38,7 @@ function tierListThumbnailUrl(tierList) {
|
||||
}
|
||||
|
||||
function openTierList(tierList) {
|
||||
router.push(`/editor/${tierList.gameId}/${tierList.id}`)
|
||||
router.push(editorPath(tierList.gameId, tierList.id))
|
||||
}
|
||||
|
||||
async function loadResults() {
|
||||
@@ -65,13 +66,13 @@ watch(
|
||||
|
||||
<template>
|
||||
<section class="wrap">
|
||||
<div class="head">
|
||||
<div>
|
||||
<div class="head__eyebrow">Search</div>
|
||||
<h2 class="title">전체 티어표 검색</h2>
|
||||
<div class="desc">공개된 모든 티어표를 제목과 작성자 기준으로 찾아볼 수 있어요.</div>
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Search</div>
|
||||
<h2 class="pageHead__title">전체 티어표 검색</h2>
|
||||
<div class="pageHead__desc">공개된 티어표를 제목과 작성자 기준으로 다시 찾아볼 수 있어요.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div v-else-if="loading" class="empty">검색 중이에요.</div>
|
||||
@@ -80,7 +81,7 @@ watch(
|
||||
<article v-for="tierList in tierLists" :key="tierList.id" class="boardCard">
|
||||
<button class="boardCard__body" @click="openTierList(tierList)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" />
|
||||
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" draggable="false" />
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="boardCard__head">
|
||||
@@ -92,7 +93,7 @@ watch(
|
||||
</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<div class="boardCard__author">
|
||||
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" />
|
||||
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" draggable="false" />
|
||||
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
|
||||
<span class="boardCard__authorName">{{ displayNameOf(tierList) }}</span>
|
||||
</div>
|
||||
@@ -110,30 +111,6 @@ watch(
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px 2px 8px;
|
||||
}
|
||||
.head__eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--theme-text-soft);
|
||||
}
|
||||
.title {
|
||||
margin: 4px 0 0;
|
||||
font-size: 32px;
|
||||
color: var(--theme-text-strong);
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.desc {
|
||||
margin-top: 6px;
|
||||
color: var(--theme-text-muted);
|
||||
}
|
||||
.error {
|
||||
margin: 0 0 8px;
|
||||
padding: 10px 12px;
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 { api } from '../lib/api'
|
||||
import { editorNewPath, editorPath, loginPath, mePath, topicPath } from '../lib/paths'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useToast } from '../composables/useToast'
|
||||
@@ -19,10 +20,10 @@ const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
const globalRightRailOpen = inject('rightRailOpen', ref(true))
|
||||
const localRightRailTarget = inject('localRightRailTarget', '#local-right-rail-root')
|
||||
const gameId = computed(() => route.params.gameId)
|
||||
const templateId = computed(() => route.params.topicId || route.params.gameId)
|
||||
const tierListId = computed(() => route.params.tierListId)
|
||||
const previewMode = computed(() => route.query.preview === '1')
|
||||
const gameName = ref('')
|
||||
const templateName = ref('')
|
||||
|
||||
const columns = ref([{ id: 'col-1', name: '' }])
|
||||
const groups = ref([
|
||||
@@ -123,19 +124,19 @@ const copiedFromLabel = computed(() => {
|
||||
const customItems = computed(() => getOrderedItems().filter((item) => item?.origin === 'custom'))
|
||||
const hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new')))
|
||||
const canRequestTemplateCreate = computed(
|
||||
() => canEdit.value && hasSavedTierList.value && gameId.value === 'freeform' && customItems.value.length > 0
|
||||
() => canEdit.value && hasSavedTierList.value && templateId.value === 'freeform' && customItems.value.length > 0
|
||||
)
|
||||
const canRequestTemplateUpdate = computed(
|
||||
() => canEdit.value && hasSavedTierList.value && gameId.value !== 'freeform' && customItems.value.length > 0
|
||||
() => canEdit.value && hasSavedTierList.value && templateId.value !== 'freeform' && customItems.value.length > 0
|
||||
)
|
||||
const canSubmitTemplateCreateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
|
||||
const canSubmitTemplateUpdateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
|
||||
const templateRequestTargetLabel = computed(() => (gameId.value === 'freeform' ? '새로운 템플릿' : (gameName.value || gameId.value || '선택한 게임')))
|
||||
const templateRequestTargetLabel = computed(() => (templateId.value === 'freeform' ? '새로운 템플릿' : (templateName.value || templateId.value || '선택한 주제')))
|
||||
const shareTierListUrl = computed(() => {
|
||||
const savedTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
|
||||
if (!savedTierListId) return ''
|
||||
if (typeof window === 'undefined') return `/editor/${gameId.value}/${savedTierListId}?preview=1`
|
||||
return new URL(`/editor/${gameId.value}/${savedTierListId}?preview=1`, window.location.origin).toString()
|
||||
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) => {
|
||||
@@ -672,7 +673,7 @@ function buildPayload(existingId) {
|
||||
const finalTitle = effectiveTitle.value
|
||||
return {
|
||||
id: existingId || undefined,
|
||||
gameId: gameId.value,
|
||||
gameId: templateId.value,
|
||||
title: finalTitle,
|
||||
thumbnailSrc: thumbnailSrc.value || '',
|
||||
description: (description.value || '').trim(),
|
||||
@@ -697,7 +698,7 @@ async function persistTierList({ showModal = false } = {}) {
|
||||
persistedTierListId.value = savedTierListId || ''
|
||||
title.value = res.tierList?.title || payload.title
|
||||
if (tierListId.value === 'new' && res.tierList?.id) {
|
||||
await router.replace(`/editor/${gameId.value}/${res.tierList.id}`)
|
||||
await router.replace(editorPath(templateId.value, res.tierList.id))
|
||||
}
|
||||
updatedAt.value = Number(res.tierList?.updatedAt || Date.now())
|
||||
authorName.value = res.tierList?.authorName || effectiveAuthorName.value
|
||||
@@ -793,7 +794,7 @@ async function confirmDeleteTierList() {
|
||||
await api.deleteTierList(currentTierListId)
|
||||
closeDeleteModal()
|
||||
toast.success('티어표를 삭제했어요.')
|
||||
router.push(gameId.value === 'freeform' ? '/me' : `/games/${gameId.value}`)
|
||||
router.push(templateId.value === 'freeform' ? mePath() : topicPath(templateId.value))
|
||||
} catch (e) {
|
||||
error.value = '티어표 삭제에 실패했어요.'
|
||||
} finally {
|
||||
@@ -808,7 +809,7 @@ async function duplicateCurrentTierList() {
|
||||
const duplicatedId = data.tierList?.id
|
||||
if (!duplicatedId) throw new Error('duplicate_failed')
|
||||
toast.success('티어표를 복사해 내 작업으로 가져왔어요.')
|
||||
router.push(`/editor/${gameId.value}/${duplicatedId}`)
|
||||
router.push(editorPath(templateId.value, duplicatedId))
|
||||
} catch (e) {
|
||||
error.value = '티어표 복사에 실패했어요.'
|
||||
}
|
||||
@@ -841,7 +842,7 @@ async function requestTemplate(type) {
|
||||
await api.requestTierListTemplate({
|
||||
type,
|
||||
sourceTierListId: sourceId,
|
||||
gameId: gameId.value,
|
||||
gameId: templateId.value,
|
||||
requestTitle: templateRequestDraftTitle.value.trim(),
|
||||
requestDescription: templateRequestDraftDescription.value.trim(),
|
||||
thumbnailSrc: saved.tierList?.thumbnailSrc || thumbnailSrc.value || '',
|
||||
@@ -892,13 +893,13 @@ onMounted(() => {
|
||||
authorAccountName.value = ((auth.user?.email || '').trim().split('@')[0] || '').trim()
|
||||
|
||||
if (isNewTierList.value && !auth.user) {
|
||||
router.replace(`/login?redirect=/editor/${gameId.value}/new`)
|
||||
router.replace(loginPath(editorNewPath(templateId.value)))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const gameRes = await api.getGame(gameId.value)
|
||||
gameName.value = gameRes.game?.name || gameId.value
|
||||
const gameRes = await api.getTopic(templateId.value)
|
||||
templateName.value = gameRes.game?.name || templateId.value
|
||||
const base = (gameRes.items || []).map((img) => ({
|
||||
id: img.id,
|
||||
src: img.src,
|
||||
@@ -910,7 +911,7 @@ onMounted(() => {
|
||||
itemsById.value = map
|
||||
pool.value = base.map((it) => it.id)
|
||||
} catch (e) {
|
||||
error.value = '게임 기본 이미지를 불러오지 못했어요.'
|
||||
error.value = '기본 템플릿 이미지를 불러오지 못했어요.'
|
||||
}
|
||||
|
||||
if (tierListId.value && tierListId.value !== 'new') {
|
||||
@@ -1021,7 +1022,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div class="requestChecklist__hint">
|
||||
제목과 설명을 함께 적어두면 관리자가 어떤 신규 템플릿인지 훨씬 빠르게 파악할 수 있어요.
|
||||
예시: 제목 `템플릿 등록 요청`, 설명 `여름 이벤트 한정 캐릭터 중심으로 새 게임 템플릿이 필요합니다.`
|
||||
예시: 제목 `템플릿 등록 요청`, 설명 `여름 이벤트 한정 캐릭터 중심으로 새 주제 템플릿이 필요합니다.`
|
||||
</div>
|
||||
<div class="templateRequestDraft">
|
||||
<label class="templateRequestDraft__field">
|
||||
@@ -1079,7 +1080,7 @@ onUnmounted(() => {
|
||||
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="deleteTierListTitle">
|
||||
<div id="deleteTierListTitle" class="modalCard__title">티어표 삭제</div>
|
||||
<div class="modalCard__desc">
|
||||
"{{ title || gameName || '이 티어표' }}"를 삭제할까요? 삭제 후에는 복구할 수 없어요.
|
||||
"{{ title || templateName || '이 티어표' }}"를 삭제할까요? 삭제 후에는 복구할 수 없어요.
|
||||
</div>
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--ghost" @click="closeDeleteModal">취소</button>
|
||||
@@ -1120,7 +1121,7 @@ onUnmounted(() => {
|
||||
<div class="editorMain">
|
||||
<section class="head">
|
||||
<div class="editorMain__headCopy">
|
||||
<div class="editorMain__title">{{ gameName || gameId }}</div>
|
||||
<div class="editorMain__title">{{ templateName || templateId }}</div>
|
||||
<div class="editorMain__subtitle">
|
||||
<template v-if="canEdit">
|
||||
행/열 이름과 순서를 바꾸고 아이템을 드래그해서 배치할 수 있어요.
|
||||
@@ -1131,7 +1132,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div v-if="sourceTierListId" class="editorMain__sourceNote">
|
||||
<span>복사본</span>
|
||||
<button class="editorMain__sourceLink" type="button" @click="router.push(`/editor/${gameId}/${sourceTierListId}`)">{{ copiedFromLabel }}</button>
|
||||
<button class="editorMain__sourceLink" type="button" @click="router.push(editorPath(templateId, sourceTierListId))">{{ copiedFromLabel }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||